Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 520a0f401c | |||
| 6adf1fe1eb | |||
| 2ac6559291 | |||
| 52618a0630 | |||
| e1a84fd568 | |||
| dd0bda05be | |||
| 4c68666c5c | |||
| 46b1fdc20f | |||
| 445cbc3100 | |||
| 8fe4473205 | |||
| 07dbd78962 | |||
| e9487a31c6 | |||
| 0fb4a7e359 |
@@ -1,227 +0,0 @@
|
|||||||
# AGENTS.go.md — Go Agent Rules
|
|
||||||
|
|
||||||
Applies to: `ai-compliance-sdk/` (Go/Gin service)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## NON-NEGOTIABLE: Pre-Push Checklist
|
|
||||||
|
|
||||||
**BEFORE every `git push`, run ALL of the following from the module root. A single failure blocks the push.**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. Format (gofmt is non-negotiable — unformatted code fails CI)
|
|
||||||
gofmt -l . | grep -q . && echo "FORMATTING ERRORS — run: gofmt -w ." && exit 1 || true
|
|
||||||
|
|
||||||
# 2. Vet (catches suspicious code that compiles but is likely wrong)
|
|
||||||
go vet ./...
|
|
||||||
|
|
||||||
# 3. Lint (golangci-lint aggregates 50+ linters — the de-facto standard)
|
|
||||||
golangci-lint run --timeout=5m ./...
|
|
||||||
|
|
||||||
# 4. Tests with race detector
|
|
||||||
go test -race -count=1 ./...
|
|
||||||
|
|
||||||
# 5. Build verification (catches import errors, missing implementations)
|
|
||||||
go build ./...
|
|
||||||
```
|
|
||||||
|
|
||||||
**One-liner pre-push gate:**
|
|
||||||
```bash
|
|
||||||
gofmt -l . | grep -q . && exit 1; go vet ./... && golangci-lint run --timeout=5m && go test -race -count=1 ./... && go build ./...
|
|
||||||
```
|
|
||||||
|
|
||||||
### Why each check matters
|
|
||||||
|
|
||||||
| Check | Catches | Time |
|
|
||||||
|-------|---------|------|
|
|
||||||
| `gofmt` | Formatting violations (CI rejects unformatted code) | <1s |
|
|
||||||
| `go vet` | Printf format mismatches, unreachable code, shadowed vars | <5s |
|
|
||||||
| `golangci-lint` | 50+ static analysis checks (errcheck, staticcheck, etc.) | 10-30s |
|
|
||||||
| `go test -race` | Race conditions (invisible without this flag) | 10-60s |
|
|
||||||
| `go build` | Import errors, interface mismatches | <5s |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## golangci-lint Configuration
|
|
||||||
|
|
||||||
Config lives in `.golangci.yml` at the repo root. Minimum required linters:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
linters:
|
|
||||||
enable:
|
|
||||||
- errcheck # unchecked errors are bugs
|
|
||||||
- gosimple # code simplification
|
|
||||||
- govet # go vet findings
|
|
||||||
- ineffassign # useless assignments
|
|
||||||
- staticcheck # advanced static analysis (SA*, S*, QF*)
|
|
||||||
- unused # unused code
|
|
||||||
- gofmt # formatting
|
|
||||||
- goimports # import organization
|
|
||||||
- gocritic # opinionated style checks
|
|
||||||
- noctx # HTTP requests without context
|
|
||||||
- bodyclose # unclosed HTTP response bodies
|
|
||||||
- exhaustive # exhaustive switch on enums
|
|
||||||
- wrapcheck # errors from external packages must be wrapped
|
|
||||||
|
|
||||||
linters-settings:
|
|
||||||
errcheck:
|
|
||||||
check-blank: true # blank identifier for errors is a bug
|
|
||||||
govet:
|
|
||||||
enable-all: true
|
|
||||||
|
|
||||||
issues:
|
|
||||||
max-issues-per-linter: 0
|
|
||||||
max-same-issues: 0
|
|
||||||
```
|
|
||||||
|
|
||||||
**Never suppress with `//nolint:` without a comment explaining why it's safe.**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Code Structure (Hexagonal Architecture)
|
|
||||||
|
|
||||||
```
|
|
||||||
ai-compliance-sdk/
|
|
||||||
├── cmd/
|
|
||||||
│ └── server/main.go # thin: parse flags, wire deps, call app.Run()
|
|
||||||
├── internal/
|
|
||||||
│ ├── app/ # dependency wiring
|
|
||||||
│ ├── domain/ # pure business logic, no framework deps
|
|
||||||
│ ├── ports/ # interfaces (repositories, external services)
|
|
||||||
│ ├── adapters/
|
|
||||||
│ │ ├── http/ # Gin handlers (≤30 LOC per handler)
|
|
||||||
│ │ ├── postgres/ # DB adapters implementing ports
|
|
||||||
│ │ └── external/ # third-party API clients
|
|
||||||
│ └── services/ # orchestration between domain + ports
|
|
||||||
└── pkg/ # exported, reusable packages
|
|
||||||
```
|
|
||||||
|
|
||||||
**Handler constraint — max 30 lines per handler:**
|
|
||||||
```go
|
|
||||||
func (h *RiskHandler) GetRisk(c *gin.Context) {
|
|
||||||
id, err := uuid.Parse(c.Param("id"))
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
risk, err := h.service.Get(c.Request.Context(), id)
|
|
||||||
if err != nil {
|
|
||||||
h.handleError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusOK, risk)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Error Handling
|
|
||||||
|
|
||||||
```go
|
|
||||||
// REQUIRED: wrap errors with context
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("get risk %s: %w", id, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// REQUIRED: define sentinel errors in domain package
|
|
||||||
var ErrNotFound = errors.New("not found")
|
|
||||||
var ErrUnauthorized = errors.New("unauthorized")
|
|
||||||
|
|
||||||
// REQUIRED: check errors — never use _ for error returns
|
|
||||||
result, err := service.Do(ctx, input)
|
|
||||||
if err != nil {
|
|
||||||
// handle it
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**`errcheck` linter enforces this — zero tolerance for unchecked errors.**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Testing Requirements
|
|
||||||
|
|
||||||
```
|
|
||||||
internal/
|
|
||||||
├── domain/
|
|
||||||
│ ├── risk.go
|
|
||||||
│ └── risk_test.go # unit: pure functions, no I/O
|
|
||||||
├── adapters/
|
|
||||||
│ ├── http/
|
|
||||||
│ │ ├── handler.go
|
|
||||||
│ │ └── handler_test.go # httptest-based, mock service
|
|
||||||
│ └── postgres/
|
|
||||||
│ ├── repo.go
|
|
||||||
│ └── repo_test.go # integration: testcontainers or real DB
|
|
||||||
```
|
|
||||||
|
|
||||||
**Test naming convention:**
|
|
||||||
```go
|
|
||||||
func TestRiskService_Get_ReturnsRisk(t *testing.T) {}
|
|
||||||
func TestRiskService_Get_NotFound_ReturnsError(t *testing.T) {}
|
|
||||||
func TestRiskService_Get_DBError_WrapsError(t *testing.T) {}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Table-driven tests are mandatory for functions with multiple cases:**
|
|
||||||
```go
|
|
||||||
func TestValidateInput(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
input string
|
|
||||||
wantErr bool
|
|
||||||
}{
|
|
||||||
{"valid", "ok", false},
|
|
||||||
{"empty", "", true},
|
|
||||||
{"too long", strings.Repeat("x", 300), true},
|
|
||||||
}
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
err := validateInput(tt.input)
|
|
||||||
if (err != nil) != tt.wantErr {
|
|
||||||
t.Errorf("got err=%v, wantErr=%v", err, tt.wantErr)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Pre-push: unit tests only (fast)
|
|
||||||
go test -race -count=1 -run "^TestUnit" ./...
|
|
||||||
|
|
||||||
# CI: all tests
|
|
||||||
go test -race -count=1 -coverprofile=coverage.out ./...
|
|
||||||
go tool cover -func=coverage.out | grep total
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Context Propagation
|
|
||||||
|
|
||||||
Every function that does I/O (DB, HTTP, file) **must** accept and pass `context.Context` as the first argument:
|
|
||||||
|
|
||||||
```go
|
|
||||||
// REQUIRED
|
|
||||||
func (r *RiskRepo) Get(ctx context.Context, id uuid.UUID) (*Risk, error) {
|
|
||||||
return r.db.QueryRowContext(ctx, query, id).Scan(...)
|
|
||||||
}
|
|
||||||
|
|
||||||
// FORBIDDEN — no context
|
|
||||||
func (r *RiskRepo) Get(id uuid.UUID) (*Risk, error) { ... }
|
|
||||||
```
|
|
||||||
|
|
||||||
`noctx` linter enforces HTTP client context. Manual review required for DB calls.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Common Pitfalls That Break CI
|
|
||||||
|
|
||||||
| Pitfall | Prevention |
|
|
||||||
|---------|------------|
|
|
||||||
| Unformatted code | `gofmt -w .` before commit |
|
|
||||||
| Unchecked error return from `rows.Close()` / `resp.Body.Close()` | `errcheck` + `bodyclose` linters |
|
|
||||||
| Goroutine leak (goroutine started but never stopped) | `-race` test flag |
|
|
||||||
| Shadowed `err` variable in nested scope | `govet -shadow` |
|
|
||||||
| HTTP response body not closed | `bodyclose` linter |
|
|
||||||
| `interface{}` instead of `any` (Go 1.18+) | `gocritic` |
|
|
||||||
| Missing context on DB/HTTP calls | `noctx` linter |
|
|
||||||
| Returning concrete type from constructor instead of interface | breaks testability |
|
|
||||||
@@ -1,157 +0,0 @@
|
|||||||
# AGENTS.python.md — Python Agent Rules
|
|
||||||
|
|
||||||
Applies to: `backend-compliance/`, `ai-compliance-sdk/` (Python path), `compliance-tts-service/`, `document-crawler/`, `dsms-gateway/` (Python services)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## NON-NEGOTIABLE: Pre-Push Checklist
|
|
||||||
|
|
||||||
**BEFORE every `git push`, run ALL of the following from the service directory. A single failure blocks the push.**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. Fast lint (Ruff — catches syntax errors, unused imports, style violations)
|
|
||||||
ruff check .
|
|
||||||
|
|
||||||
# 2. Auto-fix safe issues, then re-check
|
|
||||||
ruff check --fix . && ruff check .
|
|
||||||
|
|
||||||
# 3. Type checking (mypy strict on new modules, standard on legacy)
|
|
||||||
mypy . --ignore-missing-imports --no-error-summary
|
|
||||||
|
|
||||||
# 4. Unit tests only (fast, no external deps)
|
|
||||||
pytest tests/unit/ -x -q --no-header
|
|
||||||
|
|
||||||
# 5. Verify the service starts (catches import errors, missing env vars with defaults)
|
|
||||||
python -c "import app" 2>/dev/null || python -c "import main" 2>/dev/null || true
|
|
||||||
```
|
|
||||||
|
|
||||||
**One-liner pre-push gate (run from service root):**
|
|
||||||
```bash
|
|
||||||
ruff check . && mypy . --ignore-missing-imports --no-error-summary && pytest tests/ -x -q --no-header
|
|
||||||
```
|
|
||||||
|
|
||||||
### Why each check matters
|
|
||||||
|
|
||||||
| Check | Catches | Time |
|
|
||||||
|-------|---------|------|
|
|
||||||
| `ruff check` | Syntax errors, unused imports, undefined names | <2s |
|
|
||||||
| `mypy` | Type mismatches, wrong argument types | 5-15s |
|
|
||||||
| `pytest -x` | Logic errors, regressions | 10-60s |
|
|
||||||
| import check | Missing packages, circular imports | <1s |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Code Style (Ruff)
|
|
||||||
|
|
||||||
Config lives in `pyproject.toml`. Do **not** add per-file `# noqa` suppressions without a comment explaining why.
|
|
||||||
|
|
||||||
```toml
|
|
||||||
[tool.ruff]
|
|
||||||
line-length = 100
|
|
||||||
target-version = "py311"
|
|
||||||
|
|
||||||
[tool.ruff.lint]
|
|
||||||
select = ["E", "F", "W", "I", "N", "UP", "B", "C4", "SIM", "TCH"]
|
|
||||||
ignore = ["E501"] # line length handled by formatter
|
|
||||||
|
|
||||||
[tool.ruff.lint.per-file-ignores]
|
|
||||||
"tests/*" = ["S101"] # assert is fine in tests
|
|
||||||
```
|
|
||||||
|
|
||||||
**Blocked patterns:**
|
|
||||||
- `from module import *` — always name imports explicitly
|
|
||||||
- Bare `except:` — use `except Exception as e:` at minimum
|
|
||||||
- `print()` in production code — use `logger`
|
|
||||||
- Mutable default arguments: `def f(x=[])` → `def f(x=None)`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Type Annotations
|
|
||||||
|
|
||||||
All new functions **must** have complete type annotations. Use `from __future__ import annotations` for forward references.
|
|
||||||
|
|
||||||
```python
|
|
||||||
# Required
|
|
||||||
async def get_tenant(tenant_id: str, db: AsyncSession) -> TenantModel | None:
|
|
||||||
...
|
|
||||||
|
|
||||||
# Required for complex types
|
|
||||||
from typing import Sequence
|
|
||||||
def list_risks(filters: dict[str, str]) -> Sequence[RiskModel]:
|
|
||||||
...
|
|
||||||
```
|
|
||||||
|
|
||||||
**Mypy rules:**
|
|
||||||
- `--disallow-untyped-defs` on new files
|
|
||||||
- `--strict` on new modules (not legacy)
|
|
||||||
- Never use `type: ignore` without a comment
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## FastAPI-Specific Rules
|
|
||||||
|
|
||||||
```python
|
|
||||||
# Handlers stay thin — delegate to service layer
|
|
||||||
@router.get("/risks/{risk_id}", response_model=RiskResponse)
|
|
||||||
async def get_risk(risk_id: UUID, service: RiskService = Depends(get_risk_service)):
|
|
||||||
return await service.get(risk_id) # ≤5 lines per handler
|
|
||||||
|
|
||||||
# Always use response_model — never return raw dicts from endpoints
|
|
||||||
# Always validate input with Pydantic — no manual dict parsing
|
|
||||||
# Use HTTPException with specific status codes, never bare 500
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Testing Requirements
|
|
||||||
|
|
||||||
```
|
|
||||||
tests/
|
|
||||||
├── unit/ # Pure logic tests, no DB/HTTP (run on every push)
|
|
||||||
├── integration/ # Requires running services (run in CI only)
|
|
||||||
└── contracts/ # OpenAPI snapshot tests (run on API changes)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Unit test requirements:**
|
|
||||||
- Every new function → at least one happy-path test
|
|
||||||
- Every bug fix → regression test that would have caught it
|
|
||||||
- Mock all I/O: DB calls, HTTP calls, filesystem reads
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Run unit tests only (fast, for pre-push)
|
|
||||||
pytest tests/unit/ -x -q
|
|
||||||
|
|
||||||
# Run with coverage (for CI)
|
|
||||||
pytest tests/ --cov=. --cov-report=term-missing --cov-fail-under=70
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Dependency Management
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Check new package license before adding
|
|
||||||
pip show <package> | grep -E "License|Home-page"
|
|
||||||
|
|
||||||
# After adding to requirements.txt — verify no GPL/AGPL
|
|
||||||
pip-licenses --fail-on="GPL;AGPL" 2>/dev/null || echo "Check licenses manually"
|
|
||||||
```
|
|
||||||
|
|
||||||
**Never add:**
|
|
||||||
- GPL/AGPL licensed packages
|
|
||||||
- Packages with known CVEs (`pip audit`)
|
|
||||||
- Packages that only exist for dev (`pytest`, `ruff`) to production requirements
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Common Pitfalls That Break CI
|
|
||||||
|
|
||||||
| Pitfall | Prevention |
|
|
||||||
|---------|------------|
|
|
||||||
| `const x = ...` inside dict literal (wrong language!) | Run ruff before push |
|
|
||||||
| Pydantic v1 syntax in v2 project | Use `model_config`, not `class Config` |
|
|
||||||
| Sync function called inside async without `run_in_executor` | mypy + async linter |
|
|
||||||
| Missing `await` on coroutine | mypy catches this |
|
|
||||||
| `datetime.utcnow()` (deprecated) | Use `datetime.now(timezone.utc)` |
|
|
||||||
| Bare `except:` swallowing errors silently | ruff B001/E722 catches this |
|
|
||||||
| Unused imports left in committed code | ruff F401 catches this |
|
|
||||||
@@ -1,186 +0,0 @@
|
|||||||
# AGENTS.typescript.md — TypeScript/Next.js Agent Rules
|
|
||||||
|
|
||||||
Applies to: `pitch-deck/`, `admin-v2/` (Next.js apps in this repo)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## NON-NEGOTIABLE: Pre-Push Checklist
|
|
||||||
|
|
||||||
**BEFORE every `git push`, run ALL of the following from the Next.js app directory. A single failure blocks the push.**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. Type check (catches the class of bug that broke ChatFAB.tsx — const inside object)
|
|
||||||
npx tsc --noEmit
|
|
||||||
|
|
||||||
# 2. Lint (ESLint with TypeScript-aware rules)
|
|
||||||
npm run lint
|
|
||||||
|
|
||||||
# 3. Production build (THE most important check — passes lint/types but still fails build)
|
|
||||||
npm run build
|
|
||||||
```
|
|
||||||
|
|
||||||
**One-liner pre-push gate:**
|
|
||||||
```bash
|
|
||||||
npx tsc --noEmit && npm run lint && npm run build
|
|
||||||
```
|
|
||||||
|
|
||||||
> **Why `npm run build` is mandatory:** Next.js performs additional checks during build (server component boundaries, missing env vars referenced in code, RSC/client component violations) that `tsc` and ESLint alone do not catch. The ChatFAB syntax error (`const` inside object literal) is exactly the kind of error caught only by build.
|
|
||||||
|
|
||||||
### Why each check matters
|
|
||||||
|
|
||||||
| Check | Catches | Time |
|
|
||||||
|-------|---------|------|
|
|
||||||
| `tsc --noEmit` | Type errors, wrong prop types, missing members | 5-20s |
|
|
||||||
| `eslint` | React hooks rules, import order, unused vars | 5-15s |
|
|
||||||
| `next build` | Server/client boundary violations, missing deps, syntax errors in JSX, env var issues | 30-120s |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## TypeScript Configuration
|
|
||||||
|
|
||||||
`tsconfig.json` must have strict mode enabled:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"strict": true,
|
|
||||||
"noImplicitAny": true,
|
|
||||||
"strictNullChecks": true,
|
|
||||||
"noUncheckedIndexedAccess": true,
|
|
||||||
"exactOptionalPropertyTypes": true,
|
|
||||||
"noUnusedLocals": true,
|
|
||||||
"noUnusedParameters": true,
|
|
||||||
"noFallthroughCasesInSwitch": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Never use `// @ts-ignore` or `// @ts-expect-error` without a comment explaining why it's unavoidable.**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ESLint Configuration
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"extends": [
|
|
||||||
"next/core-web-vitals",
|
|
||||||
"plugin:@typescript-eslint/recommended-type-checked"
|
|
||||||
],
|
|
||||||
"rules": {
|
|
||||||
"@typescript-eslint/no-explicit-any": "error",
|
|
||||||
"@typescript-eslint/no-unused-vars": "error",
|
|
||||||
"@typescript-eslint/no-floating-promises": "error",
|
|
||||||
"@typescript-eslint/await-thenable": "error",
|
|
||||||
"react-hooks/exhaustive-deps": "error",
|
|
||||||
"no-console": "warn"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**`@typescript-eslint/no-floating-promises`** — catches `await`-less async calls that silently swallow errors.
|
|
||||||
**`react-hooks/exhaustive-deps`** — catches missing deps in `useEffect`/`useCallback` (source of stale closure bugs).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Next.js 15 Rules (App Router)
|
|
||||||
|
|
||||||
### Server vs Client boundary
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Server Component (default) — no 'use client' needed
|
|
||||||
// Can: fetch data, access DB, read env vars, import server-only packages
|
|
||||||
async function Page() {
|
|
||||||
const data = await fetchData() // direct async/await
|
|
||||||
return <ClientComponent data={data} />
|
|
||||||
}
|
|
||||||
|
|
||||||
// Client Component — must have 'use client' at top
|
|
||||||
'use client'
|
|
||||||
// Can: use hooks, handle events, access browser APIs
|
|
||||||
// Cannot: import server-only packages (nodemailer, fs, db pool)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Common violation:** Importing `lib/email.ts` (which imports nodemailer) from a client component → use `lib/email-templates.ts` instead.
|
|
||||||
|
|
||||||
### Route Handler typing
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Always type request and use NextResponse
|
|
||||||
export async function GET(request: Request): Promise<NextResponse> {
|
|
||||||
const { searchParams } = new URL(request.url)
|
|
||||||
return NextResponse.json({ data })
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Environment variables
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Server-only env vars: access directly
|
|
||||||
const secret = process.env.PITCH_ADMIN_SECRET // fine in server components
|
|
||||||
|
|
||||||
// Client env vars: must be prefixed NEXT_PUBLIC_
|
|
||||||
const url = process.env.NEXT_PUBLIC_API_URL // accessible in browser
|
|
||||||
|
|
||||||
// Never access server-only env vars in 'use client' components
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Component Architecture
|
|
||||||
|
|
||||||
```
|
|
||||||
app/
|
|
||||||
├── (route-group)/
|
|
||||||
│ ├── page.tsx # Server Component — data fetching
|
|
||||||
│ └── _components/ # Colocated components for this route
|
|
||||||
│ ├── ClientThing.tsx # 'use client' when needed
|
|
||||||
│ └── ServerThing.tsx # Server by default
|
|
||||||
components/
|
|
||||||
│ └── ui/ # Shared presentational components
|
|
||||||
lib/
|
|
||||||
│ ├── server-only-module.ts # import 'server-only' at top
|
|
||||||
│ └── shared-module.ts # safe for both server and client
|
|
||||||
```
|
|
||||||
|
|
||||||
**Rules:**
|
|
||||||
- Push `'use client'` boundary as deep as possible (toward leaves)
|
|
||||||
- Never import server-only modules from client components
|
|
||||||
- Colocate `_components/` and `_hooks/` per route when they're route-specific
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Testing Requirements
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Type check (fastest, run first)
|
|
||||||
npx tsc --noEmit
|
|
||||||
|
|
||||||
# Unit tests (Vitest)
|
|
||||||
npx vitest run
|
|
||||||
|
|
||||||
# E2E tests (Playwright — CI only, requires running server)
|
|
||||||
npx playwright test
|
|
||||||
```
|
|
||||||
|
|
||||||
**Test every:**
|
|
||||||
- Custom hook (`usePresenterMode`, `useSlideNavigation`)
|
|
||||||
- Utility function (`lib/auth.ts` helpers, `lib/email-templates.ts`)
|
|
||||||
- API route handler (mock DB, assert response shape)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Common Pitfalls That Break CI
|
|
||||||
|
|
||||||
| Pitfall | Prevention |
|
|
||||||
|---------|------------|
|
|
||||||
| `const x = ...` inside object literal | `tsc --noEmit` + `npm run build` |
|
|
||||||
| Server-only import in client component | `import 'server-only'` guard + ESLint |
|
|
||||||
| Missing `await` on async function call | `@typescript-eslint/no-floating-promises` |
|
|
||||||
| `useEffect` with missing dependency | `react-hooks/exhaustive-deps` error |
|
|
||||||
| `any` type hiding type errors | `@typescript-eslint/no-explicit-any` error |
|
|
||||||
| Unused variable left after refactor | `noUnusedLocals` in tsconfig |
|
|
||||||
| `process.env.SECRET` in client component | Next.js build error |
|
|
||||||
| Forgetting `export default` on page component | Next.js build error |
|
|
||||||
| Calling server action from server component | must use route handler instead |
|
|
||||||
| `jose` full import in Edge Runtime | Use specific subpath: `jose/jwt/verify` |
|
|
||||||
+29
-166
@@ -2,72 +2,28 @@
|
|||||||
|
|
||||||
## Entwicklungsumgebung (WICHTIG - IMMER ZUERST LESEN)
|
## Entwicklungsumgebung (WICHTIG - IMMER ZUERST LESEN)
|
||||||
|
|
||||||
### Zwei-Rechner-Setup + Orca
|
### Zwei-Rechner-Setup
|
||||||
|
|
||||||
| 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** | Lokaler Server | Docker fuer lokale Dev/Tests (NICHT fuer Production!) |
|
| **Mac Mini** | Server | Docker, alle Services, Tests, Builds, Deployment |
|
||||||
| **Orca** | Production | Automatisches Build + Deploy bei Push auf gitea |
|
|
||||||
|
|
||||||
**WICHTIG:** Code wird direkt auf dem MacBook in diesem Repo bearbeitet. Production-Deployment laeuft automatisch ueber Orca.
|
**WICHTIG:** Code wird direkt auf dem MacBook in diesem Repo bearbeitet. Docker und Services laufen auf dem Mac Mini.
|
||||||
|
|
||||||
### Entwicklungsworkflow (CI/CD — Orca)
|
### Entwicklungsworkflow
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. Code auf MacBook bearbeiten (dieses Verzeichnis)
|
# 1. Code auf MacBook bearbeiten (dieses Verzeichnis)
|
||||||
# 2. Committen und zu BEIDEN Remotes pushen:
|
# 2. Committen und pushen:
|
||||||
git push origin main
|
git push origin main && git push gitea main
|
||||||
|
|
||||||
# 3. FERTIG! Push auf gitea triggert automatisch:
|
# 3. Auf Mac Mini pullen und Container neu bauen:
|
||||||
# - Gitea Actions: Tests
|
|
||||||
# - Orca: Build → Deploy
|
|
||||||
```
|
|
||||||
|
|
||||||
**NIEMALS** manuell in Orca auf "Redeploy" klicken — Gitea Actions triggert Orca automatisch.
|
|
||||||
**IMMER auf `main` pushen** — sowohl origin als auch gitea.
|
|
||||||
|
|
||||||
### TEMPORAER: Compliance-Repo Refactoring (Stand 2026-04-12)
|
|
||||||
|
|
||||||
**Das Compliance-Repo wird aktuell auf Production (gitea) refakturiert.**
|
|
||||||
|
|
||||||
- **Core + Lehrer:** Normal auf `main` pushen (origin + gitea) ✅
|
|
||||||
- **Compliance auf Mac Mini (origin):** Normal auf `main` pushen ✅
|
|
||||||
- **Compliance auf Production (gitea):** **NUR Feature Branches**, NICHT auf `main` pushen! ⚠️
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Compliance-Repo — RICHTIG:
|
|
||||||
git push origin main # Mac Mini OK
|
|
||||||
git push gitea feature/mein-feature # Production: nur Feature Branch!
|
|
||||||
|
|
||||||
# Compliance-Repo — FALSCH (waehrend Refactoring):
|
|
||||||
# git push gitea main # NICHT MACHEN!
|
|
||||||
```
|
|
||||||
|
|
||||||
**Nach Abschluss des Refactorings:** Gesamten Compliance-Code einmalig von Production auf Mac Mini uebernehmen. User sagt Bescheid wann es soweit ist.
|
|
||||||
|
|
||||||
### Post-Push Deploy-Monitoring (PFLICHT nach jedem Push auf gitea)
|
|
||||||
|
|
||||||
**IMMER wenn Claude auf gitea pusht, MUSS danach automatisch das Deploy-Monitoring laufen:**
|
|
||||||
|
|
||||||
1. Dem User sofort mitteilen: "Deploy gestartet, ich ueberwache den Status..."
|
|
||||||
2. Im Hintergrund Health-Checks pollen (alle 20 Sekunden, max 5 Minuten):
|
|
||||||
```bash
|
|
||||||
curl -sf https://api-dev.breakpilot.ai/health # Compliance Backend
|
|
||||||
curl -sf https://sdk-dev.breakpilot.ai/health # AI SDK
|
|
||||||
```
|
|
||||||
3. Sobald ALLE Endpoints healthy sind, dem User im Chat melden:
|
|
||||||
**"Deploy abgeschlossen! Du kannst jetzt testen."**
|
|
||||||
4. Falls nach 5 Minuten noch nicht healthy → Fehlermeldung mit Hinweis auf Orca-Logs.
|
|
||||||
|
|
||||||
### Lokale Entwicklung (Mac Mini — optional, nur Dev/Tests)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ssh macmini "cd /Users/benjaminadmin/Projekte/breakpilot-core && git pull --no-rebase origin main"
|
ssh macmini "cd /Users/benjaminadmin/Projekte/breakpilot-core && git pull --no-rebase origin main"
|
||||||
ssh macmini "cd /Users/benjaminadmin/Projekte/breakpilot-core && /usr/local/bin/docker compose build --no-cache <service> && /usr/local/bin/docker compose up -d <service>"
|
ssh macmini "cd /Users/benjaminadmin/Projekte/breakpilot-core && /usr/local/bin/docker compose build --no-cache <service> && /usr/local/bin/docker compose up -d <service>"
|
||||||
```
|
```
|
||||||
|
|
||||||
### SSH-Verbindung (fuer lokale Docker/Tests)
|
### SSH-Verbindung (fuer Docker/Tests)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ssh macmini "cd /Users/benjaminadmin/Projekte/breakpilot-core && <cmd>"
|
ssh macmini "cd /Users/benjaminadmin/Projekte/breakpilot-core && <cmd>"
|
||||||
@@ -95,14 +51,6 @@ networks:
|
|||||||
name: breakpilot-network # Fixer Name, kein Auto-Prefix!
|
name: breakpilot-network # Fixer Name, kein Auto-Prefix!
|
||||||
```
|
```
|
||||||
|
|
||||||
### Deployment-Modell
|
|
||||||
|
|
||||||
| Repo | Deployment | Trigger |
|
|
||||||
|------|-----------|---------|
|
|
||||||
| **breakpilot-core** | Orca (automatisch) | Push auf gitea main |
|
|
||||||
| **breakpilot-compliance** | Orca (automatisch) | Push auf gitea main |
|
|
||||||
| **breakpilot-lehrer** | Mac Mini (lokal) | Manuell docker compose |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Haupt-URLs (via Nginx Reverse Proxy)
|
## Haupt-URLs (via Nginx Reverse Proxy)
|
||||||
@@ -213,7 +161,7 @@ networks:
|
|||||||
| `compliance` | Compliance | compliance_*, dsr, gdpr, sdk_tenants, consent_admin |
|
| `compliance` | Compliance | compliance_*, dsr, gdpr, sdk_tenants, consent_admin |
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# DB-Zugang (lokal)
|
# DB-Zugang
|
||||||
ssh macmini "docker exec bp-core-postgres psql -U breakpilot -d breakpilot_db"
|
ssh macmini "docker exec bp-core-postgres psql -U breakpilot -d breakpilot_db"
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -237,45 +185,15 @@ breakpilot-core/
|
|||||||
├── gitea/ # Gitea Config
|
├── gitea/ # Gitea Config
|
||||||
├── docs-src/ # MkDocs Quellen
|
├── docs-src/ # MkDocs Quellen
|
||||||
├── mkdocs.yml # MkDocs Config
|
├── mkdocs.yml # MkDocs Config
|
||||||
├── control-pipeline/ # RAG/Control Pipeline (Port 8098)
|
|
||||||
├── scripts/ # Helper Scripts
|
├── scripts/ # Helper Scripts
|
||||||
└── docker-compose.yml # Haupt-Compose (28+ Services)
|
└── docker-compose.yml # Haupt-Compose (28+ Services)
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Control Pipeline (WICHTIG)
|
|
||||||
|
|
||||||
**Seit 2026-04-09 liegt die gesamte RAG/Control-Pipeline im Core-Repo** (`control-pipeline/`), NICHT mehr im Compliance-Repo. Alle Arbeiten an der Pipeline (Pass 0a/0b, BatchDedup, Control Generator, Enrichment) finden ausschliesslich hier statt.
|
|
||||||
|
|
||||||
- **Port:** 8098
|
|
||||||
- **Container:** bp-core-control-pipeline
|
|
||||||
- **DB:** Schreibt ins `compliance`-Schema der shared PostgreSQL
|
|
||||||
- **Das Compliance-Repo wird NICHT fuer Pipeline-Aenderungen benutzt**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Container auf Mac Mini
|
|
||||||
ssh macmini "cd ~/Projekte/breakpilot-core && /usr/local/bin/docker compose build --no-cache control-pipeline && /usr/local/bin/docker compose up -d --no-deps control-pipeline"
|
|
||||||
|
|
||||||
# Health
|
|
||||||
ssh macmini "/usr/local/bin/docker exec bp-core-control-pipeline curl -sf http://127.0.0.1:8098/health"
|
|
||||||
|
|
||||||
# Logs
|
|
||||||
ssh macmini "/usr/local/bin/docker logs -f bp-core-control-pipeline"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Haeufige Befehle
|
## Haeufige Befehle
|
||||||
|
|
||||||
### Deployment (CI/CD — Standardweg)
|
### Docker
|
||||||
|
|
||||||
```bash
|
|
||||||
# Committen und pushen → Orca deployt automatisch:
|
|
||||||
git push origin main
|
|
||||||
```
|
|
||||||
|
|
||||||
### Lokale Docker-Befehle (Mac Mini — nur Dev/Tests)
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Alle Core-Services starten
|
# Alle Core-Services starten
|
||||||
@@ -293,90 +211,35 @@ ssh macmini "/usr/local/bin/docker ps --filter name=bp-core"
|
|||||||
|
|
||||||
**WICHTIG:** Docker-Pfad auf Mac Mini ist `/usr/local/bin/docker` (nicht im Standard-SSH-PATH).
|
**WICHTIG:** Docker-Pfad auf Mac Mini ist `/usr/local/bin/docker` (nicht im Standard-SSH-PATH).
|
||||||
|
|
||||||
|
### Alle 3 Projekte starten
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Core (MUSS zuerst!)
|
||||||
|
ssh macmini "cd /Users/benjaminadmin/Projekte/breakpilot-core && /usr/local/bin/docker compose up -d"
|
||||||
|
# Warten auf Health:
|
||||||
|
ssh macmini "curl -sf http://127.0.0.1:8099/health"
|
||||||
|
|
||||||
|
# 2. Lehrer
|
||||||
|
ssh macmini "cd /Users/benjaminadmin/Projekte/breakpilot-lehrer && /usr/local/bin/docker compose up -d"
|
||||||
|
|
||||||
|
# 3. Compliance
|
||||||
|
ssh macmini "cd /Users/benjaminadmin/Projekte/breakpilot-compliance && /usr/local/bin/docker compose up -d"
|
||||||
|
```
|
||||||
|
|
||||||
### Git
|
### Git
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Zu BEIDEN Remotes pushen (PFLICHT!):
|
# Zu BEIDEN Remotes pushen (PFLICHT!):
|
||||||
git push origin main
|
ssh macmini "cd /Users/benjaminadmin/Projekte/breakpilot-core && git push all main"
|
||||||
|
|
||||||
# Remotes:
|
# Remotes:
|
||||||
|
# origin: lokale Gitea (macmini:3003)
|
||||||
|
# gitea: gitea.meghsakha.com
|
||||||
|
# all: beide gleichzeitig
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Pre-Push Checks (PFLICHT — VOR JEDEM PUSH)
|
|
||||||
|
|
||||||
> Full detail: `.claude/rules/pre-push-checks.md` | Stack rules: `AGENTS.python.md`, `AGENTS.go.md`, `AGENTS.typescript.md`
|
|
||||||
|
|
||||||
**NIEMALS pushen ohne diese Checks. CI-Failures blockieren das gesamte Deploy.**
|
|
||||||
|
|
||||||
### Python (backend-core, rag-service, embedding-service, control-pipeline)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd <service-dir>
|
|
||||||
ruff check . && mypy . --ignore-missing-imports --no-error-summary && pytest tests/ -x -q --no-header
|
|
||||||
```
|
|
||||||
|
|
||||||
### Go (consent-service, billing-service)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd <service-dir>
|
|
||||||
gofmt -l . | grep -q . && exit 1; go vet ./... && golangci-lint run --timeout=5m && go test -race ./... && go build ./...
|
|
||||||
```
|
|
||||||
|
|
||||||
### TypeScript/Next.js (pitch-deck, admin-v2)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd pitch-deck # or admin-v2
|
|
||||||
npx tsc --noEmit && npm run lint && npm run build
|
|
||||||
```
|
|
||||||
|
|
||||||
> `npm run build` ist PFLICHT — `tsc` allein reicht nicht. Syntax-Fehler wie `const` inside object literal werden nur vom Build gefangen.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Code-Qualitaet Guardrails (NON-NEGOTIABLE)
|
|
||||||
|
|
||||||
> Vollstaendige Details: `.claude/rules/architecture.md`
|
|
||||||
> Ausnahmen: `.claude/rules/loc-exceptions.txt`
|
|
||||||
|
|
||||||
### File Size Budget
|
|
||||||
|
|
||||||
- **Hard Cap: 500 LOC** pro Datei
|
|
||||||
- Wenn eine Aenderung eine Datei ueber 500 LOC bringen wuerde: **erst splitten, dann aendern**
|
|
||||||
- Ausnahmen nur mit Begruendung in `loc-exceptions.txt` + `[guardrail-change]` Commit-Marker
|
|
||||||
|
|
||||||
### Architektur
|
|
||||||
|
|
||||||
- **Go:** Handler ≤40 LOC → Service-Layer → Repository-Pattern
|
|
||||||
- **Python:** Routes duenn → Business Logic in Services → Persistenz in Repositories
|
|
||||||
- **TypeScript/Next.js:** page.tsx duenn → _components/, _hooks/ auslagern
|
|
||||||
|
|
||||||
### FINGER WEG (laufende RAG Pipeline)
|
|
||||||
|
|
||||||
Diese Verzeichnisse duerfen NICHT refaktoriert werden:
|
|
||||||
- `control-pipeline/` — RAG/Control-Extraction Pipeline
|
|
||||||
- `rag-service/` — Semantische Suche
|
|
||||||
- `embedding-service/` — Text-Embeddings
|
|
||||||
- `voice-service/bqas/` — RAG Quality Assessment
|
|
||||||
|
|
||||||
### LOC-Check ausfuehren
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bash scripts/check-loc.sh --changed # nur geaenderte Dateien
|
|
||||||
bash scripts/check-loc.sh --all # alle Dateien (zeigt alle Violations)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Commit-Marker
|
|
||||||
|
|
||||||
- `[split-required]` — Aenderung beginnt mit Datei-Split
|
|
||||||
- `[guardrail-change]` — Aenderungen an .claude/**, scripts/check-loc.sh
|
|
||||||
- `[interface-change]` — Public API Contracts geaendert
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Kernprinzipien
|
## Kernprinzipien
|
||||||
|
|
||||||
### 1. Open Source Policy
|
### 1. Open Source Policy
|
||||||
|
|||||||
@@ -1,79 +0,0 @@
|
|||||||
# Architecture Rule — BreakPilot Core
|
|
||||||
|
|
||||||
## File Size Budget
|
|
||||||
|
|
||||||
Hard default: **500 LOC max** per file.
|
|
||||||
Soft targets:
|
|
||||||
- Handler/Router/Service: 300-400 LOC
|
|
||||||
- Models/Schemas/Types: 200-300 LOC
|
|
||||||
- Utilities: 100-200 LOC
|
|
||||||
|
|
||||||
Ausnahmen nur in `.claude/rules/loc-exceptions.txt` mit Begruendung.
|
|
||||||
|
|
||||||
## Split-Trigger
|
|
||||||
|
|
||||||
Sofort splitten wenn:
|
|
||||||
- Datei ueberschreitet 500 LOC
|
|
||||||
- Datei wuerde nach Aenderung 500 LOC ueberschreiten
|
|
||||||
- Datei mischt Transport + Business Logic + Persistence
|
|
||||||
- Datei enthaelt mehrere unabhaengig testbare Verantwortlichkeiten
|
|
||||||
|
|
||||||
## Go (consent-service, billing-service)
|
|
||||||
|
|
||||||
- Handler duenn halten (≤40 LOC pro Handler-Funktion)
|
|
||||||
- Business Logic in Services/Use-Cases
|
|
||||||
- Transport/Request-Decoding getrennt von Domain-Logik
|
|
||||||
- Dateien im gleichen Package teilen Typen automatisch — kein Re-Export noetig
|
|
||||||
- Models nach Domain splitten (user, consent, school, document, etc.)
|
|
||||||
|
|
||||||
## Python (backend-core, night-scheduler)
|
|
||||||
|
|
||||||
- Routes duenn halten — Business Logic in Services
|
|
||||||
- Persistenz in Repositories/Data-Access-Module
|
|
||||||
- Pydantic Schemas nach Domain splitten
|
|
||||||
- Zirkulaere Imports vermeiden
|
|
||||||
|
|
||||||
## TypeScript / Next.js (admin-core, pitch-deck)
|
|
||||||
|
|
||||||
- page.tsx duenn halten — Server Actions, Queries, Components auslagern
|
|
||||||
- _components/ + _hooks/ Konvention fuer Route-lokale Extracts
|
|
||||||
- .ts Dateien mit JSX muessen .tsx heissen (Turbopack!)
|
|
||||||
- Monolithische types.ts frueh splitten
|
|
||||||
- types.ts + types/ Shadowing vermeiden
|
|
||||||
|
|
||||||
## Entscheidungsreihenfolge
|
|
||||||
|
|
||||||
1. Bestehendes kleines kohaeesives Modul wiederverwenden
|
|
||||||
2. Neues Modul in der Naehe erstellen
|
|
||||||
3. Ueberfuellte Datei splitten, neues Verhalten in richtiges Split-Modul
|
|
||||||
4. Nur als letzter Ausweg: Grosse bestehende Datei erweitern
|
|
||||||
|
|
||||||
## FINGER WEG (laufende RAG Pipeline)
|
|
||||||
|
|
||||||
Diese Verzeichnisse duerfen NICHT refaktoriert werden:
|
|
||||||
- `control-pipeline/` — RAG/Control-Extraction Pipeline
|
|
||||||
- `rag-service/` — Semantische Suche
|
|
||||||
- `embedding-service/` — Text-Embeddings
|
|
||||||
- `voice-service/bqas/` — RAG Quality Assessment
|
|
||||||
|
|
||||||
## Workflow (bei jeder Aenderung)
|
|
||||||
|
|
||||||
1. Datei lesen + LOC pruefen
|
|
||||||
2. Wenn nahe am Budget → erst splitten
|
|
||||||
3. Minimale kohaerente Aenderung
|
|
||||||
4. Verifikation (Tests + Lint)
|
|
||||||
5. Zusammenfassung: Was geaendert, was verifiziert, Restrisiko
|
|
||||||
|
|
||||||
## LOC-Check ausfuehren
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bash scripts/check-loc.sh --changed # nur geaenderte Dateien
|
|
||||||
bash scripts/check-loc.sh --all # alle Dateien (zeigt alle Violations)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Commit-Marker
|
|
||||||
|
|
||||||
- `[split-required]` — Aenderung beginnt mit Datei-Split
|
|
||||||
- `[guardrail-change]` — Aenderungen an .claude/**, scripts/check-loc.sh
|
|
||||||
- `[interface-change]` — Public API Contracts geaendert
|
|
||||||
- `[migration-approved]` — Schema-/Migrations-Aenderungen
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
# LOC Exceptions — BreakPilot Core
|
|
||||||
# Format: <glob> | owner=<person> | reason=<why> | review=<date>
|
|
||||||
#
|
|
||||||
# Jede Ausnahme braucht Begruendung und Review-Datum.
|
|
||||||
# Temporaere Ausnahmen muessen mit [guardrail-change] Commit-Marker versehen werden.
|
|
||||||
|
|
||||||
# Generated / Build Artifacts
|
|
||||||
**/node_modules/** | owner=infra | reason=npm packages | review=permanent
|
|
||||||
**/.next/** | owner=infra | reason=Next.js build output | review=permanent
|
|
||||||
**/__pycache__/** | owner=infra | reason=Python bytecode | review=permanent
|
|
||||||
**/venv/** | owner=infra | reason=Python virtualenv | review=permanent
|
|
||||||
|
|
||||||
# Test-Dateien (duerfen groesser sein fuer Table-Driven Tests)
|
|
||||||
**/*test*.py | owner=all | reason=Tests mit Table-Driven Patterns duerfen groesser sein | review=permanent
|
|
||||||
**/*test*.go | owner=all | reason=Go Tests mit Table-Driven Patterns | review=permanent
|
|
||||||
**/*test*.ts | owner=all | reason=TypeScript Tests | review=permanent
|
|
||||||
**/tests/** | owner=all | reason=Test-Verzeichnisse | review=permanent
|
|
||||||
|
|
||||||
# FINGER WEG — Laufende RAG Pipeline (NICHT anfassen!)
|
|
||||||
control-pipeline/** | owner=pipeline | reason=Laufende RAG Pipeline, parallele Jobs aktiv | review=permanent
|
|
||||||
rag-service/** | owner=pipeline | reason=Semantische Suche, produktiv | review=permanent
|
|
||||||
embedding-service/** | owner=pipeline | reason=Text-Embeddings, produktiv | review=permanent
|
|
||||||
voice-service/bqas/** | owner=pipeline | reason=RAG Quality Assessment, produktiv | review=permanent
|
|
||||||
|
|
||||||
# Seed/Helper Scripts (keine Service-Logik)
|
|
||||||
scripts/seed-demo-and-screenshot.py | owner=infra | reason=Einmaliges Seed-Script, kein Service-Code | review=permanent
|
|
||||||
pitch-deck/scripts/import-finanzplan.py | owner=pitch-deck | reason=583 LOC, einmaliges Excel-Import-Script (9 Sheet-Importer), hardcodierte Row/Col-Mappings fuer eine Finanzplan-.xlsm-Datei, keine wiederverwendbare Logik | review=2027-01
|
|
||||||
|
|
||||||
# PDF Templates (reine statische HTML/CSS Strings, keine Logik)
|
|
||||||
backend-core/services/pdf_templates.py | owner=all | reason=519 LOC, rein statische Jinja2-HTML-Templates + CSS, keine Logik | review=2026-07
|
|
||||||
|
|
||||||
# Pitch Deck — pure data files (static text, translations, no logic)
|
|
||||||
pitch-deck/lib/presenter/presenter-faq.ts | owner=pitch-deck | reason=973 LOC, pure static FAQ array (questions/answers/keywords), no logic | review=2027-01
|
|
||||||
pitch-deck/lib/presenter/presenter-script.ts | owner=pitch-deck | reason=608 LOC, pure static presenter script data + 3 trivial lookup functions | review=2027-01
|
|
||||||
pitch-deck/lib/i18n.ts | owner=pitch-deck | reason=620 LOC, pure DE/EN translation dictionaries + 3 small format helpers | review=2027-01
|
|
||||||
|
|
||||||
# Marketing Website — adapted from pitch-deck USP slide (complex SVG animation, inline styles, no logic to split)
|
|
||||||
marketing-website/components/sections/PlatformBridgeSection.tsx | owner=marketing | reason=816 LOC, adapted 1:1 from pitch-deck USPSlide with SVG animations, CSS keyframes, inline styles — splitting would break animation coherence | review=2027-01
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
# Pre-Push Checks (MANDATORY)
|
|
||||||
|
|
||||||
## Rule
|
|
||||||
|
|
||||||
**NEVER push to any remote without first running and confirming ALL checks pass for every changed language stack.**
|
|
||||||
|
|
||||||
This rule exists because CI failures break the deploy pipeline for everyone and waste ~5 minutes per failed build. A 60-second local check prevents that.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Quick Reference by Stack
|
|
||||||
|
|
||||||
### Python (backend-compliance, ai-compliance-sdk, compliance-tts-service)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd <service-dir>
|
|
||||||
ruff check . && mypy . --ignore-missing-imports --no-error-summary && pytest tests/ -x -q --no-header
|
|
||||||
```
|
|
||||||
|
|
||||||
Blocks on: syntax errors, type errors, failing tests.
|
|
||||||
|
|
||||||
### Go (ai-compliance-sdk Go path)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd <service-dir>
|
|
||||||
gofmt -l . | grep -q . && exit 1; go vet ./... && golangci-lint run --timeout=5m && go test -race ./... && go build ./...
|
|
||||||
```
|
|
||||||
|
|
||||||
Blocks on: formatting, vet findings, lint violations, test failures, build errors.
|
|
||||||
|
|
||||||
### TypeScript/Next.js (admin-compliance, developer-portal)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd <nextjs-app-dir>
|
|
||||||
npx tsc --noEmit && npm run lint && npm run build
|
|
||||||
```
|
|
||||||
|
|
||||||
Blocks on: type errors, lint violations, **build failures**.
|
|
||||||
|
|
||||||
> `npm run build` is mandatory — `tsc` passes but `next build` fails more often than you'd expect (server/client boundary violations, env var issues, JSX syntax errors).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## What Claude Must Do Before Every Push
|
|
||||||
|
|
||||||
1. Identify which services/apps were changed in this task
|
|
||||||
2. Run the appropriate gate command(s) from the table above
|
|
||||||
3. If any check fails: fix it, re-run, confirm green
|
|
||||||
4. Only then run `git push origin main`
|
|
||||||
|
|
||||||
**No exceptions.** A push that skips pre-push checks and breaks CI is worse than a delayed push.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## CI vs Local Checks
|
|
||||||
|
|
||||||
| Stage | Where | What |
|
|
||||||
|-------|-------|------|
|
|
||||||
| Pre-push (local) | Claude runs | Lint + type check + unit tests + build |
|
|
||||||
| CI (Gitea Actions) | Automatic on push | Same + integration tests + contract tests |
|
|
||||||
| Deploy (Orca) | Automatic after CI | Docker build + health check |
|
|
||||||
|
|
||||||
Local checks catch 90% of CI failures in seconds. CI is the safety net, not the first line of defense.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Failures That Were Caused by Skipping Pre-Push Checks
|
|
||||||
|
|
||||||
- `ChatFAB.tsx`: `const textLang` inside fetch object literal — caught by `tsc --noEmit` and `npm run build`
|
|
||||||
- `nodemailer` webpack error: server-only import in client component — caught by `npm run build`
|
|
||||||
- `jose` Edge Runtime error: full package import — caught by `npm run build`
|
|
||||||
- `main.py` `<en>` tags spoken: missing `import re` — caught by `python -c "import main"`
|
|
||||||
|
|
||||||
These all caused a broken deploy. Each would have been caught in <60 seconds locally.
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
# Build + push pitch-deck Docker image to registry.meghsakha.com
|
|
||||||
# and trigger orca redeploy on every push to main that touches pitch-deck/.
|
|
||||||
#
|
|
||||||
# Requires Gitea Actions secret: ORCA_WEBHOOK_SECRET
|
|
||||||
# (must match the `secret` field in ~/.orca/webhooks.json on the orca master)
|
|
||||||
|
|
||||||
name: Build pitch-deck
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [main]
|
|
||||||
paths:
|
|
||||||
- 'pitch-deck/**'
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build-push-deploy:
|
|
||||||
runs-on: docker
|
|
||||||
container:
|
|
||||||
image: docker:27-cli
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
run: |
|
|
||||||
apk add --no-cache git openssl curl
|
|
||||||
git clone --depth 1 --branch ${GITHUB_REF_NAME} ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
|
|
||||||
|
|
||||||
- name: Login to registry
|
|
||||||
env:
|
|
||||||
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
|
|
||||||
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
|
|
||||||
run: |
|
|
||||||
echo "$REGISTRY_PASSWORD" | docker login registry.meghsakha.com -u "$REGISTRY_USERNAME" --password-stdin
|
|
||||||
|
|
||||||
- name: Build image
|
|
||||||
run: |
|
|
||||||
cd pitch-deck
|
|
||||||
SHORT_SHA=$(git rev-parse --short HEAD)
|
|
||||||
docker build \
|
|
||||||
--build-arg GIT_SHA=${SHORT_SHA} \
|
|
||||||
-t registry.meghsakha.com/breakpilot/pitch-deck:latest \
|
|
||||||
-t registry.meghsakha.com/breakpilot/pitch-deck:${SHORT_SHA} \
|
|
||||||
.
|
|
||||||
|
|
||||||
- name: Push to registry
|
|
||||||
run: |
|
|
||||||
SHORT_SHA=$(git rev-parse --short HEAD)
|
|
||||||
docker push registry.meghsakha.com/breakpilot/pitch-deck:latest
|
|
||||||
docker push registry.meghsakha.com/breakpilot/pitch-deck:${SHORT_SHA}
|
|
||||||
echo "Pushed :latest + :${SHORT_SHA}"
|
|
||||||
|
|
||||||
- name: Trigger orca redeploy
|
|
||||||
env:
|
|
||||||
ORCA_WEBHOOK_SECRET: ${{ secrets.ORCA_WEBHOOK_SECRET }}
|
|
||||||
ORCA_WEBHOOK_URL: http://46.225.100.82:6880/api/v1/webhooks/github
|
|
||||||
run: |
|
|
||||||
SHA=$(git rev-parse HEAD)
|
|
||||||
PAYLOAD="{\"ref\":\"refs/heads/main\",\"repository\":{\"full_name\":\"${GITHUB_REPOSITORY}\"},\"head_commit\":{\"id\":\"$SHA\",\"message\":\"ci: pitch-deck image build\"}}"
|
|
||||||
SIG=$(printf '%s' "$PAYLOAD" | openssl dgst -sha256 -hmac "$ORCA_WEBHOOK_SECRET" -r | awk '{print $1}')
|
|
||||||
curl -sSf -k \
|
|
||||||
-X POST \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-H "X-GitHub-Event: push" \
|
|
||||||
-H "X-Hub-Signature-256: sha256=$SIG" \
|
|
||||||
-d "$PAYLOAD" \
|
|
||||||
"$ORCA_WEBHOOK_URL" \
|
|
||||||
|| { echo "Orca redeploy failed"; exit 1; }
|
|
||||||
echo "Orca redeploy triggered"
|
|
||||||
@@ -140,6 +140,20 @@ jobs:
|
|||||||
python -m pytest tests/bqas/ -v --tb=short || true
|
python -m pytest tests/bqas/ -v --tb=short || true
|
||||||
|
|
||||||
# ========================================
|
# ========================================
|
||||||
# Deploys now handled by per-service workflows (e.g. build-pitch-deck.yml)
|
# Deploy via Coolify (nur main, kein PR)
|
||||||
# which trigger orca webhooks directly after building + pushing the image.
|
|
||||||
# ========================================
|
# ========================================
|
||||||
|
|
||||||
|
deploy-coolify:
|
||||||
|
name: Deploy
|
||||||
|
runs-on: docker
|
||||||
|
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||||
|
needs:
|
||||||
|
- test-go-consent
|
||||||
|
container:
|
||||||
|
image: alpine:latest
|
||||||
|
steps:
|
||||||
|
- name: Trigger Coolify deploy
|
||||||
|
run: |
|
||||||
|
apk add --no-cache curl
|
||||||
|
curl -sf "${{ secrets.COOLIFY_WEBHOOK }}" \
|
||||||
|
-H "Authorization: Bearer ${{ secrets.COOLIFY_TOKEN }}"
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
name: Deploy to Coolify
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- coolify
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: docker
|
||||||
|
container: alpine:latest
|
||||||
|
steps:
|
||||||
|
- name: Deploy via Coolify API
|
||||||
|
run: |
|
||||||
|
apk add --no-cache curl
|
||||||
|
echo "Deploying breakpilot-core to Coolify..."
|
||||||
|
HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
|
||||||
|
-X POST \
|
||||||
|
-H "Authorization: Bearer ${{ secrets.COOLIFY_API_TOKEN }}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"uuid": "${{ secrets.COOLIFY_RESOURCE_UUID }}", "force_rebuild": true}' \
|
||||||
|
"${{ secrets.COOLIFY_BASE_URL }}/api/v1/deploy")
|
||||||
|
|
||||||
|
echo "HTTP Status: $HTTP_STATUS"
|
||||||
|
if [ "$HTTP_STATUS" -ne 200 ] && [ "$HTTP_STATUS" -ne 201 ]; then
|
||||||
|
echo "Deployment failed with status $HTTP_STATUS"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "Deployment triggered successfully!"
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
# Daily GDPR data cleanup for the pitch deck.
|
|
||||||
# Calls /api/admin/cleanup which runs runDataCleanup():
|
|
||||||
# - anonymizes investors inactive 30+ days
|
|
||||||
# - anonymizes never-activated invites after 90 days
|
|
||||||
# - deletes sessions + magic links older than 30 days
|
|
||||||
# - anonymizes IPs in audit logs older than 30 days
|
|
||||||
#
|
|
||||||
# Requires Gitea Actions secret: PITCH_ADMIN_SECRET
|
|
||||||
|
|
||||||
name: Pitch deck — GDPR cleanup
|
|
||||||
|
|
||||||
on:
|
|
||||||
schedule:
|
|
||||||
- cron: '0 2 * * *'
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
cleanup:
|
|
||||||
runs-on: docker
|
|
||||||
container:
|
|
||||||
image: alpine:3.19
|
|
||||||
steps:
|
|
||||||
- name: Run data cleanup
|
|
||||||
env:
|
|
||||||
PITCH_ADMIN_SECRET: ${{ secrets.PITCH_ADMIN_SECRET }}
|
|
||||||
run: |
|
|
||||||
apk add --no-cache curl
|
|
||||||
RESPONSE=$(curl -sSf -w "\n%{http_code}" -X POST \
|
|
||||||
-H "Authorization: Bearer $PITCH_ADMIN_SECRET" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
https://pitch.breakpilot.com/api/admin/cleanup) \
|
|
||||||
|| { echo "Cleanup request failed"; exit 1; }
|
|
||||||
HTTP_CODE=$(echo "$RESPONSE" | tail -n1)
|
|
||||||
BODY=$(echo "$RESPONSE" | head -n-1)
|
|
||||||
echo "Response: $BODY"
|
|
||||||
[ "$HTTP_CODE" = "200" ] || { echo "Unexpected status $HTTP_CODE"; exit 1; }
|
|
||||||
echo "GDPR cleanup completed successfully"
|
|
||||||
@@ -7,7 +7,6 @@
|
|||||||
secrets/
|
secrets/
|
||||||
*.pem
|
*.pem
|
||||||
*.key
|
*.key
|
||||||
.mcp.json
|
|
||||||
|
|
||||||
# Node
|
# Node
|
||||||
node_modules/
|
node_modules/
|
||||||
|
|||||||
@@ -1,154 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import type { ContainerInfo, DockerStats } from '../types'
|
|
||||||
|
|
||||||
export function DeploymentsTab({
|
|
||||||
dockerStats,
|
|
||||||
containerFilter,
|
|
||||||
setContainerFilter,
|
|
||||||
filteredContainers,
|
|
||||||
onContainerAction,
|
|
||||||
actionLoading,
|
|
||||||
onRefresh,
|
|
||||||
}: {
|
|
||||||
dockerStats: DockerStats | null
|
|
||||||
containerFilter: 'all' | 'running' | 'stopped'
|
|
||||||
setContainerFilter: (filter: 'all' | 'running' | 'stopped') => void
|
|
||||||
filteredContainers: ContainerInfo[]
|
|
||||||
onContainerAction: (containerId: string, action: 'start' | 'stop' | 'restart') => void
|
|
||||||
actionLoading: string | null
|
|
||||||
onRefresh: () => void
|
|
||||||
}) {
|
|
||||||
const getStateColor = (state: string) => {
|
|
||||||
switch (state) {
|
|
||||||
case 'running': return 'bg-green-100 text-green-800'
|
|
||||||
case 'exited':
|
|
||||||
case 'dead': return 'bg-red-100 text-red-800'
|
|
||||||
case 'paused': return 'bg-yellow-100 text-yellow-800'
|
|
||||||
case 'restarting': return 'bg-blue-100 text-blue-800'
|
|
||||||
default: return 'bg-slate-100 text-slate-600'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h3 className="text-lg font-semibold text-slate-800">Docker Container</h3>
|
|
||||||
{dockerStats && (
|
|
||||||
<p className="text-sm text-slate-600">
|
|
||||||
{dockerStats.running_containers} laufend, {dockerStats.stopped_containers} gestoppt, {dockerStats.total_containers} gesamt
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<select
|
|
||||||
value={containerFilter}
|
|
||||||
onChange={(e) => setContainerFilter(e.target.value as typeof containerFilter)}
|
|
||||||
className="px-3 py-1.5 text-sm border border-slate-300 rounded-lg bg-white"
|
|
||||||
>
|
|
||||||
<option value="all">Alle</option>
|
|
||||||
<option value="running">Laufend</option>
|
|
||||||
<option value="stopped">Gestoppt</option>
|
|
||||||
</select>
|
|
||||||
<button
|
|
||||||
onClick={onRefresh}
|
|
||||||
className="px-3 py-1.5 text-sm border border-slate-300 text-slate-700 rounded-lg hover:bg-slate-50"
|
|
||||||
>
|
|
||||||
Aktualisieren
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Container List */}
|
|
||||||
{filteredContainers.length === 0 ? (
|
|
||||||
<div className="text-center py-8 text-slate-500">Keine Container gefunden</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-3">
|
|
||||||
{filteredContainers.map((container) => (
|
|
||||||
<div
|
|
||||||
key={container.id}
|
|
||||||
className={`border rounded-xl p-4 transition-colors ${
|
|
||||||
container.state === 'running'
|
|
||||||
? 'border-green-200 bg-green-50/30'
|
|
||||||
: 'border-slate-200 bg-slate-50/50'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="flex items-start justify-between gap-4">
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="flex items-center gap-2 mb-1">
|
|
||||||
<span className="font-semibold text-slate-900 truncate">{container.name}</span>
|
|
||||||
<span className={`px-2 py-0.5 text-xs font-medium rounded-full ${getStateColor(container.state)}`}>
|
|
||||||
{container.state}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-slate-500 mb-2">
|
|
||||||
<span className="font-mono">{container.image}</span>
|
|
||||||
{container.ports.length > 0 && (
|
|
||||||
<span className="ml-2 text-slate-400">
|
|
||||||
| {container.ports.slice(0, 2).join(', ')}
|
|
||||||
{container.ports.length > 2 && ` +${container.ports.length - 2}`}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{container.state === 'running' && (
|
|
||||||
<div className="flex flex-wrap gap-4 text-sm">
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<span className="text-slate-500">CPU:</span>
|
|
||||||
<span className={`font-medium ${container.cpu_percent > 80 ? 'text-red-600' : 'text-slate-700'}`}>
|
|
||||||
{container.cpu_percent.toFixed(1)}%
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<span className="text-slate-500">RAM:</span>
|
|
||||||
<span className={`font-medium ${container.memory_percent > 80 ? 'text-red-600' : 'text-slate-700'}`}>
|
|
||||||
{container.memory_usage}
|
|
||||||
</span>
|
|
||||||
<span className="text-slate-400">({container.memory_percent.toFixed(1)}%)</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<span className="text-slate-500">Net:</span>
|
|
||||||
<span className="text-slate-700">{container.network_rx} / {container.network_tx}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2 flex-shrink-0">
|
|
||||||
{container.state === 'running' ? (
|
|
||||||
<>
|
|
||||||
<button
|
|
||||||
onClick={() => onContainerAction(container.id, 'restart')}
|
|
||||||
disabled={actionLoading !== null}
|
|
||||||
className="px-3 py-1.5 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 transition-colors"
|
|
||||||
>
|
|
||||||
{actionLoading === `${container.id}-restart` ? '...' : 'Restart'}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => onContainerAction(container.id, 'stop')}
|
|
||||||
disabled={actionLoading !== null}
|
|
||||||
className="px-3 py-1.5 text-sm bg-red-600 text-white rounded-lg hover:bg-red-700 disabled:opacity-50 transition-colors"
|
|
||||||
>
|
|
||||||
{actionLoading === `${container.id}-stop` ? '...' : 'Stop'}
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
onClick={() => onContainerAction(container.id, 'start')}
|
|
||||||
disabled={actionLoading !== null}
|
|
||||||
className="px-3 py-1.5 text-sm bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50 transition-colors"
|
|
||||||
>
|
|
||||||
{actionLoading === `${container.id}-start` ? '...' : 'Start'}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,168 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import type { PipelineStatus, PipelineRun, SystemStats, DockerStats } from '../types'
|
|
||||||
|
|
||||||
function ProgressBar({ percent, color = 'blue' }: { percent: number; color?: string }) {
|
|
||||||
const getColor = () => {
|
|
||||||
if (percent > 90) return 'bg-red-500'
|
|
||||||
if (percent > 70) return 'bg-yellow-500'
|
|
||||||
if (color === 'green') return 'bg-green-500'
|
|
||||||
if (color === 'purple') return 'bg-purple-500'
|
|
||||||
return 'bg-blue-500'
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="w-full bg-slate-200 rounded-full h-2">
|
|
||||||
<div
|
|
||||||
className={`h-2 rounded-full transition-all duration-300 ${getColor()}`}
|
|
||||||
style={{ width: `${Math.min(percent, 100)}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function OverviewTab({
|
|
||||||
pipelineStatus,
|
|
||||||
pipelineHistory,
|
|
||||||
systemStats,
|
|
||||||
dockerStats,
|
|
||||||
}: {
|
|
||||||
pipelineStatus: PipelineStatus | null
|
|
||||||
pipelineHistory: PipelineRun[]
|
|
||||||
systemStats: SystemStats | null
|
|
||||||
dockerStats: DockerStats | null
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Status Cards */}
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
|
||||||
<div className={`p-4 rounded-lg ${pipelineStatus?.gitea_connected ? 'bg-green-50' : 'bg-yellow-50'}`}>
|
|
||||||
<div className="flex items-center gap-2 mb-2">
|
|
||||||
<span className={`w-3 h-3 rounded-full ${pipelineStatus?.gitea_connected ? 'bg-green-500' : 'bg-yellow-500'}`}></span>
|
|
||||||
<span className="text-sm font-medium">Gitea Status</span>
|
|
||||||
</div>
|
|
||||||
<p className={`text-lg font-bold ${pipelineStatus?.gitea_connected ? 'text-green-700' : 'text-yellow-700'}`}>
|
|
||||||
{pipelineStatus?.gitea_connected ? 'Verbunden' : 'Nicht verbunden'}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-slate-500">http://macmini:3003</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-blue-50 p-4 rounded-lg">
|
|
||||||
<div className="flex items-center gap-2 mb-2">
|
|
||||||
<svg className="w-4 h-4 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
|
|
||||||
</svg>
|
|
||||||
<span className="text-sm font-medium">Pipeline Runs</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-lg font-bold text-blue-700">{pipelineStatus?.total_runs || 0}</p>
|
|
||||||
<p className="text-xs text-slate-500">{pipelineStatus?.successful_runs || 0} erfolgreich</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-purple-50 p-4 rounded-lg">
|
|
||||||
<div className="flex items-center gap-2 mb-2">
|
|
||||||
<svg className="w-4 h-4 text-purple-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2" />
|
|
||||||
</svg>
|
|
||||||
<span className="text-sm font-medium">Container</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-lg font-bold text-purple-700">{dockerStats?.running_containers || 0}</p>
|
|
||||||
<p className="text-xs text-slate-500">von {dockerStats?.total_containers || 0} laufend</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-slate-50 p-4 rounded-lg">
|
|
||||||
<div className="flex items-center gap-2 mb-2">
|
|
||||||
<svg className="w-4 h-4 text-slate-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
||||||
</svg>
|
|
||||||
<span className="text-sm font-medium">Letztes Update</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-lg font-bold text-slate-700">
|
|
||||||
{pipelineStatus?.last_sbom_update ? new Date(pipelineStatus.last_sbom_update).toLocaleDateString('de-DE') : 'Nie'}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-slate-500">
|
|
||||||
{pipelineStatus?.last_sbom_update ? new Date(pipelineStatus.last_sbom_update).toLocaleTimeString('de-DE') : '-'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* System Resources */}
|
|
||||||
{systemStats && (
|
|
||||||
<div className="bg-slate-50 rounded-lg p-4">
|
|
||||||
<h3 className="font-medium text-slate-800 mb-4 flex items-center gap-2">
|
|
||||||
<svg className="w-5 h-5 text-slate-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z" />
|
|
||||||
</svg>
|
|
||||||
Server Ressourcen ({systemStats.hostname})
|
|
||||||
</h3>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
||||||
<div className="bg-white rounded-lg p-3">
|
|
||||||
<div className="flex justify-between mb-2">
|
|
||||||
<span className="text-sm text-slate-600">CPU</span>
|
|
||||||
<span className={`font-bold ${systemStats.cpu.usage_percent > 80 ? 'text-red-600' : 'text-slate-900'}`}>
|
|
||||||
{systemStats.cpu.usage_percent.toFixed(1)}%
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<ProgressBar percent={systemStats.cpu.usage_percent} />
|
|
||||||
</div>
|
|
||||||
<div className="bg-white rounded-lg p-3">
|
|
||||||
<div className="flex justify-between mb-2">
|
|
||||||
<span className="text-sm text-slate-600">RAM</span>
|
|
||||||
<span className={`font-bold ${systemStats.memory.usage_percent > 80 ? 'text-red-600' : 'text-slate-900'}`}>
|
|
||||||
{systemStats.memory.usage_percent.toFixed(1)}%
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<ProgressBar percent={systemStats.memory.usage_percent} color="purple" />
|
|
||||||
</div>
|
|
||||||
<div className="bg-white rounded-lg p-3">
|
|
||||||
<div className="flex justify-between mb-2">
|
|
||||||
<span className="text-sm text-slate-600">Disk</span>
|
|
||||||
<span className={`font-bold ${systemStats.disk.usage_percent > 80 ? 'text-red-600' : 'text-slate-900'}`}>
|
|
||||||
{systemStats.disk.usage_percent.toFixed(1)}%
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<ProgressBar percent={systemStats.disk.usage_percent} color="green" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Recent Pipeline Runs */}
|
|
||||||
{pipelineHistory.length > 0 && (
|
|
||||||
<div className="bg-slate-50 rounded-lg p-4">
|
|
||||||
<h3 className="font-medium text-slate-800 mb-3">Letzte Pipeline Runs</h3>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{pipelineHistory.slice(0, 5).map((run) => (
|
|
||||||
<div key={run.id} className="flex items-center justify-between bg-white p-3 rounded-lg">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<span className={`w-2 h-2 rounded-full ${
|
|
||||||
run.status === 'success' ? 'bg-green-500' :
|
|
||||||
run.status === 'failed' ? 'bg-red-500' :
|
|
||||||
run.status === 'running' ? 'bg-yellow-500 animate-pulse' : 'bg-slate-400'
|
|
||||||
}`}></span>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium text-slate-800">{run.workflow || 'SBOM Pipeline'}</p>
|
|
||||||
<p className="text-xs text-slate-500">{run.branch} - {run.commit_sha.substring(0, 8)}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-right">
|
|
||||||
<p className={`text-sm font-medium ${
|
|
||||||
run.status === 'success' ? 'text-green-600' :
|
|
||||||
run.status === 'failed' ? 'text-red-600' :
|
|
||||||
run.status === 'running' ? 'text-yellow-600' : 'text-slate-600'
|
|
||||||
}`}>
|
|
||||||
{run.status === 'success' ? 'Erfolgreich' :
|
|
||||||
run.status === 'failed' ? 'Fehlgeschlagen' :
|
|
||||||
run.status === 'running' ? 'Laeuft...' : run.status}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-slate-500">
|
|
||||||
{new Date(run.started_at).toLocaleString('de-DE')}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,143 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import type { PipelineRun } from '../types'
|
|
||||||
|
|
||||||
export function PipelinesTab({
|
|
||||||
pipelineHistory,
|
|
||||||
triggeringPipeline,
|
|
||||||
onTriggerPipeline,
|
|
||||||
}: {
|
|
||||||
pipelineHistory: PipelineRun[]
|
|
||||||
triggeringPipeline: boolean
|
|
||||||
onTriggerPipeline: () => void
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Pipeline Controls */}
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h3 className="text-lg font-semibold text-slate-800">Gitea Actions Pipelines</h3>
|
|
||||||
<p className="text-sm text-slate-600">Workflows werden bei Push auf main/develop automatisch ausgefuehrt</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={onTriggerPipeline}
|
|
||||||
disabled={triggeringPipeline}
|
|
||||||
className="px-4 py-2 bg-orange-600 text-white rounded-lg font-medium hover:bg-orange-700 disabled:opacity-50 transition-colors flex items-center gap-2"
|
|
||||||
>
|
|
||||||
{triggeringPipeline ? (
|
|
||||||
<>
|
|
||||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
|
|
||||||
Laeuft...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
||||||
</svg>
|
|
||||||
Pipeline starten
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Available Pipelines */}
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
||||||
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
|
|
||||||
<div className="flex items-center gap-2 mb-2">
|
|
||||||
<span className="w-2 h-2 rounded-full bg-green-500"></span>
|
|
||||||
<span className="font-medium text-green-800">SBOM Pipeline</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-green-700 mb-2">Generiert Software Bill of Materials</p>
|
|
||||||
<p className="text-xs text-green-600">5 Jobs: generate, scan, license, upload, summary</p>
|
|
||||||
</div>
|
|
||||||
<div className="bg-slate-50 border border-slate-200 rounded-lg p-4 opacity-60">
|
|
||||||
<div className="flex items-center gap-2 mb-2">
|
|
||||||
<span className="w-2 h-2 rounded-full bg-slate-400"></span>
|
|
||||||
<span className="font-medium text-slate-600">Test Pipeline</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-slate-500 mb-2">Unit & Integration Tests</p>
|
|
||||||
<p className="text-xs text-slate-400">Geplant</p>
|
|
||||||
</div>
|
|
||||||
<div className="bg-slate-50 border border-slate-200 rounded-lg p-4 opacity-60">
|
|
||||||
<div className="flex items-center gap-2 mb-2">
|
|
||||||
<span className="w-2 h-2 rounded-full bg-slate-400"></span>
|
|
||||||
<span className="font-medium text-slate-600">Security Pipeline</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-slate-500 mb-2">SAST, SCA, Secrets Scan</p>
|
|
||||||
<p className="text-xs text-slate-400">Geplant</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Pipeline History */}
|
|
||||||
<div className="bg-slate-50 rounded-lg p-4">
|
|
||||||
<h4 className="font-medium text-slate-800 mb-4">Pipeline Historie</h4>
|
|
||||||
{pipelineHistory.length === 0 ? (
|
|
||||||
<div className="text-center py-8 text-slate-500">
|
|
||||||
Keine Pipeline-Runs vorhanden. Starten Sie die erste Pipeline!
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="w-full">
|
|
||||||
<thead>
|
|
||||||
<tr className="border-b border-slate-200">
|
|
||||||
<th className="text-left py-2 px-3 text-xs font-semibold text-slate-500 uppercase">Status</th>
|
|
||||||
<th className="text-left py-2 px-3 text-xs font-semibold text-slate-500 uppercase">Workflow</th>
|
|
||||||
<th className="text-left py-2 px-3 text-xs font-semibold text-slate-500 uppercase">Branch</th>
|
|
||||||
<th className="text-left py-2 px-3 text-xs font-semibold text-slate-500 uppercase">Commit</th>
|
|
||||||
<th className="text-left py-2 px-3 text-xs font-semibold text-slate-500 uppercase">Gestartet</th>
|
|
||||||
<th className="text-left py-2 px-3 text-xs font-semibold text-slate-500 uppercase">Dauer</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-slate-100">
|
|
||||||
{pipelineHistory.map((run) => (
|
|
||||||
<tr key={run.id} className="hover:bg-white">
|
|
||||||
<td className="py-2 px-3">
|
|
||||||
<span className={`inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium ${
|
|
||||||
run.status === 'success' ? 'bg-green-100 text-green-800' :
|
|
||||||
run.status === 'failed' ? 'bg-red-100 text-red-800' :
|
|
||||||
run.status === 'running' ? 'bg-yellow-100 text-yellow-800' : 'bg-slate-100 text-slate-600'
|
|
||||||
}`}>
|
|
||||||
<span className={`w-1.5 h-1.5 rounded-full ${
|
|
||||||
run.status === 'success' ? 'bg-green-500' :
|
|
||||||
run.status === 'failed' ? 'bg-red-500' :
|
|
||||||
run.status === 'running' ? 'bg-yellow-500 animate-pulse' : 'bg-slate-400'
|
|
||||||
}`}></span>
|
|
||||||
{run.status}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="py-2 px-3 text-sm text-slate-900">{run.workflow || 'SBOM Pipeline'}</td>
|
|
||||||
<td className="py-2 px-3 text-sm text-slate-600">{run.branch}</td>
|
|
||||||
<td className="py-2 px-3 text-sm font-mono text-slate-500">{run.commit_sha.substring(0, 8)}</td>
|
|
||||||
<td className="py-2 px-3 text-sm text-slate-500">{new Date(run.started_at).toLocaleString('de-DE')}</td>
|
|
||||||
<td className="py-2 px-3 text-sm text-slate-500">
|
|
||||||
{run.duration_seconds ? `${run.duration_seconds}s` : '-'}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Pipeline Architecture */}
|
|
||||||
<div className="bg-slate-50 rounded-lg p-4">
|
|
||||||
<h4 className="font-medium text-slate-800 mb-3">SBOM Pipeline Architektur</h4>
|
|
||||||
<pre className="bg-slate-800 text-slate-100 p-4 rounded-lg overflow-x-auto text-sm">
|
|
||||||
{`Gitea Actions Pipeline (.gitea/workflows/sbom.yaml)
|
|
||||||
│
|
|
||||||
├── 1. generate-sbom → Syft generiert CycloneDX SBOM
|
|
||||||
│
|
|
||||||
├── 2. vulnerability-scan → Grype scannt auf CVEs
|
|
||||||
│
|
|
||||||
├── 3. license-check → Prueft GPL/AGPL Lizenzen
|
|
||||||
│
|
|
||||||
├── 4. upload-dashboard → POST /api/v1/security/sbom/upload
|
|
||||||
│
|
|
||||||
└── 5. summary → Job Summary generieren`}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,187 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
export function SchedulerTab() {
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Status Overview */}
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
||||||
<div className="rounded-xl border p-5 bg-emerald-100 border-emerald-200 text-emerald-700">
|
|
||||||
<div className="flex items-start gap-4">
|
|
||||||
<div className="flex-shrink-0">
|
|
||||||
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<h4 className="font-semibold">launchd Job</h4>
|
|
||||||
<span className="w-2 h-2 rounded-full bg-emerald-500" />
|
|
||||||
</div>
|
|
||||||
<p className="text-sm mt-1 opacity-80">Taeglich um 07:00 Uhr automatisch</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-xl border p-5 bg-emerald-100 border-emerald-200 text-emerald-700">
|
|
||||||
<div className="flex items-start gap-4">
|
|
||||||
<div className="flex-shrink-0">
|
|
||||||
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<h4 className="font-semibold">Git Hook</h4>
|
|
||||||
<span className="w-2 h-2 rounded-full bg-emerald-500" />
|
|
||||||
</div>
|
|
||||||
<p className="text-sm mt-1 opacity-80">Quick Tests bei voice-service Aenderungen</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-xl border p-5 bg-emerald-100 border-emerald-200 text-emerald-700">
|
|
||||||
<div className="flex items-start gap-4">
|
|
||||||
<div className="flex-shrink-0">
|
|
||||||
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<h4 className="font-semibold">Benachrichtigungen</h4>
|
|
||||||
<span className="w-2 h-2 rounded-full bg-emerald-500" />
|
|
||||||
</div>
|
|
||||||
<p className="text-sm mt-1 opacity-80">Desktop-Alerts bei Fehlern aktiviert</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Quick Actions */}
|
|
||||||
<div className="bg-slate-50 rounded-lg p-4">
|
|
||||||
<h3 className="font-medium text-slate-800 mb-4">Quick Actions (BQAS)</h3>
|
|
||||||
<div className="flex flex-wrap gap-3">
|
|
||||||
<a href="/ai/test-quality" className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 flex items-center gap-2">
|
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
||||||
</svg>
|
|
||||||
Test Dashboard oeffnen
|
|
||||||
</a>
|
|
||||||
<span className="text-sm text-slate-500 self-center">Starte Tests direkt im BQAS Dashboard</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* GitHub Actions vs Local - Comparison */}
|
|
||||||
<div className="bg-slate-50 rounded-lg p-4">
|
|
||||||
<h3 className="font-medium text-slate-800 mb-4">GitHub Actions Alternative</h3>
|
|
||||||
<p className="text-slate-600 mb-4">
|
|
||||||
Der lokale BQAS Scheduler ersetzt GitHub Actions und bietet DSGVO-konforme, vollstaendig lokale Test-Ausfuehrung.
|
|
||||||
</p>
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="w-full text-sm">
|
|
||||||
<thead>
|
|
||||||
<tr className="border-b border-slate-200 bg-white">
|
|
||||||
<th className="text-left py-3 px-4 font-medium text-slate-700">Feature</th>
|
|
||||||
<th className="text-center py-3 px-4 font-medium text-slate-700">GitHub Actions</th>
|
|
||||||
<th className="text-center py-3 px-4 font-medium text-slate-700">Lokaler Scheduler</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{[
|
|
||||||
{ feature: 'Taegliche Tests (07:00)', gh: 'schedule: cron', local: 'macOS launchd', localColor: 'bg-emerald-100 text-emerald-700' },
|
|
||||||
{ feature: 'Push-basierte Tests', gh: 'on: push', local: 'Git post-commit Hook', localColor: 'bg-emerald-100 text-emerald-700' },
|
|
||||||
{ feature: 'PR-basierte Tests', gh: 'on: pull_request', ghColor: 'bg-emerald-100 text-emerald-700', local: 'Nicht moeglich', localColor: 'bg-amber-100 text-amber-700' },
|
|
||||||
{ feature: 'DSGVO-Konformitaet', gh: 'Daten bei GitHub (US)', ghColor: 'bg-amber-100 text-amber-700', local: '100% lokal', localColor: 'bg-emerald-100 text-emerald-700' },
|
|
||||||
{ feature: 'Offline-Faehig', gh: 'Nein', ghColor: 'bg-red-100 text-red-700', local: 'Ja', localColor: 'bg-emerald-100 text-emerald-700' },
|
|
||||||
].map((row) => (
|
|
||||||
<tr key={row.feature} className="border-b border-slate-100">
|
|
||||||
<td className="py-3 px-4 text-slate-600">{row.feature}</td>
|
|
||||||
<td className="py-3 px-4 text-center">
|
|
||||||
<span className={`${row.ghColor ? `px-2 py-1 rounded text-xs font-medium ${row.ghColor}` : 'text-slate-600'}`}>{row.gh}</span>
|
|
||||||
</td>
|
|
||||||
<td className="py-3 px-4 text-center">
|
|
||||||
<span className={`px-2 py-1 rounded text-xs font-medium ${row.localColor}`}>{row.local}</span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Configuration Details */}
|
|
||||||
<div className="bg-slate-50 rounded-lg p-4">
|
|
||||||
<h3 className="font-medium text-slate-800 mb-4">Konfiguration</h3>
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
||||||
<div>
|
|
||||||
<h4 className="font-medium text-slate-700 mb-3">launchd Job</h4>
|
|
||||||
<div className="bg-slate-900 rounded-lg p-4 font-mono text-sm text-slate-100 overflow-x-auto">
|
|
||||||
<pre>{`# ~/Library/LaunchAgents/com.breakpilot.bqas.plist
|
|
||||||
Label: com.breakpilot.bqas
|
|
||||||
Schedule: 07:00 taeglich
|
|
||||||
Script: /voice-service/scripts/run_bqas.sh
|
|
||||||
Logs: /var/log/bqas/`}</pre>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h4 className="font-medium text-slate-700 mb-3">Umgebungsvariablen</h4>
|
|
||||||
<div className="space-y-2 text-sm">
|
|
||||||
<div className="flex justify-between p-2 bg-white rounded">
|
|
||||||
<span className="font-mono text-slate-600">BQAS_SERVICE_URL</span>
|
|
||||||
<span className="text-slate-900">http://localhost:8091</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between p-2 bg-white rounded">
|
|
||||||
<span className="font-mono text-slate-600">BQAS_REGRESSION_THRESHOLD</span>
|
|
||||||
<span className="text-slate-900">0.1</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between p-2 bg-white rounded">
|
|
||||||
<span className="font-mono text-slate-600">BQAS_NOTIFY_DESKTOP</span>
|
|
||||||
<span className="text-emerald-600 font-medium">true</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between p-2 bg-white rounded">
|
|
||||||
<span className="font-mono text-slate-600">BQAS_NOTIFY_SLACK</span>
|
|
||||||
<span className="text-slate-400">false</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Detailed Explanation */}
|
|
||||||
<div className="bg-gradient-to-r from-blue-50 to-indigo-50 rounded-xl border border-blue-200 p-6">
|
|
||||||
<h3 className="text-lg font-semibold text-slate-900 mb-4 flex items-center gap-2">
|
|
||||||
<svg className="w-5 h-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
||||||
</svg>
|
|
||||||
Detaillierte Erklaerung
|
|
||||||
</h3>
|
|
||||||
<div className="prose prose-sm max-w-none text-slate-700">
|
|
||||||
<h4 className="text-base font-semibold mt-4 mb-2">Warum ein lokaler Scheduler?</h4>
|
|
||||||
<p className="mb-4">
|
|
||||||
Der lokale BQAS Scheduler wurde entwickelt, um die gleiche Funktionalitaet wie GitHub Actions zu bieten,
|
|
||||||
aber mit dem entscheidenden Vorteil, dass <strong>alle Daten zu 100% auf dem lokalen Mac Mini verbleiben</strong>.
|
|
||||||
Dies ist besonders wichtig fuer DSGVO-Konformitaet, da keine Schuelerdaten oder Testergebnisse an externe Server uebertragen werden.
|
|
||||||
</p>
|
|
||||||
<h4 className="text-base font-semibold mt-4 mb-2">Komponenten</h4>
|
|
||||||
<ul className="list-disc list-inside space-y-2 mb-4">
|
|
||||||
<li><strong>run_bqas.sh</strong> - Hauptscript das pytest ausfuehrt, Regression-Checks macht und Benachrichtigungen versendet</li>
|
|
||||||
<li><strong>launchd Job</strong> - macOS-nativer Scheduler der das Script taeglich um 07:00 Uhr startet</li>
|
|
||||||
<li><strong>Git Hook</strong> - post-commit Hook der bei Aenderungen im voice-service automatisch Quick-Tests startet</li>
|
|
||||||
<li><strong>Notifier</strong> - Python-Modul das Desktop-, Slack- und E-Mail-Benachrichtigungen versendet</li>
|
|
||||||
</ul>
|
|
||||||
<h4 className="text-base font-semibold mt-4 mb-2">Installation</h4>
|
|
||||||
<div className="bg-slate-900 rounded-lg p-3 font-mono text-sm text-slate-100 mb-4">
|
|
||||||
<code>./voice-service/scripts/install_bqas_scheduler.sh install</code>
|
|
||||||
</div>
|
|
||||||
<h4 className="text-base font-semibold mt-4 mb-2">Vorteile gegenueber GitHub Actions</h4>
|
|
||||||
<ul className="list-disc list-inside space-y-1">
|
|
||||||
<li>100% DSGVO-konform - alle Daten bleiben lokal</li>
|
|
||||||
<li>Keine Internet-Abhaengigkeit - funktioniert auch offline</li>
|
|
||||||
<li>Keine GitHub-Kosten fuer private Repositories</li>
|
|
||||||
<li>Schnellere Ausfuehrung ohne Cloud-Overhead</li>
|
|
||||||
<li>Volle Kontrolle ueber Scheduling und Benachrichtigungen</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,152 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import type { PipelineStatus } from '../types'
|
|
||||||
|
|
||||||
export function SetupTab({ pipelineStatus }: { pipelineStatus: PipelineStatus | null }) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div>
|
|
||||||
<h3 className="text-lg font-semibold text-slate-800 mb-2">Erstkonfiguration - Gitea CI/CD</h3>
|
|
||||||
<p className="text-slate-600">
|
|
||||||
Anleitung zur Einrichtung der CI/CD Pipeline mit Gitea Actions auf dem Mac Mini Server.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Gitea Server Info */}
|
|
||||||
<div className="bg-blue-50 p-4 rounded-lg">
|
|
||||||
<h4 className="font-medium text-blue-800 mb-3 flex items-center gap-2">
|
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2" />
|
|
||||||
</svg>
|
|
||||||
Gitea Server
|
|
||||||
</h4>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
||||||
<div className="bg-white p-3 rounded-lg">
|
|
||||||
<p className="text-sm text-slate-500">Web-URL</p>
|
|
||||||
<p className="font-mono text-blue-700">http://macmini:3003</p>
|
|
||||||
</div>
|
|
||||||
<div className="bg-white p-3 rounded-lg">
|
|
||||||
<p className="text-sm text-slate-500">SSH</p>
|
|
||||||
<p className="font-mono text-blue-700">macmini:2222</p>
|
|
||||||
</div>
|
|
||||||
<div className="bg-white p-3 rounded-lg">
|
|
||||||
<p className="text-sm text-slate-500">Status</p>
|
|
||||||
<p className={`font-medium ${pipelineStatus?.gitea_connected ? 'text-green-600' : 'text-yellow-600'}`}>
|
|
||||||
{pipelineStatus?.gitea_connected ? 'Verbunden' : 'Konfiguration erforderlich'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Implementierte Komponenten */}
|
|
||||||
<div className="bg-slate-50 p-4 rounded-lg">
|
|
||||||
<h4 className="font-medium text-slate-800 mb-3">Implementierte Komponenten</h4>
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="min-w-full text-sm">
|
|
||||||
<thead>
|
|
||||||
<tr className="border-b border-slate-200">
|
|
||||||
<th className="text-left py-2 px-3 font-medium text-slate-600">Komponente</th>
|
|
||||||
<th className="text-left py-2 px-3 font-medium text-slate-600">Pfad</th>
|
|
||||||
<th className="text-left py-2 px-3 font-medium text-slate-600">Beschreibung</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-slate-100">
|
|
||||||
<tr>
|
|
||||||
<td className="py-2 px-3 font-medium">Gitea Service</td>
|
|
||||||
<td className="py-2 px-3"><code className="bg-slate-200 px-1 rounded text-xs">docker-compose.yml</code></td>
|
|
||||||
<td className="py-2 px-3 text-slate-600">Gitea 1.22 mit Actions enabled</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td className="py-2 px-3 font-medium">Gitea Runner</td>
|
|
||||||
<td className="py-2 px-3"><code className="bg-slate-200 px-1 rounded text-xs">docker-compose.yml</code></td>
|
|
||||||
<td className="py-2 px-3 text-slate-600">act_runner fuer Job-Ausfuehrung</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td className="py-2 px-3 font-medium">SBOM Workflow</td>
|
|
||||||
<td className="py-2 px-3"><code className="bg-slate-200 px-1 rounded text-xs">.gitea/workflows/sbom.yaml</code></td>
|
|
||||||
<td className="py-2 px-3 text-slate-600">5 Jobs: generate, scan, license, upload, summary</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td className="py-2 px-3 font-medium">Backend API</td>
|
|
||||||
<td className="py-2 px-3"><code className="bg-slate-200 px-1 rounded text-xs">backend/security_api.py</code></td>
|
|
||||||
<td className="py-2 px-3 text-slate-600">SBOM Upload, Pipeline Status, History</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td className="py-2 px-3 font-medium">Runner Config</td>
|
|
||||||
<td className="py-2 px-3"><code className="bg-slate-200 px-1 rounded text-xs">gitea/runner-config.yaml</code></td>
|
|
||||||
<td className="py-2 px-3 text-slate-600">Labels: ubuntu-latest, self-hosted</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Setup Steps */}
|
|
||||||
<div className="bg-orange-50 p-4 rounded-lg">
|
|
||||||
<h4 className="font-medium text-orange-800 mb-3 flex items-center gap-2">
|
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01" />
|
|
||||||
</svg>
|
|
||||||
Setup-Schritte
|
|
||||||
</h4>
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="bg-white p-3 rounded-lg">
|
|
||||||
<h5 className="font-medium text-slate-800 mb-1">1. Gitea oeffnen</h5>
|
|
||||||
<code className="text-sm bg-slate-100 px-2 py-1 rounded">http://macmini:3003</code>
|
|
||||||
</div>
|
|
||||||
<div className="bg-white p-3 rounded-lg">
|
|
||||||
<h5 className="font-medium text-slate-800 mb-1">2. Admin-Account erstellen</h5>
|
|
||||||
<p className="text-sm text-slate-600">Username: admin, Email: admin@breakpilot.de</p>
|
|
||||||
</div>
|
|
||||||
<div className="bg-white p-3 rounded-lg">
|
|
||||||
<h5 className="font-medium text-slate-800 mb-1">3. Repository erstellen</h5>
|
|
||||||
<p className="text-sm text-slate-600">Name: breakpilot-pwa, Visibility: Private</p>
|
|
||||||
</div>
|
|
||||||
<div className="bg-white p-3 rounded-lg">
|
|
||||||
<h5 className="font-medium text-slate-800 mb-1">4. Actions aktivieren</h5>
|
|
||||||
<p className="text-sm text-slate-600">Repository Settings → Actions → Enable Repository Actions</p>
|
|
||||||
</div>
|
|
||||||
<div className="bg-white p-3 rounded-lg">
|
|
||||||
<h5 className="font-medium text-slate-800 mb-1">5. Runner Token erstellen & starten</h5>
|
|
||||||
<pre className="text-xs bg-slate-100 p-2 rounded mt-1 overflow-x-auto">
|
|
||||||
{`export GITEA_RUNNER_TOKEN=<token>
|
|
||||||
docker compose up -d gitea-runner`}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
<div className="bg-white p-3 rounded-lg">
|
|
||||||
<h5 className="font-medium text-slate-800 mb-1">6. Repository pushen</h5>
|
|
||||||
<pre className="text-xs bg-slate-100 p-2 rounded mt-1 overflow-x-auto">
|
|
||||||
{`git remote add gitea http://macmini:3003/admin/breakpilot-pwa.git
|
|
||||||
git push gitea main`}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Quick Links */}
|
|
||||||
<div className="bg-purple-50 p-4 rounded-lg">
|
|
||||||
<h4 className="font-medium text-purple-800 mb-3">Quick Links</h4>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
|
||||||
<a href="http://macmini:3003" target="_blank" rel="noopener noreferrer" className="flex items-center justify-between bg-white p-3 rounded-lg hover:bg-purple-100 transition-colors">
|
|
||||||
<div>
|
|
||||||
<p className="font-medium text-purple-800">Gitea</p>
|
|
||||||
<p className="text-xs text-slate-500">Git Server & CI/CD</p>
|
|
||||||
</div>
|
|
||||||
<svg className="w-5 h-5 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
|
||||||
</svg>
|
|
||||||
</a>
|
|
||||||
<a href="http://macmini:3003/admin/breakpilot-pwa/actions" target="_blank" rel="noopener noreferrer" className="flex items-center justify-between bg-white p-3 rounded-lg hover:bg-purple-100 transition-colors">
|
|
||||||
<div>
|
|
||||||
<p className="font-medium text-purple-800">Pipeline Actions</p>
|
|
||||||
<p className="text-xs text-slate-500">Workflow Runs</p>
|
|
||||||
</div>
|
|
||||||
<svg className="w-5 h-5 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
|
||||||
</svg>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,72 +0,0 @@
|
|||||||
/**
|
|
||||||
* Types for CI/CD Dashboard
|
|
||||||
*/
|
|
||||||
|
|
||||||
export interface PipelineStatus {
|
|
||||||
gitea_connected: boolean
|
|
||||||
gitea_url: string
|
|
||||||
last_sbom_update: string | null
|
|
||||||
total_runs: number
|
|
||||||
successful_runs: number
|
|
||||||
failed_runs: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PipelineRun {
|
|
||||||
id: string
|
|
||||||
workflow: string
|
|
||||||
branch: string
|
|
||||||
commit_sha: string
|
|
||||||
status: 'success' | 'failed' | 'running' | 'pending'
|
|
||||||
started_at: string
|
|
||||||
finished_at: string | null
|
|
||||||
duration_seconds: number | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ContainerInfo {
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
image: string
|
|
||||||
status: string
|
|
||||||
state: string
|
|
||||||
created: string
|
|
||||||
ports: string[]
|
|
||||||
cpu_percent: number
|
|
||||||
memory_usage: string
|
|
||||||
memory_limit: string
|
|
||||||
memory_percent: number
|
|
||||||
network_rx: string
|
|
||||||
network_tx: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SystemStats {
|
|
||||||
hostname: string
|
|
||||||
platform: string
|
|
||||||
arch: string
|
|
||||||
uptime: number
|
|
||||||
cpu: {
|
|
||||||
model: string
|
|
||||||
cores: number
|
|
||||||
usage_percent: number
|
|
||||||
}
|
|
||||||
memory: {
|
|
||||||
total: string
|
|
||||||
used: string
|
|
||||||
free: string
|
|
||||||
usage_percent: number
|
|
||||||
}
|
|
||||||
disk: {
|
|
||||||
total: string
|
|
||||||
used: string
|
|
||||||
free: string
|
|
||||||
usage_percent: number
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DockerStats {
|
|
||||||
containers: ContainerInfo[]
|
|
||||||
total_containers: number
|
|
||||||
running_containers: number
|
|
||||||
stopped_containers: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export type TabType = 'overview' | 'pipelines' | 'deployments' | 'setup' | 'scheduler'
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
import type { MiddlewareConfig } from '../types'
|
|
||||||
import { getMiddlewareDescription } from './helpers'
|
|
||||||
|
|
||||||
interface ConfigTabProps {
|
|
||||||
configs: MiddlewareConfig[]
|
|
||||||
actionLoading: string | null
|
|
||||||
onToggle: (name: string, enabled: boolean) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ConfigTab({ configs, actionLoading, onToggle }: ConfigTabProps) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
{configs.map(config => {
|
|
||||||
const info = getMiddlewareDescription(config.middleware_name)
|
|
||||||
return (
|
|
||||||
<div key={config.id} className="bg-slate-50 rounded-lg p-4 border border-slate-200">
|
|
||||||
<div className="flex justify-between items-start mb-4">
|
|
||||||
<div>
|
|
||||||
<h3 className="font-semibold text-slate-900 flex items-center gap-2">
|
|
||||||
<span>{info.icon}</span>
|
|
||||||
<span className="capitalize">{config.middleware_name.replace('_', ' ')}</span>
|
|
||||||
</h3>
|
|
||||||
<p className="text-sm text-slate-600">{info.desc}</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<span
|
|
||||||
className={`px-3 py-1 rounded-full text-xs font-semibold ${
|
|
||||||
config.enabled ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{config.enabled ? 'Aktiviert' : 'Deaktiviert'}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
onClick={() => onToggle(config.middleware_name, !config.enabled)}
|
|
||||||
disabled={actionLoading === config.middleware_name}
|
|
||||||
className="px-4 py-1.5 text-sm border border-slate-300 rounded-lg hover:bg-slate-100 disabled:opacity-50 transition-colors"
|
|
||||||
>
|
|
||||||
{actionLoading === config.middleware_name
|
|
||||||
? '...'
|
|
||||||
: config.enabled
|
|
||||||
? 'Deaktivieren'
|
|
||||||
: 'Aktivieren'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{Object.keys(config.config).length > 0 && (
|
|
||||||
<div className="mt-3 pt-3 border-t border-slate-200">
|
|
||||||
<div className="text-xs font-semibold text-slate-500 uppercase mb-2">
|
|
||||||
Konfiguration
|
|
||||||
</div>
|
|
||||||
<pre className="text-xs bg-white p-3 rounded border border-slate-200 overflow-x-auto">
|
|
||||||
{JSON.stringify(config.config, null, 2)}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
import type { MiddlewareEvent } from '../types'
|
|
||||||
import { getEventTypeColor } from './helpers'
|
|
||||||
|
|
||||||
interface EventsTabProps {
|
|
||||||
events: MiddlewareEvent[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export function EventsTab({ events }: EventsTabProps) {
|
|
||||||
return (
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="w-full">
|
|
||||||
<thead>
|
|
||||||
<tr className="border-b border-slate-200">
|
|
||||||
<th className="text-left py-3 px-4 text-xs font-semibold text-slate-500 uppercase">
|
|
||||||
Zeit
|
|
||||||
</th>
|
|
||||||
<th className="text-left py-3 px-4 text-xs font-semibold text-slate-500 uppercase">
|
|
||||||
Middleware
|
|
||||||
</th>
|
|
||||||
<th className="text-left py-3 px-4 text-xs font-semibold text-slate-500 uppercase">
|
|
||||||
Event
|
|
||||||
</th>
|
|
||||||
<th className="text-left py-3 px-4 text-xs font-semibold text-slate-500 uppercase">
|
|
||||||
IP
|
|
||||||
</th>
|
|
||||||
<th className="text-left py-3 px-4 text-xs font-semibold text-slate-500 uppercase">
|
|
||||||
Pfad
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{events.length === 0 ? (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={5} className="text-center py-8 text-slate-500">
|
|
||||||
Keine Events vorhanden.
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
) : (
|
|
||||||
events.map(event => (
|
|
||||||
<tr key={event.id} className="border-b border-slate-100 hover:bg-slate-50">
|
|
||||||
<td className="py-3 px-4 text-sm text-slate-500">
|
|
||||||
{new Date(event.created_at).toLocaleString('de-DE')}
|
|
||||||
</td>
|
|
||||||
<td className="py-3 px-4 text-sm capitalize">
|
|
||||||
{event.middleware_name.replace('_', ' ')}
|
|
||||||
</td>
|
|
||||||
<td className="py-3 px-4">
|
|
||||||
<span
|
|
||||||
className={`px-2 py-1 rounded text-xs font-semibold ${getEventTypeColor(event.event_type)}`}
|
|
||||||
>
|
|
||||||
{event.event_type}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="py-3 px-4 text-sm font-mono text-slate-600">
|
|
||||||
{event.ip_address || '-'}
|
|
||||||
</td>
|
|
||||||
<td className="py-3 px-4 text-sm text-slate-600 max-w-xs truncate">
|
|
||||||
{event.request_method && event.request_path
|
|
||||||
? `${event.request_method} ${event.request_path}`
|
|
||||||
: '-'}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
import type { RateLimitIP } from '../types'
|
|
||||||
|
|
||||||
interface IpListTabProps {
|
|
||||||
ipList: RateLimitIP[]
|
|
||||||
actionLoading: string | null
|
|
||||||
newIP: string
|
|
||||||
newIPType: 'whitelist' | 'blacklist'
|
|
||||||
newIPReason: string
|
|
||||||
onNewIPChange: (value: string) => void
|
|
||||||
onNewIPTypeChange: (value: 'whitelist' | 'blacklist') => void
|
|
||||||
onNewIPReasonChange: (value: string) => void
|
|
||||||
onAddIP: (e: React.FormEvent) => void
|
|
||||||
onRemoveIP: (id: string) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export function IpListTab({
|
|
||||||
ipList,
|
|
||||||
actionLoading,
|
|
||||||
newIP,
|
|
||||||
newIPType,
|
|
||||||
newIPReason,
|
|
||||||
onNewIPChange,
|
|
||||||
onNewIPTypeChange,
|
|
||||||
onNewIPReasonChange,
|
|
||||||
onAddIP,
|
|
||||||
onRemoveIP,
|
|
||||||
}: IpListTabProps) {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
{/* Add IP Form */}
|
|
||||||
<form onSubmit={onAddIP} className="mb-6 p-4 bg-slate-50 rounded-lg border border-slate-200">
|
|
||||||
<h3 className="font-semibold text-slate-900 mb-4">IP hinzufuegen</h3>
|
|
||||||
<div className="flex flex-wrap gap-3">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={newIP}
|
|
||||||
onChange={e => onNewIPChange(e.target.value)}
|
|
||||||
placeholder="IP-Adresse (z.B. 192.168.1.1)"
|
|
||||||
className="flex-1 min-w-[200px] px-4 py-2 border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-orange-500"
|
|
||||||
/>
|
|
||||||
<select
|
|
||||||
value={newIPType}
|
|
||||||
onChange={e => onNewIPTypeChange(e.target.value as 'whitelist' | 'blacklist')}
|
|
||||||
className="px-4 py-2 border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-orange-500"
|
|
||||||
>
|
|
||||||
<option value="whitelist">Whitelist</option>
|
|
||||||
<option value="blacklist">Blacklist</option>
|
|
||||||
</select>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={newIPReason}
|
|
||||||
onChange={e => onNewIPReasonChange(e.target.value)}
|
|
||||||
placeholder="Grund (optional)"
|
|
||||||
className="flex-1 min-w-[150px] px-4 py-2 border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-orange-500"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={!newIP.trim() || actionLoading === 'add-ip'}
|
|
||||||
className="px-6 py-2 bg-orange-600 text-white rounded-lg font-medium hover:bg-orange-700 disabled:opacity-50 transition-colors"
|
|
||||||
>
|
|
||||||
{actionLoading === 'add-ip' ? 'Hinzufuegen...' : 'Hinzufuegen'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
{/* IP List Table */}
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="w-full">
|
|
||||||
<thead>
|
|
||||||
<tr className="border-b border-slate-200">
|
|
||||||
<th className="text-left py-3 px-4 text-xs font-semibold text-slate-500 uppercase">
|
|
||||||
IP-Adresse
|
|
||||||
</th>
|
|
||||||
<th className="text-left py-3 px-4 text-xs font-semibold text-slate-500 uppercase">
|
|
||||||
Typ
|
|
||||||
</th>
|
|
||||||
<th className="text-left py-3 px-4 text-xs font-semibold text-slate-500 uppercase">
|
|
||||||
Grund
|
|
||||||
</th>
|
|
||||||
<th className="text-left py-3 px-4 text-xs font-semibold text-slate-500 uppercase">
|
|
||||||
Hinzugefuegt
|
|
||||||
</th>
|
|
||||||
<th className="text-right py-3 px-4 text-xs font-semibold text-slate-500 uppercase">
|
|
||||||
Aktion
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{ipList.length === 0 ? (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={5} className="text-center py-8 text-slate-500">
|
|
||||||
Keine IP-Eintraege vorhanden.
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
) : (
|
|
||||||
ipList.map(ip => (
|
|
||||||
<tr key={ip.id} className="border-b border-slate-100 hover:bg-slate-50">
|
|
||||||
<td className="py-3 px-4 font-mono text-sm">{ip.ip_address}</td>
|
|
||||||
<td className="py-3 px-4">
|
|
||||||
<span
|
|
||||||
className={`px-2 py-1 rounded text-xs font-semibold ${
|
|
||||||
ip.list_type === 'whitelist'
|
|
||||||
? 'bg-green-100 text-green-800'
|
|
||||||
: 'bg-red-100 text-red-800'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{ip.list_type === 'whitelist' ? 'Whitelist' : 'Blacklist'}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="py-3 px-4 text-sm text-slate-600">{ip.reason || '-'}</td>
|
|
||||||
<td className="py-3 px-4 text-sm text-slate-500">
|
|
||||||
{new Date(ip.created_at).toLocaleString('de-DE')}
|
|
||||||
</td>
|
|
||||||
<td className="py-3 px-4 text-right">
|
|
||||||
<button
|
|
||||||
onClick={() => onRemoveIP(ip.id)}
|
|
||||||
disabled={actionLoading === `remove-${ip.id}`}
|
|
||||||
className="px-3 py-1 text-sm text-red-600 hover:bg-red-50 rounded transition-colors disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{actionLoading === `remove-${ip.id}` ? '...' : 'Entfernen'}
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
import type { MiddlewareConfig } from '../types'
|
|
||||||
import { getMiddlewareDescription } from './helpers'
|
|
||||||
|
|
||||||
interface OverviewTabProps {
|
|
||||||
configs: MiddlewareConfig[]
|
|
||||||
actionLoading: string | null
|
|
||||||
onToggle: (name: string, enabled: boolean) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export function OverviewTab({ configs, actionLoading, onToggle }: OverviewTabProps) {
|
|
||||||
return (
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
||||||
{configs.map(config => {
|
|
||||||
const info = getMiddlewareDescription(config.middleware_name)
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={config.id}
|
|
||||||
className={`rounded-lg p-4 border ${
|
|
||||||
config.enabled
|
|
||||||
? 'bg-green-50 border-green-200'
|
|
||||||
: 'bg-slate-50 border-slate-200'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="flex justify-between items-start mb-2">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-xl">{info.icon}</span>
|
|
||||||
<span className="font-semibold text-slate-900 capitalize">
|
|
||||||
{config.middleware_name.replace('_', ' ')}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => onToggle(config.middleware_name, !config.enabled)}
|
|
||||||
disabled={actionLoading === config.middleware_name}
|
|
||||||
className={`px-3 py-1 rounded-full text-xs font-semibold transition-colors ${
|
|
||||||
config.enabled
|
|
||||||
? 'bg-green-200 text-green-800 hover:bg-green-300'
|
|
||||||
: 'bg-slate-200 text-slate-600 hover:bg-slate-300'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{actionLoading === config.middleware_name
|
|
||||||
? '...'
|
|
||||||
: config.enabled
|
|
||||||
? 'Aktiv'
|
|
||||||
: 'Inaktiv'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-slate-600">{info.desc}</p>
|
|
||||||
{config.updated_at && (
|
|
||||||
<div className="mt-2 text-xs text-slate-400">
|
|
||||||
Aktualisiert: {new Date(config.updated_at).toLocaleString('de-DE')}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
import type { MiddlewareStats } from '../types'
|
|
||||||
import { getMiddlewareDescription, getEventTypeColor } from './helpers'
|
|
||||||
|
|
||||||
interface StatsTabProps {
|
|
||||||
stats: MiddlewareStats[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export function StatsTab({ stats }: StatsTabProps) {
|
|
||||||
return (
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
||||||
{stats.map(stat => {
|
|
||||||
const info = getMiddlewareDescription(stat.middleware_name)
|
|
||||||
return (
|
|
||||||
<div key={stat.middleware_name} className="bg-slate-50 rounded-lg p-4 border border-slate-200">
|
|
||||||
<h3 className="font-semibold text-slate-900 flex items-center gap-2 mb-4">
|
|
||||||
<span>{info.icon}</span>
|
|
||||||
<span className="capitalize">{stat.middleware_name.replace('_', ' ')}</span>
|
|
||||||
</h3>
|
|
||||||
<div className="grid grid-cols-3 gap-4 mb-4">
|
|
||||||
<div>
|
|
||||||
<div className="text-2xl font-bold text-slate-900">{stat.total_events}</div>
|
|
||||||
<div className="text-xs text-slate-500">Gesamt</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="text-2xl font-bold text-blue-600">{stat.events_last_hour}</div>
|
|
||||||
<div className="text-xs text-slate-500">Letzte Stunde</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="text-2xl font-bold text-orange-600">{stat.events_last_24h}</div>
|
|
||||||
<div className="text-xs text-slate-500">24 Stunden</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{stat.top_event_types.length > 0 && (
|
|
||||||
<div className="mb-3">
|
|
||||||
<div className="text-xs font-semibold text-slate-500 uppercase mb-2">
|
|
||||||
Top Event-Typen
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{stat.top_event_types.slice(0, 3).map(et => (
|
|
||||||
<span
|
|
||||||
key={et.event_type}
|
|
||||||
className={`px-2 py-1 rounded text-xs ${getEventTypeColor(et.event_type)}`}
|
|
||||||
>
|
|
||||||
{et.event_type} ({et.count})
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{stat.top_ips.length > 0 && (
|
|
||||||
<div>
|
|
||||||
<div className="text-xs font-semibold text-slate-500 uppercase mb-2">Top IPs</div>
|
|
||||||
<div className="text-xs text-slate-600">
|
|
||||||
{stat.top_ips
|
|
||||||
.slice(0, 3)
|
|
||||||
.map(ip => `${ip.ip_address} (${ip.count})`)
|
|
||||||
.join(', ')}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
interface StatusOverviewProps {
|
|
||||||
configCount: number
|
|
||||||
whitelistCount: number
|
|
||||||
blacklistCount: number
|
|
||||||
eventCount: number
|
|
||||||
loading: boolean
|
|
||||||
onRefresh: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export function StatusOverview({
|
|
||||||
configCount,
|
|
||||||
whitelistCount,
|
|
||||||
blacklistCount,
|
|
||||||
eventCount,
|
|
||||||
loading,
|
|
||||||
onRefresh,
|
|
||||||
}: StatusOverviewProps) {
|
|
||||||
return (
|
|
||||||
<div className="bg-white rounded-xl border border-slate-200 p-6 mb-6">
|
|
||||||
<div className="flex justify-between items-center mb-6">
|
|
||||||
<h2 className="text-xl font-semibold text-slate-900">Middleware Status</h2>
|
|
||||||
<button
|
|
||||||
onClick={onRefresh}
|
|
||||||
disabled={loading}
|
|
||||||
className="px-4 py-2 text-sm bg-orange-600 text-white rounded-lg hover:bg-orange-700 disabled:opacity-50 transition-colors"
|
|
||||||
>
|
|
||||||
{loading ? 'Laden...' : 'Aktualisieren'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
|
||||||
<div className="bg-slate-50 rounded-lg p-4 border border-slate-200">
|
|
||||||
<div className="text-2xl font-bold text-slate-900">{configCount}</div>
|
|
||||||
<div className="text-sm text-slate-600">Middleware</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-green-50 rounded-lg p-4 border border-green-200">
|
|
||||||
<div className="text-2xl font-bold text-green-600">{whitelistCount}</div>
|
|
||||||
<div className="text-sm text-slate-600">Whitelist IPs</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-red-50 rounded-lg p-4 border border-red-200">
|
|
||||||
<div className="text-2xl font-bold text-red-600">{blacklistCount}</div>
|
|
||||||
<div className="text-sm text-slate-600">Blacklist IPs</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-blue-50 rounded-lg p-4 border border-blue-200">
|
|
||||||
<div className="text-2xl font-bold text-blue-600">{eventCount}</div>
|
|
||||||
<div className="text-sm text-slate-600">Recent Events</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
export function getMiddlewareDescription(name: string): { icon: string; desc: string } {
|
|
||||||
const descriptions: Record<string, { icon: string; desc: string }> = {
|
|
||||||
request_id: { icon: '🆔', desc: 'Generiert eindeutige Request-IDs fuer Tracing' },
|
|
||||||
security_headers: { icon: '🛡️', desc: 'Fuegt Security-Header hinzu (CSP, HSTS, etc.)' },
|
|
||||||
cors: { icon: '🌐', desc: 'Cross-Origin Resource Sharing Konfiguration' },
|
|
||||||
rate_limiter: { icon: '⏱️', desc: 'Rate Limiting zum Schutz vor Missbrauch' },
|
|
||||||
pii_redactor: { icon: '🔒', desc: 'Redaktiert personenbezogene Daten in Logs' },
|
|
||||||
input_gate: { icon: '🚪', desc: 'Validiert und sanitisiert Eingaben' },
|
|
||||||
}
|
|
||||||
return descriptions[name] || { icon: '⚙️', desc: 'Middleware-Komponente' }
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getEventTypeColor(eventType: string) {
|
|
||||||
if (eventType.includes('error') || eventType.includes('blocked') || eventType.includes('blacklist')) {
|
|
||||||
return 'bg-red-100 text-red-800'
|
|
||||||
}
|
|
||||||
if (eventType.includes('warning') || eventType.includes('rate_limit')) {
|
|
||||||
return 'bg-yellow-100 text-yellow-800'
|
|
||||||
}
|
|
||||||
if (eventType.includes('success') || eventType.includes('whitelist')) {
|
|
||||||
return 'bg-green-100 text-green-800'
|
|
||||||
}
|
|
||||||
return 'bg-slate-100 text-slate-800'
|
|
||||||
}
|
|
||||||
@@ -9,13 +9,44 @@
|
|||||||
|
|
||||||
import { useEffect, useState, useCallback } from 'react'
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
import { PagePurpose } from '@/components/common/PagePurpose'
|
import { PagePurpose } from '@/components/common/PagePurpose'
|
||||||
import type { MiddlewareConfig, RateLimitIP, MiddlewareEvent, MiddlewareStats } from './types'
|
|
||||||
import { StatusOverview } from './_components/StatusOverview'
|
interface MiddlewareConfig {
|
||||||
import { OverviewTab } from './_components/OverviewTab'
|
id: string
|
||||||
import { ConfigTab } from './_components/ConfigTab'
|
middleware_name: string
|
||||||
import { IpListTab } from './_components/IpListTab'
|
enabled: boolean
|
||||||
import { EventsTab } from './_components/EventsTab'
|
config: Record<string, unknown>
|
||||||
import { StatsTab } from './_components/StatsTab'
|
updated_at: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RateLimitIP {
|
||||||
|
id: string
|
||||||
|
ip_address: string
|
||||||
|
list_type: 'whitelist' | 'blacklist'
|
||||||
|
reason: string | null
|
||||||
|
expires_at: string | null
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MiddlewareEvent {
|
||||||
|
id: string
|
||||||
|
middleware_name: string
|
||||||
|
event_type: string
|
||||||
|
ip_address: string | null
|
||||||
|
user_id: string | null
|
||||||
|
request_path: string | null
|
||||||
|
request_method: string | null
|
||||||
|
details: Record<string, unknown> | null
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MiddlewareStats {
|
||||||
|
middleware_name: string
|
||||||
|
total_events: number
|
||||||
|
events_last_hour: number
|
||||||
|
events_last_24h: number
|
||||||
|
top_event_types: Array<{ event_type: string; count: number }>
|
||||||
|
top_ips: Array<{ ip_address: string; count: number }>
|
||||||
|
}
|
||||||
|
|
||||||
export default function MiddlewareAdminPage() {
|
export default function MiddlewareAdminPage() {
|
||||||
const [configs, setConfigs] = useState<MiddlewareConfig[]>([])
|
const [configs, setConfigs] = useState<MiddlewareConfig[]>([])
|
||||||
@@ -153,6 +184,31 @@ export default function MiddlewareAdminPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getMiddlewareDescription = (name: string): { icon: string; desc: string } => {
|
||||||
|
const descriptions: Record<string, { icon: string; desc: string }> = {
|
||||||
|
request_id: { icon: '🆔', desc: 'Generiert eindeutige Request-IDs fuer Tracing' },
|
||||||
|
security_headers: { icon: '🛡️', desc: 'Fuegt Security-Header hinzu (CSP, HSTS, etc.)' },
|
||||||
|
cors: { icon: '🌐', desc: 'Cross-Origin Resource Sharing Konfiguration' },
|
||||||
|
rate_limiter: { icon: '⏱️', desc: 'Rate Limiting zum Schutz vor Missbrauch' },
|
||||||
|
pii_redactor: { icon: '🔒', desc: 'Redaktiert personenbezogene Daten in Logs' },
|
||||||
|
input_gate: { icon: '🚪', desc: 'Validiert und sanitisiert Eingaben' },
|
||||||
|
}
|
||||||
|
return descriptions[name] || { icon: '⚙️', desc: 'Middleware-Komponente' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const getEventTypeColor = (eventType: string) => {
|
||||||
|
if (eventType.includes('error') || eventType.includes('blocked') || eventType.includes('blacklist')) {
|
||||||
|
return 'bg-red-100 text-red-800'
|
||||||
|
}
|
||||||
|
if (eventType.includes('warning') || eventType.includes('rate_limit')) {
|
||||||
|
return 'bg-yellow-100 text-yellow-800'
|
||||||
|
}
|
||||||
|
if (eventType.includes('success') || eventType.includes('whitelist')) {
|
||||||
|
return 'bg-green-100 text-green-800'
|
||||||
|
}
|
||||||
|
return 'bg-slate-100 text-slate-800'
|
||||||
|
}
|
||||||
|
|
||||||
const whitelistCount = ipList.filter(ip => ip.list_type === 'whitelist').length
|
const whitelistCount = ipList.filter(ip => ip.list_type === 'whitelist').length
|
||||||
const blacklistCount = ipList.filter(ip => ip.list_type === 'blacklist').length
|
const blacklistCount = ipList.filter(ip => ip.list_type === 'blacklist').length
|
||||||
|
|
||||||
@@ -176,14 +232,38 @@ export default function MiddlewareAdminPage() {
|
|||||||
defaultCollapsed={true}
|
defaultCollapsed={true}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<StatusOverview
|
{/* Stats Overview */}
|
||||||
configCount={configs.length}
|
<div className="bg-white rounded-xl border border-slate-200 p-6 mb-6">
|
||||||
whitelistCount={whitelistCount}
|
<div className="flex justify-between items-center mb-6">
|
||||||
blacklistCount={blacklistCount}
|
<h2 className="text-xl font-semibold text-slate-900">Middleware Status</h2>
|
||||||
eventCount={events.length}
|
<button
|
||||||
loading={loading}
|
onClick={fetchData}
|
||||||
onRefresh={fetchData}
|
disabled={loading}
|
||||||
/>
|
className="px-4 py-2 text-sm bg-orange-600 text-white rounded-lg hover:bg-orange-700 disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
{loading ? 'Laden...' : 'Aktualisieren'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
|
<div className="bg-slate-50 rounded-lg p-4 border border-slate-200">
|
||||||
|
<div className="text-2xl font-bold text-slate-900">{configs.length}</div>
|
||||||
|
<div className="text-sm text-slate-600">Middleware</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-green-50 rounded-lg p-4 border border-green-200">
|
||||||
|
<div className="text-2xl font-bold text-green-600">{whitelistCount}</div>
|
||||||
|
<div className="text-sm text-slate-600">Whitelist IPs</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-red-50 rounded-lg p-4 border border-red-200">
|
||||||
|
<div className="text-2xl font-bold text-red-600">{blacklistCount}</div>
|
||||||
|
<div className="text-sm text-slate-600">Blacklist IPs</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-blue-50 rounded-lg p-4 border border-blue-200">
|
||||||
|
<div className="text-2xl font-bold text-blue-600">{events.length}</div>
|
||||||
|
<div className="text-sm text-slate-600">Recent Events</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Tabs */}
|
{/* Tabs */}
|
||||||
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden mb-6">
|
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden mb-6">
|
||||||
@@ -218,28 +298,332 @@ export default function MiddlewareAdminPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
{/* Overview Tab */}
|
||||||
{activeTab === 'overview' && (
|
{activeTab === 'overview' && (
|
||||||
<OverviewTab configs={configs} actionLoading={actionLoading} onToggle={toggleMiddleware} />
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{configs.map(config => {
|
||||||
|
const info = getMiddlewareDescription(config.middleware_name)
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={config.id}
|
||||||
|
className={`rounded-lg p-4 border ${
|
||||||
|
config.enabled
|
||||||
|
? 'bg-green-50 border-green-200'
|
||||||
|
: 'bg-slate-50 border-slate-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex justify-between items-start mb-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xl">{info.icon}</span>
|
||||||
|
<span className="font-semibold text-slate-900 capitalize">
|
||||||
|
{config.middleware_name.replace('_', ' ')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => toggleMiddleware(config.middleware_name, !config.enabled)}
|
||||||
|
disabled={actionLoading === config.middleware_name}
|
||||||
|
className={`px-3 py-1 rounded-full text-xs font-semibold transition-colors ${
|
||||||
|
config.enabled
|
||||||
|
? 'bg-green-200 text-green-800 hover:bg-green-300'
|
||||||
|
: 'bg-slate-200 text-slate-600 hover:bg-slate-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{actionLoading === config.middleware_name
|
||||||
|
? '...'
|
||||||
|
: config.enabled
|
||||||
|
? 'Aktiv'
|
||||||
|
: 'Inaktiv'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-slate-600">{info.desc}</p>
|
||||||
|
{config.updated_at && (
|
||||||
|
<div className="mt-2 text-xs text-slate-400">
|
||||||
|
Aktualisiert: {new Date(config.updated_at).toLocaleString('de-DE')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Config Tab */}
|
||||||
{activeTab === 'config' && (
|
{activeTab === 'config' && (
|
||||||
<ConfigTab configs={configs} actionLoading={actionLoading} onToggle={toggleMiddleware} />
|
<div className="space-y-4">
|
||||||
|
{configs.map(config => {
|
||||||
|
const info = getMiddlewareDescription(config.middleware_name)
|
||||||
|
return (
|
||||||
|
<div key={config.id} className="bg-slate-50 rounded-lg p-4 border border-slate-200">
|
||||||
|
<div className="flex justify-between items-start mb-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-slate-900 flex items-center gap-2">
|
||||||
|
<span>{info.icon}</span>
|
||||||
|
<span className="capitalize">{config.middleware_name.replace('_', ' ')}</span>
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-slate-600">{info.desc}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span
|
||||||
|
className={`px-3 py-1 rounded-full text-xs font-semibold ${
|
||||||
|
config.enabled ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{config.enabled ? 'Aktiviert' : 'Deaktiviert'}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => toggleMiddleware(config.middleware_name, !config.enabled)}
|
||||||
|
disabled={actionLoading === config.middleware_name}
|
||||||
|
className="px-4 py-1.5 text-sm border border-slate-300 rounded-lg hover:bg-slate-100 disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
{actionLoading === config.middleware_name
|
||||||
|
? '...'
|
||||||
|
: config.enabled
|
||||||
|
? 'Deaktivieren'
|
||||||
|
: 'Aktivieren'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{Object.keys(config.config).length > 0 && (
|
||||||
|
<div className="mt-3 pt-3 border-t border-slate-200">
|
||||||
|
<div className="text-xs font-semibold text-slate-500 uppercase mb-2">
|
||||||
|
Konfiguration
|
||||||
|
</div>
|
||||||
|
<pre className="text-xs bg-white p-3 rounded border border-slate-200 overflow-x-auto">
|
||||||
|
{JSON.stringify(config.config, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* IP List Tab */}
|
||||||
{activeTab === 'ip-list' && (
|
{activeTab === 'ip-list' && (
|
||||||
<IpListTab
|
<div>
|
||||||
ipList={ipList}
|
{/* Add IP Form */}
|
||||||
actionLoading={actionLoading}
|
<form onSubmit={addIP} className="mb-6 p-4 bg-slate-50 rounded-lg border border-slate-200">
|
||||||
newIP={newIP}
|
<h3 className="font-semibold text-slate-900 mb-4">IP hinzufuegen</h3>
|
||||||
newIPType={newIPType}
|
<div className="flex flex-wrap gap-3">
|
||||||
newIPReason={newIPReason}
|
<input
|
||||||
onNewIPChange={setNewIP}
|
type="text"
|
||||||
onNewIPTypeChange={setNewIPType}
|
value={newIP}
|
||||||
onNewIPReasonChange={setNewIPReason}
|
onChange={e => setNewIP(e.target.value)}
|
||||||
onAddIP={addIP}
|
placeholder="IP-Adresse (z.B. 192.168.1.1)"
|
||||||
onRemoveIP={removeIP}
|
className="flex-1 min-w-[200px] px-4 py-2 border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-orange-500"
|
||||||
/>
|
/>
|
||||||
|
<select
|
||||||
|
value={newIPType}
|
||||||
|
onChange={e => setNewIPType(e.target.value as 'whitelist' | 'blacklist')}
|
||||||
|
className="px-4 py-2 border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-orange-500"
|
||||||
|
>
|
||||||
|
<option value="whitelist">Whitelist</option>
|
||||||
|
<option value="blacklist">Blacklist</option>
|
||||||
|
</select>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newIPReason}
|
||||||
|
onChange={e => setNewIPReason(e.target.value)}
|
||||||
|
placeholder="Grund (optional)"
|
||||||
|
className="flex-1 min-w-[150px] px-4 py-2 border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-orange-500"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={!newIP.trim() || actionLoading === 'add-ip'}
|
||||||
|
className="px-6 py-2 bg-orange-600 text-white rounded-lg font-medium hover:bg-orange-700 disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
{actionLoading === 'add-ip' ? 'Hinzufuegen...' : 'Hinzufuegen'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* IP List Table */}
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-slate-200">
|
||||||
|
<th className="text-left py-3 px-4 text-xs font-semibold text-slate-500 uppercase">
|
||||||
|
IP-Adresse
|
||||||
|
</th>
|
||||||
|
<th className="text-left py-3 px-4 text-xs font-semibold text-slate-500 uppercase">
|
||||||
|
Typ
|
||||||
|
</th>
|
||||||
|
<th className="text-left py-3 px-4 text-xs font-semibold text-slate-500 uppercase">
|
||||||
|
Grund
|
||||||
|
</th>
|
||||||
|
<th className="text-left py-3 px-4 text-xs font-semibold text-slate-500 uppercase">
|
||||||
|
Hinzugefuegt
|
||||||
|
</th>
|
||||||
|
<th className="text-right py-3 px-4 text-xs font-semibold text-slate-500 uppercase">
|
||||||
|
Aktion
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{ipList.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={5} className="text-center py-8 text-slate-500">
|
||||||
|
Keine IP-Eintraege vorhanden.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
ipList.map(ip => (
|
||||||
|
<tr key={ip.id} className="border-b border-slate-100 hover:bg-slate-50">
|
||||||
|
<td className="py-3 px-4 font-mono text-sm">{ip.ip_address}</td>
|
||||||
|
<td className="py-3 px-4">
|
||||||
|
<span
|
||||||
|
className={`px-2 py-1 rounded text-xs font-semibold ${
|
||||||
|
ip.list_type === 'whitelist'
|
||||||
|
? 'bg-green-100 text-green-800'
|
||||||
|
: 'bg-red-100 text-red-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{ip.list_type === 'whitelist' ? 'Whitelist' : 'Blacklist'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-4 text-sm text-slate-600">{ip.reason || '-'}</td>
|
||||||
|
<td className="py-3 px-4 text-sm text-slate-500">
|
||||||
|
{new Date(ip.created_at).toLocaleString('de-DE')}
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-4 text-right">
|
||||||
|
<button
|
||||||
|
onClick={() => removeIP(ip.id)}
|
||||||
|
disabled={actionLoading === `remove-${ip.id}`}
|
||||||
|
className="px-3 py-1 text-sm text-red-600 hover:bg-red-50 rounded transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{actionLoading === `remove-${ip.id}` ? '...' : 'Entfernen'}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Events Tab */}
|
||||||
|
{activeTab === 'events' && (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-slate-200">
|
||||||
|
<th className="text-left py-3 px-4 text-xs font-semibold text-slate-500 uppercase">
|
||||||
|
Zeit
|
||||||
|
</th>
|
||||||
|
<th className="text-left py-3 px-4 text-xs font-semibold text-slate-500 uppercase">
|
||||||
|
Middleware
|
||||||
|
</th>
|
||||||
|
<th className="text-left py-3 px-4 text-xs font-semibold text-slate-500 uppercase">
|
||||||
|
Event
|
||||||
|
</th>
|
||||||
|
<th className="text-left py-3 px-4 text-xs font-semibold text-slate-500 uppercase">
|
||||||
|
IP
|
||||||
|
</th>
|
||||||
|
<th className="text-left py-3 px-4 text-xs font-semibold text-slate-500 uppercase">
|
||||||
|
Pfad
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{events.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={5} className="text-center py-8 text-slate-500">
|
||||||
|
Keine Events vorhanden.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
events.map(event => (
|
||||||
|
<tr key={event.id} className="border-b border-slate-100 hover:bg-slate-50">
|
||||||
|
<td className="py-3 px-4 text-sm text-slate-500">
|
||||||
|
{new Date(event.created_at).toLocaleString('de-DE')}
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-4 text-sm capitalize">
|
||||||
|
{event.middleware_name.replace('_', ' ')}
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-4">
|
||||||
|
<span
|
||||||
|
className={`px-2 py-1 rounded text-xs font-semibold ${getEventTypeColor(event.event_type)}`}
|
||||||
|
>
|
||||||
|
{event.event_type}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-4 text-sm font-mono text-slate-600">
|
||||||
|
{event.ip_address || '-'}
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-4 text-sm text-slate-600 max-w-xs truncate">
|
||||||
|
{event.request_method && event.request_path
|
||||||
|
? `${event.request_method} ${event.request_path}`
|
||||||
|
: '-'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Stats Tab */}
|
||||||
|
{activeTab === 'stats' && (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
{stats.map(stat => {
|
||||||
|
const info = getMiddlewareDescription(stat.middleware_name)
|
||||||
|
return (
|
||||||
|
<div key={stat.middleware_name} className="bg-slate-50 rounded-lg p-4 border border-slate-200">
|
||||||
|
<h3 className="font-semibold text-slate-900 flex items-center gap-2 mb-4">
|
||||||
|
<span>{info.icon}</span>
|
||||||
|
<span className="capitalize">{stat.middleware_name.replace('_', ' ')}</span>
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-3 gap-4 mb-4">
|
||||||
|
<div>
|
||||||
|
<div className="text-2xl font-bold text-slate-900">{stat.total_events}</div>
|
||||||
|
<div className="text-xs text-slate-500">Gesamt</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-2xl font-bold text-blue-600">{stat.events_last_hour}</div>
|
||||||
|
<div className="text-xs text-slate-500">Letzte Stunde</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-2xl font-bold text-orange-600">{stat.events_last_24h}</div>
|
||||||
|
<div className="text-xs text-slate-500">24 Stunden</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{stat.top_event_types.length > 0 && (
|
||||||
|
<div className="mb-3">
|
||||||
|
<div className="text-xs font-semibold text-slate-500 uppercase mb-2">
|
||||||
|
Top Event-Typen
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{stat.top_event_types.slice(0, 3).map(et => (
|
||||||
|
<span
|
||||||
|
key={et.event_type}
|
||||||
|
className={`px-2 py-1 rounded text-xs ${getEventTypeColor(et.event_type)}`}
|
||||||
|
>
|
||||||
|
{et.event_type} ({et.count})
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{stat.top_ips.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<div className="text-xs font-semibold text-slate-500 uppercase mb-2">Top IPs</div>
|
||||||
|
<div className="text-xs text-slate-600">
|
||||||
|
{stat.top_ips
|
||||||
|
.slice(0, 3)
|
||||||
|
.map(ip => `${ip.ip_address} (${ip.count})`)
|
||||||
|
.join(', ')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
{activeTab === 'events' && <EventsTab events={events} />}
|
|
||||||
{activeTab === 'stats' && <StatsTab stats={stats} />}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,37 +0,0 @@
|
|||||||
export interface MiddlewareConfig {
|
|
||||||
id: string
|
|
||||||
middleware_name: string
|
|
||||||
enabled: boolean
|
|
||||||
config: Record<string, unknown>
|
|
||||||
updated_at: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RateLimitIP {
|
|
||||||
id: string
|
|
||||||
ip_address: string
|
|
||||||
list_type: 'whitelist' | 'blacklist'
|
|
||||||
reason: string | null
|
|
||||||
expires_at: string | null
|
|
||||||
created_at: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MiddlewareEvent {
|
|
||||||
id: string
|
|
||||||
middleware_name: string
|
|
||||||
event_type: string
|
|
||||||
ip_address: string | null
|
|
||||||
user_id: string | null
|
|
||||||
request_path: string | null
|
|
||||||
request_method: string | null
|
|
||||||
details: Record<string, unknown> | null
|
|
||||||
created_at: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MiddlewareStats {
|
|
||||||
middleware_name: string
|
|
||||||
total_events: number
|
|
||||||
events_last_hour: number
|
|
||||||
events_last_24h: number
|
|
||||||
top_event_types: Array<{ event_type: string; count: number }>
|
|
||||||
top_ips: Array<{ ip_address: string; count: number }>
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
import type { Component } from '../types'
|
|
||||||
|
|
||||||
export const GO_MODULES: Component[] = [
|
|
||||||
{ type: 'library', name: 'gin-gonic/gin', version: '1.9+', category: 'go', description: 'Web Framework', license: 'MIT', sourceUrl: 'https://github.com/gin-gonic/gin' },
|
|
||||||
{ type: 'library', name: 'gorm.io/gorm', version: '1.25+', category: 'go', description: 'ORM', license: 'MIT', sourceUrl: 'https://github.com/go-gorm/gorm' },
|
|
||||||
{ type: 'library', name: 'golang-jwt/jwt', version: 'v5', category: 'go', description: 'JWT Library', license: 'MIT', sourceUrl: 'https://github.com/golang-jwt/jwt' },
|
|
||||||
{ type: 'library', name: 'stripe/stripe-go', version: 'v76', category: 'go', description: 'Stripe SDK', license: 'MIT', sourceUrl: 'https://github.com/stripe/stripe-go' },
|
|
||||||
{ type: 'library', name: 'spf13/viper', version: 'latest', category: 'go', description: 'Configuration', license: 'MIT', sourceUrl: 'https://github.com/spf13/viper' },
|
|
||||||
{ type: 'library', name: 'uber-go/zap', version: 'latest', category: 'go', description: 'Structured Logging', license: 'MIT', sourceUrl: 'https://github.com/uber-go/zap' },
|
|
||||||
{ type: 'library', name: 'swaggo/swag', version: 'latest', category: 'go', description: 'Swagger Docs', license: 'MIT', sourceUrl: 'https://github.com/swaggo/swag' },
|
|
||||||
]
|
|
||||||
|
|
||||||
export const NODE_PACKAGES: Component[] = [
|
|
||||||
{ type: 'library', name: 'Next.js', version: '15.1', category: 'nodejs', description: 'React Framework', license: 'MIT', sourceUrl: 'https://github.com/vercel/next.js' },
|
|
||||||
{ type: 'library', name: 'React', version: '19', category: 'nodejs', description: 'UI Library', license: 'MIT', sourceUrl: 'https://github.com/facebook/react' },
|
|
||||||
{ type: 'library', name: 'Vue.js', version: '3.4', category: 'nodejs', description: 'UI Framework (Creator Studio)', license: 'MIT', sourceUrl: 'https://github.com/vuejs/core' },
|
|
||||||
{ type: 'library', name: 'Angular', version: '17', category: 'nodejs', description: 'UI Framework (Policy Vault)', license: 'MIT', sourceUrl: 'https://github.com/angular/angular' },
|
|
||||||
{ type: 'library', name: 'NestJS', version: '10', category: 'nodejs', description: 'Node.js Framework', license: 'MIT', sourceUrl: 'https://github.com/nestjs/nest' },
|
|
||||||
{ type: 'library', name: 'TypeScript', version: '5.x', category: 'nodejs', description: 'Type System', license: 'Apache-2.0', sourceUrl: 'https://github.com/microsoft/TypeScript' },
|
|
||||||
{ type: 'library', name: 'Tailwind CSS', version: '3.4', category: 'nodejs', description: 'Utility CSS', license: 'MIT', sourceUrl: 'https://github.com/tailwindlabs/tailwindcss' },
|
|
||||||
{ type: 'library', name: 'Prisma', version: '5.x', category: 'nodejs', description: 'ORM (Policy Vault)', license: 'Apache-2.0', sourceUrl: 'https://github.com/prisma/prisma' },
|
|
||||||
{ type: 'library', name: 'Material Design Icons', version: 'latest', category: 'nodejs', description: 'Icon-System (Companion UI, Studio)', license: 'Apache-2.0', sourceUrl: 'https://github.com/google/material-design-icons' },
|
|
||||||
{ type: 'library', name: 'Recharts', version: '2.12', category: 'nodejs', description: 'React Charts (Compliance Dashboard)', license: 'MIT', sourceUrl: 'https://github.com/recharts/recharts' },
|
|
||||||
{ type: 'library', name: 'React Flow', version: '11.x', category: 'nodejs', description: 'Node-basierte Flow-Diagramme (Screen Flow)', license: 'MIT', sourceUrl: 'https://github.com/xyflow/xyflow' },
|
|
||||||
{ type: 'library', name: 'Playwright', version: '1.50', category: 'nodejs', description: 'E2E Testing Framework (SDK Tests)', license: 'Apache-2.0', sourceUrl: 'https://github.com/microsoft/playwright' },
|
|
||||||
{ type: 'library', name: 'Vitest', version: '4.x', category: 'nodejs', description: 'Unit Testing Framework', license: 'MIT', sourceUrl: 'https://github.com/vitest-dev/vitest' },
|
|
||||||
{ type: 'library', name: 'jsPDF', version: '4.x', category: 'nodejs', description: 'PDF Generation (SDK Export)', license: 'MIT', sourceUrl: 'https://github.com/parallax/jsPDF' },
|
|
||||||
{ type: 'library', name: 'JSZip', version: '3.x', category: 'nodejs', description: 'ZIP File Creation (SDK Export)', license: 'MIT/GPL-3.0', sourceUrl: 'https://github.com/Stuk/jszip' },
|
|
||||||
{ type: 'library', name: 'Lucide React', version: '0.468', category: 'nodejs', description: 'Icon Library', license: 'ISC', sourceUrl: 'https://github.com/lucide-icons/lucide' },
|
|
||||||
]
|
|
||||||
|
|
||||||
export const UNITY_PACKAGES: Component[] = [
|
|
||||||
{ type: 'library', name: 'Unity Engine', version: '6000.0 (Unity 6)', category: 'unity', description: 'Game Engine', license: 'Unity EULA', sourceUrl: 'https://unity.com' },
|
|
||||||
{ type: 'library', name: 'Universal Render Pipeline (URP)', version: '17.x', category: 'unity', description: 'Render Pipeline', license: 'Unity Companion', sourceUrl: 'https://docs.unity3d.com/Packages/com.unity.render-pipelines.universal@17.0' },
|
|
||||||
{ type: 'library', name: 'TextMeshPro', version: '3.2', category: 'unity', description: 'Advanced Text Rendering', license: 'Unity Companion', sourceUrl: 'https://docs.unity3d.com/Packages/com.unity.textmeshpro@3.2' },
|
|
||||||
{ type: 'library', name: 'Unity Mathematics', version: '1.3', category: 'unity', description: 'Math Library (SIMD)', license: 'Unity Companion', sourceUrl: 'https://docs.unity3d.com/Packages/com.unity.mathematics@1.3' },
|
|
||||||
{ type: 'library', name: 'Newtonsoft.Json (Unity)', version: '3.2', category: 'unity', description: 'JSON Serialization', license: 'MIT', sourceUrl: 'https://docs.unity3d.com/Packages/com.unity.nuget.newtonsoft-json@3.2' },
|
|
||||||
{ type: 'library', name: 'Unity UI', version: '2.0', category: 'unity', description: 'UI System', license: 'Unity Companion', sourceUrl: 'https://docs.unity3d.com/Packages/com.unity.ugui@2.0' },
|
|
||||||
{ type: 'library', name: 'Unity Input System', version: '1.8', category: 'unity', description: 'New Input System', license: 'Unity Companion', sourceUrl: 'https://docs.unity3d.com/Packages/com.unity.inputsystem@1.8' },
|
|
||||||
]
|
|
||||||
|
|
||||||
export const CSHARP_PACKAGES: Component[] = [
|
|
||||||
{ type: 'library', name: '.NET Standard', version: '2.1', category: 'csharp', description: 'Runtime', license: 'MIT', sourceUrl: 'https://github.com/dotnet/standard' },
|
|
||||||
{ type: 'library', name: 'UnityWebRequest', version: 'built-in', category: 'csharp', description: 'HTTP Client', license: 'Unity Companion', sourceUrl: '-' },
|
|
||||||
{ type: 'library', name: 'System.Text.Json', version: 'built-in', category: 'csharp', description: 'JSON Parsing', license: 'MIT', sourceUrl: 'https://github.com/dotnet/runtime' },
|
|
||||||
]
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
import type { Component } from '../types'
|
|
||||||
|
|
||||||
export const PYTHON_PACKAGES: Component[] = [
|
|
||||||
{ type: 'library', name: 'FastAPI', version: '0.109+', category: 'python', description: 'Web Framework', license: 'MIT', sourceUrl: 'https://github.com/tiangolo/fastapi' },
|
|
||||||
{ type: 'library', name: 'Uvicorn', version: '0.38+', category: 'python', description: 'ASGI Server', license: 'BSD-3-Clause', sourceUrl: 'https://github.com/encode/uvicorn' },
|
|
||||||
{ type: 'library', name: 'Starlette', version: '0.49+', category: 'python', description: 'ASGI Framework (FastAPI Basis)', license: 'BSD-3-Clause', sourceUrl: 'https://github.com/encode/starlette' },
|
|
||||||
{ type: 'library', name: 'Pydantic', version: '2.x', category: 'python', description: 'Data Validation', license: 'MIT', sourceUrl: 'https://github.com/pydantic/pydantic' },
|
|
||||||
{ type: 'library', name: 'SQLAlchemy', version: '2.x', category: 'python', description: 'ORM', license: 'MIT', sourceUrl: 'https://github.com/sqlalchemy/sqlalchemy' },
|
|
||||||
{ type: 'library', name: 'Alembic', version: '1.14+', category: 'python', description: 'DB Migrations (Classroom, Feedback Tables)', license: 'MIT', sourceUrl: 'https://github.com/sqlalchemy/alembic' },
|
|
||||||
{ type: 'library', name: 'psycopg2-binary', version: '2.9+', category: 'python', description: 'PostgreSQL Driver', license: 'LGPL-3.0', sourceUrl: 'https://github.com/psycopg/psycopg2' },
|
|
||||||
{ type: 'library', name: 'httpx', version: 'latest', category: 'python', description: 'Async HTTP Client', license: 'BSD-3-Clause', sourceUrl: 'https://github.com/encode/httpx' },
|
|
||||||
{ type: 'library', name: 'PyJWT', version: 'latest', category: 'python', description: 'JWT Handling', license: 'MIT', sourceUrl: 'https://github.com/jpadilla/pyjwt' },
|
|
||||||
{ type: 'library', name: 'hvac', version: 'latest', category: 'python', description: 'Vault Client', license: 'Apache-2.0', sourceUrl: 'https://github.com/hvac/hvac' },
|
|
||||||
{ type: 'library', name: 'python-multipart', version: 'latest', category: 'python', description: 'File Uploads', license: 'Apache-2.0', sourceUrl: 'https://github.com/andrew-d/python-multipart' },
|
|
||||||
{ type: 'library', name: 'aiofiles', version: 'latest', category: 'python', description: 'Async File I/O', license: 'Apache-2.0', sourceUrl: 'https://github.com/Tinche/aiofiles' },
|
|
||||||
{ type: 'library', name: 'openai', version: 'latest', category: 'python', description: 'OpenAI SDK', license: 'MIT', sourceUrl: 'https://github.com/openai/openai-python' },
|
|
||||||
{ type: 'library', name: 'anthropic', version: 'latest', category: 'python', description: 'Anthropic Claude SDK', license: 'MIT', sourceUrl: 'https://github.com/anthropics/anthropic-sdk-python' },
|
|
||||||
{ type: 'library', name: 'langchain', version: 'latest', category: 'python', description: 'LLM Framework', license: 'MIT', sourceUrl: 'https://github.com/langchain-ai/langchain' },
|
|
||||||
{ type: 'library', name: 'aioimaplib', version: 'latest', category: 'python', description: 'Async IMAP Client (Unified Inbox)', license: 'MIT', sourceUrl: 'https://github.com/bamthomas/aioimaplib' },
|
|
||||||
{ type: 'library', name: 'aiosmtplib', version: 'latest', category: 'python', description: 'Async SMTP Client (Mail Sending)', license: 'MIT', sourceUrl: 'https://github.com/cole/aiosmtplib' },
|
|
||||||
{ type: 'library', name: 'email-validator', version: 'latest', category: 'python', description: 'Email Validation', license: 'CC0-1.0', sourceUrl: 'https://github.com/JoshData/python-email-validator' },
|
|
||||||
{ type: 'library', name: 'cryptography', version: 'latest', category: 'python', description: 'Encryption (Mail Credentials)', license: 'BSD-3-Clause', sourceUrl: 'https://github.com/pyca/cryptography' },
|
|
||||||
{ type: 'library', name: 'asyncpg', version: 'latest', category: 'python', description: 'Async PostgreSQL Driver', license: 'Apache-2.0', sourceUrl: 'https://github.com/MagicStack/asyncpg' },
|
|
||||||
{ type: 'library', name: 'python-dateutil', version: 'latest', category: 'python', description: 'Date Parsing (Deadline Extraction)', license: 'Apache-2.0', sourceUrl: 'https://github.com/dateutil/dateutil' },
|
|
||||||
{ type: 'library', name: 'faster-whisper', version: '1.0+', category: 'python', description: 'CTranslate2 Whisper (GPU-optimiert)', license: 'MIT', sourceUrl: 'https://github.com/SYSTRAN/faster-whisper' },
|
|
||||||
{ type: 'library', name: 'pyannote.audio', version: '3.x', category: 'python', description: 'Speaker Diarization', license: 'MIT', sourceUrl: 'https://github.com/pyannote/pyannote-audio' },
|
|
||||||
{ type: 'library', name: 'rq', version: '1.x', category: 'python', description: 'Redis Queue (Task Processing)', license: 'BSD-2-Clause', sourceUrl: 'https://github.com/rq/rq' },
|
|
||||||
{ type: 'library', name: 'ffmpeg-python', version: '0.2+', category: 'python', description: 'FFmpeg Python Bindings', license: 'Apache-2.0', sourceUrl: 'https://github.com/kkroening/ffmpeg-python' },
|
|
||||||
{ type: 'library', name: 'webvtt-py', version: '0.4+', category: 'python', description: 'WebVTT Subtitle Export', license: 'MIT', sourceUrl: 'https://github.com/glut23/webvtt-py' },
|
|
||||||
{ type: 'library', name: 'minio', version: '7.x', category: 'python', description: 'MinIO S3 Client', license: 'Apache-2.0', sourceUrl: 'https://github.com/minio/minio-py' },
|
|
||||||
{ type: 'library', name: 'structlog', version: '24.x', category: 'python', description: 'Structured Logging', license: 'Apache-2.0', sourceUrl: 'https://github.com/hynek/structlog' },
|
|
||||||
{ type: 'library', name: 'feedparser', version: '6.x', category: 'python', description: 'RSS/Atom Feed Parser (Alerts Agent)', license: 'BSD-2-Clause', sourceUrl: 'https://github.com/kurtmckee/feedparser' },
|
|
||||||
{ type: 'library', name: 'APScheduler', version: '3.x', category: 'python', description: 'AsyncIO Job Scheduler (Alerts Agent)', license: 'MIT', sourceUrl: 'https://github.com/agronholm/apscheduler' },
|
|
||||||
{ type: 'library', name: 'beautifulsoup4', version: '4.x', category: 'python', description: 'HTML Parser (Email Parsing, Compliance Scraper)', license: 'MIT', sourceUrl: 'https://code.launchpad.net/beautifulsoup' },
|
|
||||||
{ type: 'library', name: 'lxml', version: '5.x', category: 'python', description: 'XML/HTML Parser (EUR-Lex Scraping)', license: 'BSD-3-Clause', sourceUrl: 'https://github.com/lxml/lxml' },
|
|
||||||
{ type: 'library', name: 'PyMuPDF', version: '1.24+', category: 'python', description: 'PDF Parser (BSI-TR Extraction)', license: 'AGPL-3.0', sourceUrl: 'https://github.com/pymupdf/PyMuPDF' },
|
|
||||||
{ type: 'library', name: 'pdfplumber', version: '0.11+', category: 'python', description: 'PDF Table Extraction (Compliance Docs)', license: 'MIT', sourceUrl: 'https://github.com/jsvine/pdfplumber' },
|
|
||||||
{ type: 'library', name: 'websockets', version: '14.x', category: 'python', description: 'WebSocket Support (Voice Streaming)', license: 'BSD-3-Clause', sourceUrl: 'https://github.com/python-websockets/websockets' },
|
|
||||||
{ type: 'library', name: 'soundfile', version: '0.13+', category: 'python', description: 'Audio File Processing (Voice Service)', license: 'BSD-3-Clause', sourceUrl: 'https://github.com/bastibe/python-soundfile' },
|
|
||||||
{ type: 'library', name: 'scipy', version: '1.14+', category: 'python', description: 'Signal Processing (Audio)', license: 'BSD-3-Clause', sourceUrl: 'https://github.com/scipy/scipy' },
|
|
||||||
{ type: 'library', name: 'redis', version: '5.x', category: 'python', description: 'Valkey/Redis Client (Voice Sessions)', license: 'MIT', sourceUrl: 'https://github.com/redis/redis-py' },
|
|
||||||
{ type: 'library', name: 'pydantic-settings', version: '2.x', category: 'python', description: 'Settings Management (Voice Config)', license: 'MIT', sourceUrl: 'https://github.com/pydantic/pydantic-settings' },
|
|
||||||
{ type: 'library', name: 'pyspellchecker', version: '0.8.1+', category: 'python', description: 'Regel-basierte OCR-Korrektur (klausur-service Schritt 6)', license: 'MIT', sourceUrl: 'https://github.com/barrust/pyspellchecker' },
|
|
||||||
{ type: 'library', name: 'pytesseract', version: '0.3.10+', category: 'python', description: 'Tesseract OCR Engine Wrapper (klausur-service)', license: 'Apache-2.0', sourceUrl: 'https://github.com/madmaze/pytesseract' },
|
|
||||||
{ type: 'library', name: 'opencv-python-headless', version: '4.8+', category: 'python', description: 'Bildverarbeitung, Projektionsprofile, Inpainting (klausur-service)', license: 'Apache-2.0', sourceUrl: 'https://github.com/opencv/opencv-python' },
|
|
||||||
{ type: 'library', name: 'rapidocr-onnxruntime', version: 'latest', category: 'python', description: 'Schnelles OCR ARM64 via ONNX (klausur-service)', license: 'Apache-2.0', sourceUrl: 'https://github.com/RapidAI/RapidOCR' },
|
|
||||||
{ type: 'library', name: 'onnxruntime', version: 'latest', category: 'python', description: 'ONNX-Inferenz fuer RapidOCR (klausur-service)', license: 'MIT', sourceUrl: 'https://github.com/microsoft/onnxruntime' },
|
|
||||||
{ type: 'library', name: 'eng-to-ipa', version: 'latest', category: 'python', description: 'IPA-Lautschrift-Lookup (klausur-service Vokabel-Pipeline)', license: 'MIT', sourceUrl: 'https://github.com/mphilli/English-to-IPA' },
|
|
||||||
{ type: 'library', name: 'sentence-transformers', version: '2.2+', category: 'python', description: 'Lokale Embeddings (klausur-service, rag-service)', license: 'Apache-2.0', sourceUrl: 'https://github.com/UKPLab/sentence-transformers' },
|
|
||||||
{ type: 'library', name: 'torch', version: '2.0+', category: 'python', description: 'ML-Framework CPU/MPS (TrOCR, klausur-service)', license: 'BSD-3-Clause', sourceUrl: 'https://github.com/pytorch/pytorch' },
|
|
||||||
{ type: 'library', name: 'transformers', version: '4.x', category: 'python', description: 'HuggingFace Transformers (TrOCR, Handschrift-HTR)', license: 'Apache-2.0', sourceUrl: 'https://github.com/huggingface/transformers' },
|
|
||||||
]
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
/**
|
|
||||||
* Static SBOM component data
|
|
||||||
* Extracted from page.tsx to keep file sizes manageable
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { Component } from '../types'
|
|
||||||
|
|
||||||
// Infrastructure components from docker-compose.yml and project analysis
|
|
||||||
export const INFRASTRUCTURE_COMPONENTS: Component[] = [
|
|
||||||
{ type: 'service', name: 'PostgreSQL', version: '16-alpine', category: 'database', port: '5432', description: 'Hauptdatenbank', license: 'PostgreSQL', sourceUrl: 'https://github.com/postgres/postgres' },
|
|
||||||
{ type: 'service', name: 'Synapse PostgreSQL', version: '16-alpine', category: 'database', port: '-', description: 'Matrix Datenbank', license: 'PostgreSQL', sourceUrl: 'https://github.com/postgres/postgres' },
|
|
||||||
{ type: 'service', name: 'ERPNext MariaDB', version: '10.6', category: 'database', port: '-', description: 'ERPNext Datenbank', license: 'GPL-2.0', sourceUrl: 'https://github.com/MariaDB/server' },
|
|
||||||
{ type: 'service', name: 'MongoDB', version: '7.0', category: 'database', port: '27017', description: 'LibreChat Datenbank', license: 'SSPL-1.0', sourceUrl: 'https://github.com/mongodb/mongo' },
|
|
||||||
{ type: 'service', name: 'Valkey', version: '8-alpine', category: 'cache', port: '6379', description: 'In-Memory Cache & Sessions (Redis OSS Fork)', license: 'BSD-3-Clause', sourceUrl: 'https://github.com/valkey-io/valkey' },
|
|
||||||
{ type: 'service', name: 'ERPNext Valkey Queue', version: 'alpine', category: 'cache', port: '-', description: 'Job Queue', license: 'BSD-3-Clause', sourceUrl: 'https://github.com/valkey-io/valkey' },
|
|
||||||
{ type: 'service', name: 'ERPNext Valkey Cache', version: 'alpine', category: 'cache', port: '-', description: 'Cache Layer', license: 'BSD-3-Clause', sourceUrl: 'https://github.com/valkey-io/valkey' },
|
|
||||||
{ type: 'service', name: 'Qdrant', version: '1.7.4', category: 'search', port: '6333', description: 'Vector Database (RAG/Embeddings)', license: 'Apache-2.0', sourceUrl: 'https://github.com/qdrant/qdrant' },
|
|
||||||
{ type: 'service', name: 'OpenSearch', version: '2.x', category: 'search', port: '9200', description: 'Volltext-Suche (Elasticsearch Fork)', license: 'Apache-2.0', sourceUrl: 'https://github.com/opensearch-project/OpenSearch' },
|
|
||||||
{ type: 'service', name: 'Meilisearch', version: 'latest', category: 'search', port: '7700', description: 'Instant Search Engine', license: 'MIT', sourceUrl: 'https://github.com/meilisearch/meilisearch' },
|
|
||||||
{ type: 'service', name: 'MinIO', version: 'latest', category: 'storage', port: '9000/9001', description: 'S3-kompatibel Object Storage', license: 'AGPL-3.0', sourceUrl: 'https://github.com/minio/minio' },
|
|
||||||
{ type: 'service', name: 'IPFS (Kubo)', version: '0.24', category: 'storage', port: '5001', description: 'Dezentrales Speichersystem', license: 'MIT/Apache-2.0', sourceUrl: 'https://github.com/ipfs/kubo' },
|
|
||||||
{ type: 'service', name: 'DSMS Gateway', version: '1.0', category: 'storage', port: '8082', description: 'IPFS REST API', license: 'Proprietary', sourceUrl: '-' },
|
|
||||||
{ type: 'service', name: 'HashiCorp Vault', version: '1.15', category: 'security', port: '8200', description: 'Secrets Management', license: 'BUSL-1.1', sourceUrl: 'https://github.com/hashicorp/vault' },
|
|
||||||
{ type: 'service', name: 'Keycloak', version: '23.0', category: 'security', port: '8180', description: 'Identity Provider (SSO/OIDC)', license: 'Apache-2.0', sourceUrl: 'https://github.com/keycloak/keycloak' },
|
|
||||||
{ type: 'service', name: 'NetBird', version: '0.64.5', category: 'security', port: '-', description: 'Zero-Trust Mesh VPN (WireGuard-basiert)', license: 'BSD-3-Clause', sourceUrl: 'https://github.com/netbirdio/netbird' },
|
|
||||||
{ type: 'service', name: 'Matrix Synapse', version: 'latest', category: 'communication', port: '8008', description: 'E2EE Messenger Server', license: 'AGPL-3.0', sourceUrl: 'https://github.com/element-hq/synapse' },
|
|
||||||
{ type: 'service', name: 'Jitsi Web', version: 'stable-9823', category: 'communication', port: '8443', description: 'Videokonferenz UI', license: 'Apache-2.0', sourceUrl: 'https://github.com/jitsi/jitsi-meet' },
|
|
||||||
{ type: 'service', name: 'Jitsi Prosody (XMPP)', version: 'stable-9823', category: 'communication', port: '-', description: 'XMPP Server', license: 'MIT', sourceUrl: 'https://github.com/bjc/prosody' },
|
|
||||||
{ type: 'service', name: 'Jitsi Jicofo', version: 'stable-9823', category: 'communication', port: '-', description: 'Conference Focus Component', license: 'Apache-2.0', sourceUrl: 'https://github.com/jitsi/jicofo' },
|
|
||||||
{ type: 'service', name: 'Jitsi JVB', version: 'stable-9823', category: 'communication', port: '10000/udp', description: 'Videobridge (WebRTC SFU)', license: 'Apache-2.0', sourceUrl: 'https://github.com/jitsi/jitsi-videobridge' },
|
|
||||||
{ type: 'service', name: 'Jibri', version: 'stable-9823', category: 'communication', port: '-', description: 'Recording & Streaming Service', license: 'Apache-2.0', sourceUrl: 'https://github.com/jitsi/jibri' },
|
|
||||||
{ type: 'service', name: 'Python Backend (FastAPI)', version: '3.12', category: 'application', port: '8000', description: 'Haupt-Backend API, Studio & Alerts Agent', license: 'Proprietary', sourceUrl: '-' },
|
|
||||||
{ type: 'service', name: 'Klausur Service', version: '1.0', category: 'application', port: '8086', description: 'Abitur-Klausurkorrektur (BYOEH)', license: 'Proprietary', sourceUrl: '-' },
|
|
||||||
{ type: 'service', name: 'Compliance Module', version: '2.0', category: 'application', port: '8000', description: 'GRC Framework (19 Regulations, 558 Requirements, AI)', license: 'Proprietary', sourceUrl: '-' },
|
|
||||||
{ type: 'service', name: 'Transcription Worker', version: '1.0', category: 'application', port: '-', description: 'Whisper + pyannote Transkription', license: 'Proprietary', sourceUrl: '-' },
|
|
||||||
{ type: 'service', name: 'Go Consent Service', version: '1.21', category: 'application', port: '8081', description: 'DSGVO Consent Management', license: 'Proprietary', sourceUrl: '-' },
|
|
||||||
{ type: 'service', name: 'Go School Service', version: '1.21', category: 'application', port: '8084', description: 'Klausuren, Noten, Zeugnisse', license: 'Proprietary', sourceUrl: '-' },
|
|
||||||
{ type: 'service', name: 'Go Billing Service', version: '1.21', category: 'application', port: '8083', description: 'Stripe Billing Integration', license: 'Proprietary', sourceUrl: '-' },
|
|
||||||
{ type: 'service', name: 'Next.js Admin Frontend', version: '15.1', category: 'application', port: '3000', description: 'Admin Dashboard (React)', license: 'Proprietary', sourceUrl: '-' },
|
|
||||||
{ type: 'service', name: 'H5P Content Service', version: 'latest', category: 'application', port: '8085', description: 'Interaktive Inhalte', license: 'MIT', sourceUrl: 'https://github.com/h5p/h5p-server' },
|
|
||||||
{ type: 'service', name: 'Policy Vault (NestJS)', version: '1.0', category: 'application', port: '3001', description: 'Richtlinien-Verwaltung API', license: 'Proprietary', sourceUrl: '-' },
|
|
||||||
{ type: 'service', name: 'Policy Vault (Angular)', version: '17', category: 'application', port: '4200', description: 'Richtlinien-Verwaltung UI', license: 'Proprietary', sourceUrl: '-' },
|
|
||||||
{ type: 'service', name: 'Creator Studio (Vue 3)', version: '3.4', category: 'application', port: '-', description: 'Content Creation UI', license: 'Proprietary', sourceUrl: '-' },
|
|
||||||
{ type: 'service', name: 'LibreChat', version: 'latest', category: 'ai', port: '3080', description: 'Multi-LLM Chat Interface', license: 'MIT', sourceUrl: 'https://github.com/danny-avila/LibreChat' },
|
|
||||||
{ type: 'service', name: 'RAGFlow', version: 'latest', category: 'ai', port: '9380', description: 'RAG Pipeline Service', license: 'Apache-2.0', sourceUrl: 'https://github.com/infiniflow/ragflow' },
|
|
||||||
{ type: 'service', name: 'ERPNext', version: 'v15', category: 'erp', port: '8090', description: 'Open Source ERP System', license: 'GPL-3.0', sourceUrl: 'https://github.com/frappe/erpnext' },
|
|
||||||
{ type: 'service', name: 'Gitea', version: '1.21', category: 'cicd', port: '3003', description: 'Self-hosted Git Service with Actions CI/CD', license: 'MIT', sourceUrl: 'https://github.com/go-gitea/gitea' },
|
|
||||||
{ type: 'service', name: 'Dokploy', version: '0.26.7', category: 'cicd', port: '3000', description: 'Self-hosted PaaS (Vercel/Heroku Alternative)', license: 'Apache-2.0', sourceUrl: 'https://github.com/Dokploy/dokploy' },
|
|
||||||
{ type: 'service', name: 'Mailpit', version: 'latest', category: 'development', port: '8025/1025', description: 'E-Mail Testing (SMTP Catch-All)', license: 'MIT', sourceUrl: 'https://github.com/axllent/mailpit' },
|
|
||||||
{ type: 'service', name: 'Breakpilot Drive (Unity WebGL)', version: '6000.0', category: 'game', port: '3001', description: 'Lernspiel fuer Schueler (Klasse 2-6)', license: 'Proprietary', sourceUrl: '-' },
|
|
||||||
{ type: 'service', name: 'BQAS Local Scheduler', version: '1.0', category: 'qa', port: '-', description: 'Lokale GitHub Actions Alternative (launchd)', license: 'Proprietary', sourceUrl: '-' },
|
|
||||||
{ type: 'service', name: 'BQAS LLM Judge', version: '1.0', category: 'qa', port: '-', description: 'Qwen2.5-32B basierte Test-Bewertung', license: 'Proprietary', sourceUrl: '-' },
|
|
||||||
{ type: 'service', name: 'BQAS RAG Judge', version: '1.0', category: 'qa', port: '-', description: 'RAG/Korrektur Evaluierung', license: 'Proprietary', sourceUrl: '-' },
|
|
||||||
{ type: 'service', name: 'BQAS Notifier', version: '1.0', category: 'qa', port: '-', description: 'Desktop/Slack/Email Benachrichtigungen', license: 'Proprietary', sourceUrl: '-' },
|
|
||||||
{ type: 'service', name: 'BQAS Regression Tracker', version: '1.0', category: 'qa', port: '-', description: 'Score-Historie und Regression-Erkennung', license: 'Proprietary', sourceUrl: '-' },
|
|
||||||
]
|
|
||||||
|
|
||||||
export const SECURITY_TOOLS: Component[] = [
|
|
||||||
{ type: 'tool', name: 'Trivy', version: 'latest', category: 'security-tool', description: 'Container Vulnerability Scanner', license: 'Apache-2.0', sourceUrl: 'https://github.com/aquasecurity/trivy' },
|
|
||||||
{ type: 'tool', name: 'Grype', version: 'latest', category: 'security-tool', description: 'SBOM Vulnerability Scanner', license: 'Apache-2.0', sourceUrl: 'https://github.com/anchore/grype' },
|
|
||||||
{ type: 'tool', name: 'Syft', version: 'latest', category: 'security-tool', description: 'SBOM Generator', license: 'Apache-2.0', sourceUrl: 'https://github.com/anchore/syft' },
|
|
||||||
{ type: 'tool', name: 'Gitleaks', version: 'latest', category: 'security-tool', description: 'Secrets Detection in Git', license: 'MIT', sourceUrl: 'https://github.com/gitleaks/gitleaks' },
|
|
||||||
{ type: 'tool', name: 'TruffleHog', version: '3.x', category: 'security-tool', description: 'Secrets Scanner (Regex/Entropy)', license: 'AGPL-3.0', sourceUrl: 'https://github.com/trufflesecurity/trufflehog' },
|
|
||||||
{ type: 'tool', name: 'Semgrep', version: 'latest', category: 'security-tool', description: 'SAST - Static Analysis', license: 'LGPL-2.1', sourceUrl: 'https://github.com/semgrep/semgrep' },
|
|
||||||
{ type: 'tool', name: 'Bandit', version: 'latest', category: 'security-tool', description: 'Python Security Linter', license: 'Apache-2.0', sourceUrl: 'https://github.com/PyCQA/bandit' },
|
|
||||||
{ type: 'tool', name: 'Gosec', version: 'latest', category: 'security-tool', description: 'Go Security Scanner', license: 'Apache-2.0', sourceUrl: 'https://github.com/securego/gosec' },
|
|
||||||
{ type: 'tool', name: 'govulncheck', version: 'latest', category: 'security-tool', description: 'Go Vulnerability Check', license: 'BSD-3-Clause', sourceUrl: 'https://pkg.go.dev/golang.org/x/vuln/cmd/govulncheck' },
|
|
||||||
{ type: 'tool', name: 'golangci-lint', version: 'latest', category: 'security-tool', description: 'Go Linter (Security Rules)', license: 'GPL-3.0', sourceUrl: 'https://github.com/golangci/golangci-lint' },
|
|
||||||
{ type: 'tool', name: 'npm audit', version: 'built-in', category: 'security-tool', description: 'Node.js Vulnerability Check', license: 'Artistic-2.0', sourceUrl: 'https://docs.npmjs.com/cli/commands/npm-audit' },
|
|
||||||
{ type: 'tool', name: 'pip-audit', version: 'latest', category: 'security-tool', description: 'Python Dependency Audit', license: 'Apache-2.0', sourceUrl: 'https://github.com/pypa/pip-audit' },
|
|
||||||
{ type: 'tool', name: 'safety', version: 'latest', category: 'security-tool', description: 'Python Safety Check', license: 'MIT', sourceUrl: 'https://github.com/pyupio/safety' },
|
|
||||||
{ type: 'tool', name: 'CodeQL', version: 'latest', category: 'security-tool', description: 'GitHub Security Analysis', license: 'MIT', sourceUrl: 'https://github.com/github/codeql' },
|
|
||||||
]
|
|
||||||
|
|
||||||
export { PYTHON_PACKAGES } from './sbom-data-python'
|
|
||||||
export { GO_MODULES, NODE_PACKAGES, UNITY_PACKAGES, CSHARP_PACKAGES } from './sbom-data-libs'
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,31 +0,0 @@
|
|||||||
/**
|
|
||||||
* Types for SBOM page
|
|
||||||
*/
|
|
||||||
|
|
||||||
export interface Component {
|
|
||||||
type: string
|
|
||||||
name: string
|
|
||||||
version: string
|
|
||||||
purl?: string
|
|
||||||
licenses?: { license: { id: string } }[]
|
|
||||||
category?: string
|
|
||||||
port?: string
|
|
||||||
description?: string
|
|
||||||
license?: string
|
|
||||||
sourceUrl?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SBOMData {
|
|
||||||
bomFormat?: string
|
|
||||||
specVersion?: string
|
|
||||||
version?: number
|
|
||||||
metadata?: {
|
|
||||||
timestamp?: string
|
|
||||||
tools?: { vendor: string; name: string; version: string }[]
|
|
||||||
component?: { type: string; name: string; version: string }
|
|
||||||
}
|
|
||||||
components?: Component[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export type CategoryType = 'all' | 'infrastructure' | 'security-tools' | 'python' | 'go' | 'nodejs' | 'unity' | 'csharp'
|
|
||||||
export type InfoTabType = 'audit' | 'documentation'
|
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import type { Finding } from '../types'
|
|
||||||
|
|
||||||
export function FindingsTab({
|
|
||||||
findings,
|
|
||||||
severityFilter,
|
|
||||||
setSeverityFilter,
|
|
||||||
toolFilter,
|
|
||||||
setToolFilter,
|
|
||||||
getSeverityBadge,
|
|
||||||
}: {
|
|
||||||
findings: Finding[]
|
|
||||||
severityFilter: string | null
|
|
||||||
setSeverityFilter: (v: string | null) => void
|
|
||||||
toolFilter: string | null
|
|
||||||
setToolFilter: (v: string | null) => void
|
|
||||||
getSeverityBadge: (severity: string) => string
|
|
||||||
}) {
|
|
||||||
const filteredFindings = findings.filter(f => {
|
|
||||||
if (severityFilter && f.severity.toUpperCase() !== severityFilter.toUpperCase()) return false
|
|
||||||
if (toolFilter && f.tool.toLowerCase() !== toolFilter.toLowerCase()) return false
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
{/* Filters */}
|
|
||||||
<div className="flex gap-2 mb-4 flex-wrap">
|
|
||||||
<button
|
|
||||||
onClick={() => setSeverityFilter(null)}
|
|
||||||
className={`px-3 py-1 rounded-full text-sm ${!severityFilter ? 'bg-orange-600 text-white' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'}`}
|
|
||||||
>
|
|
||||||
Alle
|
|
||||||
</button>
|
|
||||||
{['CRITICAL', 'HIGH', 'MEDIUM', 'LOW', 'INFO'].map(sev => (
|
|
||||||
<button
|
|
||||||
key={sev}
|
|
||||||
onClick={() => setSeverityFilter(sev)}
|
|
||||||
className={`px-3 py-1 rounded-full text-sm ${severityFilter === sev ? 'bg-orange-600 text-white' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'}`}
|
|
||||||
>
|
|
||||||
{sev}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
<span className="mx-2 border-l border-slate-300" />
|
|
||||||
{['gitleaks', 'semgrep', 'bandit', 'trivy', 'grype'].map(t => (
|
|
||||||
<button
|
|
||||||
key={t}
|
|
||||||
onClick={() => setToolFilter(toolFilter === t ? null : t)}
|
|
||||||
className={`px-3 py-1 rounded-full text-sm capitalize ${toolFilter === t ? 'bg-orange-600 text-white' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'}`}
|
|
||||||
>
|
|
||||||
{t}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{filteredFindings.length === 0 ? (
|
|
||||||
<div className="text-center py-8 text-slate-500">
|
|
||||||
Keine Findings mit diesem Filter gefunden.
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="w-full">
|
|
||||||
<thead>
|
|
||||||
<tr className="border-b border-slate-200">
|
|
||||||
<th className="text-left py-3 px-4 text-xs font-semibold text-slate-500 uppercase">Severity</th>
|
|
||||||
<th className="text-left py-3 px-4 text-xs font-semibold text-slate-500 uppercase">Tool</th>
|
|
||||||
<th className="text-left py-3 px-4 text-xs font-semibold text-slate-500 uppercase">Finding</th>
|
|
||||||
<th className="text-left py-3 px-4 text-xs font-semibold text-slate-500 uppercase">Datei</th>
|
|
||||||
<th className="text-left py-3 px-4 text-xs font-semibold text-slate-500 uppercase">Zeile</th>
|
|
||||||
<th className="text-left py-3 px-4 text-xs font-semibold text-slate-500 uppercase">Gefunden</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{filteredFindings.map((finding, idx) => (
|
|
||||||
<tr key={`${finding.id}-${idx}`} className="border-b border-slate-100 hover:bg-slate-50">
|
|
||||||
<td className="py-3 px-4">
|
|
||||||
<span className={getSeverityBadge(finding.severity)}>{finding.severity}</span>
|
|
||||||
</td>
|
|
||||||
<td className="py-3 px-4 text-sm text-slate-600">{finding.tool}</td>
|
|
||||||
<td className="py-3 px-4">
|
|
||||||
<div className="text-sm text-slate-900">{finding.title}</div>
|
|
||||||
{finding.message && (
|
|
||||||
<div className="text-xs text-slate-500 mt-1">{finding.message}</div>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="py-3 px-4 text-sm text-slate-500 font-mono max-w-xs truncate">
|
|
||||||
{finding.file || '-'}
|
|
||||||
</td>
|
|
||||||
<td className="py-3 px-4 text-sm text-slate-500">{finding.line || '-'}</td>
|
|
||||||
<td className="py-3 px-4 text-sm text-slate-500">
|
|
||||||
{finding.found_at ? new Date(finding.found_at).toLocaleDateString('de-DE') : '-'}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import type { HistoryItem } from '../types'
|
|
||||||
|
|
||||||
export function HistoryTab({ history }: { history: HistoryItem[] }) {
|
|
||||||
const getHistoryStatusColor = (status: string) => {
|
|
||||||
switch (status) {
|
|
||||||
case 'success': return 'bg-green-500'
|
|
||||||
case 'warning': return 'bg-yellow-500'
|
|
||||||
case 'error': return 'bg-red-500'
|
|
||||||
default: return 'bg-slate-400'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (history.length === 0) {
|
|
||||||
return (
|
|
||||||
<div className="text-center py-8 text-slate-500">
|
|
||||||
Keine Scan-Historie vorhanden.
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="relative">
|
|
||||||
<div className="absolute left-4 top-0 bottom-0 w-0.5 bg-slate-200" />
|
|
||||||
{history.map((item, idx) => (
|
|
||||||
<div key={idx} className="relative pl-10 pb-6">
|
|
||||||
<div className={`absolute left-2.5 w-3 h-3 rounded-full ${getHistoryStatusColor(item.status)}`} />
|
|
||||||
<div className="bg-slate-50 rounded-lg p-4 border border-slate-200">
|
|
||||||
<div className="flex justify-between items-start mb-1">
|
|
||||||
<span className="font-semibold text-slate-900">{item.title}</span>
|
|
||||||
<span className="text-xs text-slate-500">
|
|
||||||
{new Date(item.timestamp).toLocaleString('de-DE')}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-slate-600">{item.description}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,172 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import type { MonitoringMetric, ActiveAlert } from '../types'
|
|
||||||
|
|
||||||
export function MonitoringTab({
|
|
||||||
monitoringMetrics,
|
|
||||||
activeAlerts,
|
|
||||||
}: {
|
|
||||||
monitoringMetrics: MonitoringMetric[]
|
|
||||||
activeAlerts: ActiveAlert[]
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Real-time Metrics */}
|
|
||||||
<div>
|
|
||||||
<h3 className="text-lg font-semibold text-slate-900 mb-4 flex items-center gap-2">
|
|
||||||
<svg className="w-5 h-5 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
|
||||||
</svg>
|
|
||||||
Security Metriken
|
|
||||||
</h3>
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
|
|
||||||
{monitoringMetrics.map((metric, idx) => (
|
|
||||||
<div
|
|
||||||
key={idx}
|
|
||||||
className={`rounded-lg p-4 border ${
|
|
||||||
metric.status === 'critical' ? 'bg-red-50 border-red-200' :
|
|
||||||
metric.status === 'warning' ? 'bg-yellow-50 border-yellow-200' :
|
|
||||||
'bg-green-50 border-green-200'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="flex justify-between items-start mb-2">
|
|
||||||
<span className="text-xs text-slate-600">{metric.name}</span>
|
|
||||||
<span className={`text-xs ${
|
|
||||||
metric.trend === 'up' ? 'text-red-600' :
|
|
||||||
metric.trend === 'down' ? 'text-green-600' :
|
|
||||||
'text-slate-500'
|
|
||||||
}`}>
|
|
||||||
{metric.trend === 'up' ? '↑' : metric.trend === 'down' ? '↓' : '→'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className={`text-2xl font-bold ${
|
|
||||||
metric.status === 'critical' ? 'text-red-700' :
|
|
||||||
metric.status === 'warning' ? 'text-yellow-700' :
|
|
||||||
'text-green-700'
|
|
||||||
}`}>
|
|
||||||
{metric.value}
|
|
||||||
<span className="text-sm font-normal ml-1">{metric.unit}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Active Alerts */}
|
|
||||||
<div>
|
|
||||||
<h3 className="text-lg font-semibold text-slate-900 mb-4 flex items-center gap-2">
|
|
||||||
<svg className="w-5 h-5 text-orange-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />
|
|
||||||
</svg>
|
|
||||||
Aktive Alerts
|
|
||||||
{activeAlerts.filter(a => !a.acknowledged).length > 0 && (
|
|
||||||
<span className="ml-2 px-2 py-0.5 bg-red-500 text-white text-xs rounded-full">
|
|
||||||
{activeAlerts.filter(a => !a.acknowledged).length}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</h3>
|
|
||||||
{activeAlerts.length === 0 ? (
|
|
||||||
<div className="text-center py-8 bg-green-50 rounded-lg border border-green-200">
|
|
||||||
<span className="text-4xl block mb-2">✓</span>
|
|
||||||
<span className="text-green-700">Keine aktiven Security-Alerts</span>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-2">
|
|
||||||
{activeAlerts.map((alert) => (
|
|
||||||
<div
|
|
||||||
key={alert.id}
|
|
||||||
className={`flex items-center justify-between p-4 rounded-lg border ${
|
|
||||||
alert.severity === 'critical' ? 'bg-red-50 border-red-200' :
|
|
||||||
alert.severity === 'high' ? 'bg-orange-50 border-orange-200' :
|
|
||||||
alert.severity === 'medium' ? 'bg-yellow-50 border-yellow-200' :
|
|
||||||
'bg-blue-50 border-blue-200'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<span className={`px-2 py-1 text-xs font-semibold rounded uppercase ${
|
|
||||||
alert.severity === 'critical' ? 'bg-red-100 text-red-800' :
|
|
||||||
alert.severity === 'high' ? 'bg-orange-100 text-orange-800' :
|
|
||||||
alert.severity === 'medium' ? 'bg-yellow-100 text-yellow-800' :
|
|
||||||
'bg-blue-100 text-blue-800'
|
|
||||||
}`}>
|
|
||||||
{alert.severity}
|
|
||||||
</span>
|
|
||||||
<div>
|
|
||||||
<div className="font-medium text-slate-900">{alert.title}</div>
|
|
||||||
<div className="text-xs text-slate-500">
|
|
||||||
{alert.source} • {new Date(alert.timestamp).toLocaleString('de-DE')}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{!alert.acknowledged && (
|
|
||||||
<button className="px-3 py-1 text-xs bg-white border border-slate-300 rounded hover:bg-slate-50">
|
|
||||||
Bestaetigen
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Security Overview Cards */}
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
||||||
<div className="bg-slate-50 rounded-lg p-4 border border-slate-200">
|
|
||||||
<h4 className="font-medium text-slate-900 mb-3 flex items-center gap-2">
|
|
||||||
<svg className="w-4 h-4 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
|
||||||
</svg>
|
|
||||||
Authentifizierung
|
|
||||||
</h4>
|
|
||||||
<div className="space-y-2 text-sm">
|
|
||||||
<div className="flex justify-between"><span className="text-slate-600">Aktive Sessions</span><span className="font-medium">24</span></div>
|
|
||||||
<div className="flex justify-between"><span className="text-slate-600">Fehlgeschlagene Logins (24h)</span><span className="font-medium text-green-600">0</span></div>
|
|
||||||
<div className="flex justify-between"><span className="text-slate-600">2FA-Quote</span><span className="font-medium text-green-600">100%</span></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-slate-50 rounded-lg p-4 border border-slate-200">
|
|
||||||
<h4 className="font-medium text-slate-900 mb-3 flex items-center gap-2">
|
|
||||||
<svg className="w-4 h-4 text-purple-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
|
||||||
</svg>
|
|
||||||
SSL/TLS
|
|
||||||
</h4>
|
|
||||||
<div className="space-y-2 text-sm">
|
|
||||||
<div className="flex justify-between"><span className="text-slate-600">Zertifikate</span><span className="font-medium">5 aktiv</span></div>
|
|
||||||
<div className="flex justify-between"><span className="text-slate-600">Naechster Ablauf</span><span className="font-medium text-green-600">45 Tage</span></div>
|
|
||||||
<div className="flex justify-between"><span className="text-slate-600">TLS Version</span><span className="font-medium">1.3</span></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-slate-50 rounded-lg p-4 border border-slate-200">
|
|
||||||
<h4 className="font-medium text-slate-900 mb-3 flex items-center gap-2">
|
|
||||||
<svg className="w-4 h-4 text-orange-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
|
||||||
</svg>
|
|
||||||
Firewall
|
|
||||||
</h4>
|
|
||||||
<div className="space-y-2 text-sm">
|
|
||||||
<div className="flex justify-between"><span className="text-slate-600">Blockierte IPs (24h)</span><span className="font-medium">12</span></div>
|
|
||||||
<div className="flex justify-between"><span className="text-slate-600">Rate Limit Hits</span><span className="font-medium text-yellow-600">7</span></div>
|
|
||||||
<div className="flex justify-between"><span className="text-slate-600">WAF Status</span><span className="font-medium text-green-600">Aktiv</span></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Link to CI/CD */}
|
|
||||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<svg className="w-5 h-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
|
||||||
</svg>
|
|
||||||
<div>
|
|
||||||
<div className="font-medium text-blue-900">Pipeline Security</div>
|
|
||||||
<div className="text-sm text-blue-700">Security-Scans in CI/CD Pipelines und Container-Status</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<a href="/infrastructure/ci-cd" className="px-4 py-2 bg-blue-600 text-white rounded-lg text-sm font-medium hover:bg-blue-700 transition-colors">
|
|
||||||
CI/CD Dashboard →
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { useState } from 'react'
|
|
||||||
|
|
||||||
export function SecurityDocsSection() {
|
|
||||||
const [showFullDocs, setShowFullDocs] = useState(false)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
|
|
||||||
<div className="p-6">
|
|
||||||
<div className="flex justify-between items-center mb-4">
|
|
||||||
<h3 className="text-lg font-semibold text-slate-900 flex items-center gap-2">
|
|
||||||
<svg className="w-5 h-5 text-slate-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
|
||||||
</svg>
|
|
||||||
Security Dokumentation
|
|
||||||
</h3>
|
|
||||||
<button
|
|
||||||
onClick={() => setShowFullDocs(!showFullDocs)}
|
|
||||||
className="px-4 py-2 bg-slate-100 text-slate-700 rounded-lg hover:bg-slate-200 transition-colors flex items-center gap-2 text-sm font-medium"
|
|
||||||
>
|
|
||||||
<svg className={`w-4 h-4 transition-transform ${showFullDocs ? 'rotate-180' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
|
||||||
</svg>
|
|
||||||
{showFullDocs ? 'Weniger anzeigen' : 'Vollstaendige Dokumentation'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Short Description */}
|
|
||||||
<div className="prose prose-slate max-w-none">
|
|
||||||
<p className="text-slate-600">
|
|
||||||
Das Security Dashboard bietet einen zentralen Ueberblick ueber alle DevSecOps-Aktivitaeten.
|
|
||||||
Es integriert 6 Security-Tools fuer umfassende Code- und Infrastruktur-Sicherheit:
|
|
||||||
Secrets Detection, Static Analysis (SAST), Dependency Scanning und SBOM-Generierung.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tool Quick Reference */}
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3 mt-4">
|
|
||||||
{[
|
|
||||||
{ bg: 'bg-red-50', icon: '🔑', name: 'Gitleaks', label: 'Secrets', color: 'text-red-800', labelColor: 'text-red-600' },
|
|
||||||
{ bg: 'bg-blue-50', icon: '🔍', name: 'Semgrep', label: 'SAST', color: 'text-blue-800', labelColor: 'text-blue-600' },
|
|
||||||
{ bg: 'bg-yellow-50', icon: '🐍', name: 'Bandit', label: 'Python', color: 'text-yellow-800', labelColor: 'text-yellow-600' },
|
|
||||||
{ bg: 'bg-purple-50', icon: '🔒', name: 'Trivy', label: 'Container', color: 'text-purple-800', labelColor: 'text-purple-600' },
|
|
||||||
{ bg: 'bg-green-50', icon: '🐛', name: 'Grype', label: 'Dependencies', color: 'text-green-800', labelColor: 'text-green-600' },
|
|
||||||
{ bg: 'bg-orange-50', icon: '📦', name: 'Syft', label: 'SBOM', color: 'text-orange-800', labelColor: 'text-orange-600' },
|
|
||||||
].map((tool) => (
|
|
||||||
<div key={tool.name} className={`${tool.bg} p-3 rounded-lg text-center`}>
|
|
||||||
<span className="text-lg">{tool.icon}</span>
|
|
||||||
<p className={`text-xs font-medium ${tool.color} mt-1`}>{tool.name}</p>
|
|
||||||
<p className={`text-xs ${tool.labelColor}`}>{tool.label}</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Full Documentation (Expandable) */}
|
|
||||||
{showFullDocs && (
|
|
||||||
<div className="mt-6 bg-slate-50 rounded-lg p-6 border border-slate-200">
|
|
||||||
<div className="prose prose-slate max-w-none prose-headings:text-slate-900 prose-p:text-slate-600 prose-li:text-slate-600">
|
|
||||||
<h3>1. Security Tools Uebersicht</h3>
|
|
||||||
<h4>🔑 Gitleaks - Secrets Detection</h4>
|
|
||||||
<p>Durchsucht die gesamte Git-Historie nach versehentlich eingecheckten Secrets wie API-Keys, Passwoertern und Tokens.</p>
|
|
||||||
<ul>
|
|
||||||
<li><strong>Scan-Bereich:</strong> Git-Historie, Commits, Branches</li>
|
|
||||||
<li><strong>Erkannte Secrets:</strong> AWS Keys, GitHub Tokens, Private Keys, Passwoerter</li>
|
|
||||||
<li><strong>Ausgabe:</strong> JSON-Report mit Fundstelle, Commit-Hash, Autor</li>
|
|
||||||
</ul>
|
|
||||||
<h4>🔍 Semgrep - Static Application Security Testing</h4>
|
|
||||||
<p>Fuehrt regelbasierte statische Code-Analyse durch, um Sicherheitsluecken und Anti-Patterns zu finden.</p>
|
|
||||||
<ul>
|
|
||||||
<li><strong>Unterstuetzte Sprachen:</strong> Python, JavaScript, TypeScript, Go, Java</li>
|
|
||||||
<li><strong>Regelsets:</strong> OWASP Top 10, CWE, Security Best Practices</li>
|
|
||||||
<li><strong>Findings:</strong> SQL Injection, XSS, Path Traversal, Insecure Deserialization</li>
|
|
||||||
</ul>
|
|
||||||
<h3>2. Severity-Klassifizierung</h3>
|
|
||||||
<table className="min-w-full text-sm">
|
|
||||||
<thead><tr className="border-b"><th className="text-left py-2">Severity</th><th className="text-left py-2">CVSS Score</th><th className="text-left py-2">Reaktionszeit</th></tr></thead>
|
|
||||||
<tbody>
|
|
||||||
<tr className="border-b"><td className="py-2"><span className="px-2 py-0.5 bg-red-100 text-red-800 rounded text-xs font-semibold">CRITICAL</span></td><td>9.0 - 10.0</td><td>Sofort (24h)</td></tr>
|
|
||||||
<tr className="border-b"><td className="py-2"><span className="px-2 py-0.5 bg-orange-100 text-orange-800 rounded text-xs font-semibold">HIGH</span></td><td>7.0 - 8.9</td><td>1-3 Tage</td></tr>
|
|
||||||
<tr className="border-b"><td className="py-2"><span className="px-2 py-0.5 bg-yellow-100 text-yellow-800 rounded text-xs font-semibold">MEDIUM</span></td><td>4.0 - 6.9</td><td>1-2 Wochen</td></tr>
|
|
||||||
<tr className="border-b"><td className="py-2"><span className="px-2 py-0.5 bg-green-100 text-green-800 rounded text-xs font-semibold">LOW</span></td><td>0.1 - 3.9</td><td>Naechster Sprint</td></tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
<h3>3. Scan-Workflow</h3>
|
|
||||||
<pre className="bg-slate-800 text-slate-100 p-4 rounded-lg overflow-x-auto text-sm">
|
|
||||||
{`1. Secrets Detection (Gitleaks)
|
|
||||||
2. Static Analysis (Semgrep + Bandit)
|
|
||||||
3. Dependency Scan (Trivy + Grype)
|
|
||||||
4. SBOM Generation (Syft)
|
|
||||||
5. Report & Dashboard`}
|
|
||||||
</pre>
|
|
||||||
<h3>4. API-Endpunkte</h3>
|
|
||||||
<table className="min-w-full text-sm font-mono">
|
|
||||||
<thead><tr className="border-b"><th className="text-left py-2">Methode</th><th className="text-left py-2">Endpoint</th><th className="text-left py-2 font-sans">Beschreibung</th></tr></thead>
|
|
||||||
<tbody>
|
|
||||||
<tr className="border-b"><td className="py-2"><span className="bg-blue-100 text-blue-700 px-1 rounded">GET</span></td><td>/api/v1/security/tools</td><td className="font-sans">Tool-Status abrufen</td></tr>
|
|
||||||
<tr className="border-b"><td className="py-2"><span className="bg-blue-100 text-blue-700 px-1 rounded">GET</span></td><td>/api/v1/security/findings</td><td className="font-sans">Alle Findings abrufen</td></tr>
|
|
||||||
<tr className="border-b"><td className="py-2"><span className="bg-green-100 text-green-700 px-1 rounded">POST</span></td><td>/api/v1/security/scan/all</td><td className="font-sans">Full Scan starten</td></tr>
|
|
||||||
<tr><td className="py-2"><span className="bg-green-100 text-green-700 px-1 rounded">POST</span></td><td>/api/v1/security/scan/[tool]</td><td className="font-sans">Einzelnes Tool scannen</td></tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import type { ToolStatus, Finding, ScanType } from '../types'
|
|
||||||
|
|
||||||
export function SecurityOverviewTab({
|
|
||||||
tools,
|
|
||||||
findings,
|
|
||||||
scanning,
|
|
||||||
onRunScan,
|
|
||||||
onShowAllFindings,
|
|
||||||
toolDescriptions,
|
|
||||||
toolToScanType,
|
|
||||||
getSeverityBadge,
|
|
||||||
getStatusBadge,
|
|
||||||
}: {
|
|
||||||
tools: ToolStatus[]
|
|
||||||
findings: Finding[]
|
|
||||||
scanning: string | null
|
|
||||||
onRunScan: (scanType: ScanType) => void
|
|
||||||
onShowAllFindings: () => void
|
|
||||||
toolDescriptions: Record<string, { icon: string; desc: string }>
|
|
||||||
toolToScanType: Record<string, ScanType>
|
|
||||||
getSeverityBadge: (severity: string) => string
|
|
||||||
getStatusBadge: (installed: boolean) => string
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Tools Grid */}
|
|
||||||
<div>
|
|
||||||
<h3 className="text-lg font-semibold text-slate-900 mb-4">DevSecOps Tools</h3>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
||||||
{tools.map(tool => {
|
|
||||||
const info = toolDescriptions[tool.name.toLowerCase()] || { icon: '🔧', desc: 'Security Tool' }
|
|
||||||
return (
|
|
||||||
<div key={tool.name} className="bg-slate-50 rounded-lg p-4 border border-slate-200">
|
|
||||||
<div className="flex justify-between items-start mb-2">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-xl">{info.icon}</span>
|
|
||||||
<span className="font-semibold text-slate-900">{tool.name}</span>
|
|
||||||
</div>
|
|
||||||
<span className={getStatusBadge(tool.installed)}>
|
|
||||||
{tool.installed ? 'Installiert' : 'Nicht installiert'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-slate-600 mb-3">{info.desc}</p>
|
|
||||||
<div className="flex justify-between items-center text-xs text-slate-500">
|
|
||||||
<span>{tool.version || '-'}</span>
|
|
||||||
<span>Letzter Scan: {tool.last_run || 'Nie'}</span>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => onRunScan(toolToScanType[tool.name.toLowerCase()] || 'all')}
|
|
||||||
disabled={scanning !== null || !tool.installed}
|
|
||||||
className={`mt-3 w-full px-3 py-1.5 text-sm border rounded transition-colors flex items-center justify-center gap-2 ${
|
|
||||||
scanning === toolToScanType[tool.name.toLowerCase()]
|
|
||||||
? 'bg-orange-100 border-orange-300 text-orange-700'
|
|
||||||
: 'bg-white border-slate-300 hover:bg-slate-50 disabled:opacity-50'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{scanning === toolToScanType[tool.name.toLowerCase()] ? (
|
|
||||||
<>
|
|
||||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-orange-600" />
|
|
||||||
<span>Scan laeuft...</span>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
'Scan starten'
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Recent Findings */}
|
|
||||||
<div>
|
|
||||||
<h3 className="text-lg font-semibold text-slate-900 mb-4">Aktuelle Findings</h3>
|
|
||||||
{findings.length === 0 ? (
|
|
||||||
<div className="text-center py-8 text-slate-500">
|
|
||||||
<span className="text-4xl block mb-2">🎉</span>
|
|
||||||
Keine Findings gefunden. Das ist gut!
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="w-full">
|
|
||||||
<thead>
|
|
||||||
<tr className="border-b border-slate-200">
|
|
||||||
<th className="text-left py-3 px-4 text-xs font-semibold text-slate-500 uppercase">Severity</th>
|
|
||||||
<th className="text-left py-3 px-4 text-xs font-semibold text-slate-500 uppercase">Tool</th>
|
|
||||||
<th className="text-left py-3 px-4 text-xs font-semibold text-slate-500 uppercase">Finding</th>
|
|
||||||
<th className="text-left py-3 px-4 text-xs font-semibold text-slate-500 uppercase">Datei</th>
|
|
||||||
<th className="text-left py-3 px-4 text-xs font-semibold text-slate-500 uppercase">Gefunden</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{findings.slice(0, 10).map((finding, idx) => (
|
|
||||||
<tr key={`${finding.id}-${idx}`} className="border-b border-slate-100 hover:bg-slate-50">
|
|
||||||
<td className="py-3 px-4">
|
|
||||||
<span className={getSeverityBadge(finding.severity)}>{finding.severity}</span>
|
|
||||||
</td>
|
|
||||||
<td className="py-3 px-4 text-sm text-slate-600">{finding.tool}</td>
|
|
||||||
<td className="py-3 px-4 text-sm text-slate-900">{finding.title}</td>
|
|
||||||
<td className="py-3 px-4 text-sm text-slate-500 font-mono">{finding.file || '-'}</td>
|
|
||||||
<td className="py-3 px-4 text-sm text-slate-500">
|
|
||||||
{finding.found_at ? new Date(finding.found_at).toLocaleString('de-DE', {
|
|
||||||
day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit'
|
|
||||||
}) : '-'}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
{findings.length > 10 && (
|
|
||||||
<button onClick={onShowAllFindings} className="mt-4 text-sm text-orange-600 hover:text-orange-700">
|
|
||||||
Alle {findings.length} Findings anzeigen →
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import type { ToolStatus, ScanType } from '../types'
|
|
||||||
|
|
||||||
export function ToolsTab({
|
|
||||||
tools,
|
|
||||||
scanning,
|
|
||||||
onRunScan,
|
|
||||||
toolDescriptions,
|
|
||||||
toolToScanType,
|
|
||||||
getStatusBadge,
|
|
||||||
}: {
|
|
||||||
tools: ToolStatus[]
|
|
||||||
scanning: string | null
|
|
||||||
onRunScan: (scanType: ScanType) => void
|
|
||||||
toolDescriptions: Record<string, { icon: string; desc: string }>
|
|
||||||
toolToScanType: Record<string, ScanType>
|
|
||||||
getStatusBadge: (installed: boolean) => string
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
||||||
{tools.map(tool => {
|
|
||||||
const info = toolDescriptions[tool.name.toLowerCase()] || { icon: '🔧', desc: 'Security Tool' }
|
|
||||||
return (
|
|
||||||
<div key={tool.name} className="bg-white border border-slate-200 rounded-lg p-6">
|
|
||||||
<div className="flex justify-between items-start mb-4">
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center gap-3 mb-1">
|
|
||||||
<span className="text-2xl">{info.icon}</span>
|
|
||||||
<h3 className="text-lg font-semibold text-slate-900">{tool.name}</h3>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-slate-600">{info.desc}</p>
|
|
||||||
</div>
|
|
||||||
<span className={getStatusBadge(tool.installed)}>
|
|
||||||
{tool.installed ? 'Installiert' : 'Nicht installiert'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2 text-sm">
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-slate-500">Version:</span>
|
|
||||||
<span className="font-mono">{tool.version || '-'}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-slate-500">Letzter Scan:</span>
|
|
||||||
<span>{tool.last_run || 'Nie'}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-slate-500">Findings:</span>
|
|
||||||
<span className="font-semibold">{tool.last_findings}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2 mt-4">
|
|
||||||
<button
|
|
||||||
onClick={() => onRunScan(toolToScanType[tool.name.toLowerCase()] || 'all')}
|
|
||||||
disabled={scanning !== null || !tool.installed}
|
|
||||||
className={`flex-1 px-4 py-2 rounded-lg text-sm font-medium transition-colors flex items-center justify-center gap-2 ${
|
|
||||||
scanning === toolToScanType[tool.name.toLowerCase()]
|
|
||||||
? 'bg-orange-200 text-orange-800'
|
|
||||||
: 'bg-orange-600 text-white hover:bg-orange-700 disabled:opacity-50'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{scanning === toolToScanType[tool.name.toLowerCase()] ? (
|
|
||||||
<>
|
|
||||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-orange-700" />
|
|
||||||
<span>Scan laeuft...</span>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
'Scan starten'
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => window.open(`/api/v1/security/reports/${tool.name.toLowerCase()}`, '_blank')}
|
|
||||||
className="px-4 py-2 border border-slate-300 text-slate-700 rounded-lg text-sm font-medium hover:bg-slate-50 transition-colors"
|
|
||||||
>
|
|
||||||
Report
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,57 +0,0 @@
|
|||||||
/**
|
|
||||||
* Types for Security Dashboard
|
|
||||||
*/
|
|
||||||
|
|
||||||
export interface ToolStatus {
|
|
||||||
name: string
|
|
||||||
installed: boolean
|
|
||||||
version: string | null
|
|
||||||
last_run: string | null
|
|
||||||
last_findings: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Finding {
|
|
||||||
id: string
|
|
||||||
tool: string
|
|
||||||
severity: string
|
|
||||||
title: string
|
|
||||||
message: string | null
|
|
||||||
file: string | null
|
|
||||||
line: number | null
|
|
||||||
found_at: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SeveritySummary {
|
|
||||||
critical: number
|
|
||||||
high: number
|
|
||||||
medium: number
|
|
||||||
low: number
|
|
||||||
info: number
|
|
||||||
total: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface HistoryItem {
|
|
||||||
timestamp: string
|
|
||||||
title: string
|
|
||||||
description: string
|
|
||||||
status: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ScanType = 'secrets' | 'sast' | 'deps' | 'containers' | 'sbom' | 'all'
|
|
||||||
|
|
||||||
export interface MonitoringMetric {
|
|
||||||
name: string
|
|
||||||
value: number
|
|
||||||
unit: string
|
|
||||||
status: 'ok' | 'warning' | 'critical'
|
|
||||||
trend: 'up' | 'down' | 'stable'
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ActiveAlert {
|
|
||||||
id: string
|
|
||||||
severity: 'critical' | 'high' | 'medium' | 'low'
|
|
||||||
title: string
|
|
||||||
source: string
|
|
||||||
timestamp: string
|
|
||||||
acknowledged: boolean
|
|
||||||
}
|
|
||||||
@@ -1,427 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { useState } from 'react'
|
|
||||||
import type { LLMRoutingOption } from '@/types/infrastructure-modules'
|
|
||||||
import type { FailedTest, BacklogItem, BacklogPriority } from '../types'
|
|
||||||
|
|
||||||
// ==============================================================================
|
|
||||||
// FailedTestCard
|
|
||||||
// ==============================================================================
|
|
||||||
|
|
||||||
function FailedTestCard({
|
|
||||||
test,
|
|
||||||
onStatusChange,
|
|
||||||
onPriorityChange,
|
|
||||||
priority = 'medium',
|
|
||||||
failureCount = 1,
|
|
||||||
}: {
|
|
||||||
test: FailedTest
|
|
||||||
onStatusChange: (testId: string, status: string) => void
|
|
||||||
onPriorityChange?: (testId: string, priority: string) => void
|
|
||||||
priority?: BacklogPriority
|
|
||||||
failureCount?: number
|
|
||||||
}) {
|
|
||||||
const errorTypeColors: Record<string, string> = {
|
|
||||||
assertion: 'bg-amber-100 text-amber-700',
|
|
||||||
nil_pointer: 'bg-red-100 text-red-700',
|
|
||||||
type_error: 'bg-purple-100 text-purple-700',
|
|
||||||
network: 'bg-blue-100 text-blue-700',
|
|
||||||
timeout: 'bg-orange-100 text-orange-700',
|
|
||||||
logic_error: 'bg-slate-100 text-slate-700',
|
|
||||||
unknown: 'bg-slate-100 text-slate-700',
|
|
||||||
}
|
|
||||||
|
|
||||||
const statusColors: Record<string, string> = {
|
|
||||||
open: 'bg-red-100 text-red-700',
|
|
||||||
in_progress: 'bg-blue-100 text-blue-700',
|
|
||||||
fixed: 'bg-emerald-100 text-emerald-700',
|
|
||||||
wont_fix: 'bg-slate-100 text-slate-700',
|
|
||||||
flaky: 'bg-purple-100 text-purple-700',
|
|
||||||
}
|
|
||||||
|
|
||||||
const priorityColors: Record<string, string> = {
|
|
||||||
critical: 'bg-red-500 text-white',
|
|
||||||
high: 'bg-orange-500 text-white',
|
|
||||||
medium: 'bg-yellow-500 text-white',
|
|
||||||
low: 'bg-slate-400 text-white',
|
|
||||||
}
|
|
||||||
|
|
||||||
const priorityLabels: Record<string, string> = {
|
|
||||||
critical: '!!! Kritisch',
|
|
||||||
high: '!! Hoch',
|
|
||||||
medium: '! Mittel',
|
|
||||||
low: 'Niedrig',
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="bg-white rounded-lg border border-slate-200 p-4 hover:border-red-300 transition-colors">
|
|
||||||
<div className="flex items-start justify-between mb-3">
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="flex items-center gap-2 mb-1 flex-wrap">
|
|
||||||
<span className={`px-2 py-0.5 rounded text-xs font-medium ${priorityColors[priority]}`}>
|
|
||||||
{priorityLabels[priority]}
|
|
||||||
</span>
|
|
||||||
<span className={`px-2 py-0.5 rounded text-xs font-medium ${errorTypeColors[test.error_type] || errorTypeColors.unknown}`}>
|
|
||||||
{test.error_type.replace('_', ' ')}
|
|
||||||
</span>
|
|
||||||
<span className="text-xs text-slate-400">{test.service}</span>
|
|
||||||
{failureCount > 1 && (
|
|
||||||
<span className="px-1.5 py-0.5 rounded bg-red-100 text-red-600 text-xs font-medium">
|
|
||||||
{failureCount}x fehlgeschlagen
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<h4 className="font-mono text-sm font-medium text-slate-900 truncate" title={test.name}>
|
|
||||||
{test.name}
|
|
||||||
</h4>
|
|
||||||
<p className="text-xs text-slate-500 truncate" title={test.file_path}>
|
|
||||||
{test.file_path}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-1 ml-2">
|
|
||||||
<select
|
|
||||||
value={test.status}
|
|
||||||
onChange={(e) => onStatusChange(test.id, e.target.value)}
|
|
||||||
className={`px-2 py-1 rounded text-xs font-medium cursor-pointer border-0 ${statusColors[test.status]}`}
|
|
||||||
>
|
|
||||||
<option value="open">Offen</option>
|
|
||||||
<option value="in_progress">In Arbeit</option>
|
|
||||||
<option value="fixed">Behoben</option>
|
|
||||||
<option value="wont_fix">Ignoriert</option>
|
|
||||||
<option value="flaky">Flaky</option>
|
|
||||||
</select>
|
|
||||||
{onPriorityChange && (
|
|
||||||
<select
|
|
||||||
value={priority}
|
|
||||||
onChange={(e) => onPriorityChange(test.id, e.target.value)}
|
|
||||||
className="px-2 py-1 rounded text-xs font-medium cursor-pointer border border-slate-200"
|
|
||||||
>
|
|
||||||
<option value="critical">Kritisch</option>
|
|
||||||
<option value="high">Hoch</option>
|
|
||||||
<option value="medium">Mittel</option>
|
|
||||||
<option value="low">Niedrig</option>
|
|
||||||
</select>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-red-50 rounded-lg p-3 mb-3">
|
|
||||||
<p className="text-sm text-red-800 font-medium mb-1">Fehlermeldung:</p>
|
|
||||||
<p className="text-xs text-red-700 font-mono break-words">
|
|
||||||
{test.error_message || 'Keine Details verfuegbar'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{test.suggestion && (
|
|
||||||
<div className="bg-emerald-50 rounded-lg p-3">
|
|
||||||
<p className="text-sm text-emerald-800 font-medium mb-1">💡 Loesungsvorschlag:</p>
|
|
||||||
<p className="text-xs text-emerald-700">
|
|
||||||
{test.suggestion}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="mt-3 pt-3 border-t border-slate-100 flex items-center justify-between text-xs text-slate-400">
|
|
||||||
<span>Zuletzt fehlgeschlagen: {test.last_failed ? new Date(test.last_failed).toLocaleString('de-DE') : 'Unbekannt'}</span>
|
|
||||||
<button
|
|
||||||
className="text-orange-600 hover:text-orange-700 font-medium"
|
|
||||||
onClick={() => {
|
|
||||||
navigator.clipboard.writeText(test.id)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
ID kopieren
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==============================================================================
|
|
||||||
// BacklogTab
|
|
||||||
// ==============================================================================
|
|
||||||
|
|
||||||
export function BacklogTab({
|
|
||||||
failedTests,
|
|
||||||
onStatusChange,
|
|
||||||
onPriorityChange,
|
|
||||||
isLoading,
|
|
||||||
backlogItems,
|
|
||||||
usePostgres = false,
|
|
||||||
}: {
|
|
||||||
failedTests: FailedTest[]
|
|
||||||
onStatusChange: (testId: string, status: string) => void
|
|
||||||
onPriorityChange?: (testId: string, priority: string) => void
|
|
||||||
isLoading: boolean
|
|
||||||
backlogItems?: BacklogItem[]
|
|
||||||
usePostgres?: boolean
|
|
||||||
}) {
|
|
||||||
const [filterStatus, setFilterStatus] = useState<string>('open')
|
|
||||||
const [filterService, setFilterService] = useState<string>('all')
|
|
||||||
const [filterPriority, setFilterPriority] = useState<string>('all')
|
|
||||||
const [llmAutoAnalysis, setLlmAutoAnalysis] = useState<boolean>(true)
|
|
||||||
const [llmRouting, setLlmRouting] = useState<LLMRoutingOption>('smart_routing')
|
|
||||||
|
|
||||||
// Nutze PostgreSQL-Backlog wenn verfuegbar, sonst Legacy
|
|
||||||
const items = usePostgres && backlogItems ? backlogItems : failedTests
|
|
||||||
|
|
||||||
// Gruppiere nach Service
|
|
||||||
const services = [...new Set(items.map(t => 'service' in t ? t.service : (t as BacklogItem).service))]
|
|
||||||
|
|
||||||
// Filtere Items
|
|
||||||
const filteredItems = items.filter(item => {
|
|
||||||
const status = 'status' in item ? item.status : 'open'
|
|
||||||
const service = 'service' in item ? item.service : ''
|
|
||||||
const priority = 'priority' in item ? (item as BacklogItem).priority : 'medium'
|
|
||||||
|
|
||||||
if (filterStatus !== 'all' && status !== filterStatus) return false
|
|
||||||
if (filterService !== 'all' && service !== filterService) return false
|
|
||||||
if (filterPriority !== 'all' && priority !== filterPriority) return false
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
|
|
||||||
// Zaehle nach Status
|
|
||||||
const openCount = items.filter(t => t.status === 'open').length
|
|
||||||
const inProgressCount = items.filter(t => t.status === 'in_progress').length
|
|
||||||
const fixedCount = items.filter(t => t.status === 'fixed').length
|
|
||||||
const flakyCount = items.filter(t => t.status === 'flaky').length
|
|
||||||
|
|
||||||
// Zaehle nach Prioritaet (nur bei PostgreSQL)
|
|
||||||
const criticalCount = backlogItems?.filter(t => t.priority === 'critical').length || 0
|
|
||||||
const highCount = backlogItems?.filter(t => t.priority === 'high').length || 0
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-center py-12">
|
|
||||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-orange-600"></div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Konvertiere BacklogItem zu FailedTest fuer die Anzeige
|
|
||||||
const convertToFailedTest = (item: BacklogItem): FailedTest => ({
|
|
||||||
id: String(item.id),
|
|
||||||
name: item.test_name,
|
|
||||||
service: item.service,
|
|
||||||
file_path: item.test_file || '',
|
|
||||||
error_message: item.error_message || '',
|
|
||||||
error_type: item.error_type || 'unknown',
|
|
||||||
suggestion: item.fix_suggestion || '',
|
|
||||||
run_id: '',
|
|
||||||
last_failed: item.last_failed_at,
|
|
||||||
status: item.status,
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Stats */}
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
|
|
||||||
<div className="bg-red-50 border border-red-200 rounded-xl p-4">
|
|
||||||
<p className="text-2xl font-bold text-red-600">{openCount}</p>
|
|
||||||
<p className="text-sm text-red-700">Offene Fehler</p>
|
|
||||||
</div>
|
|
||||||
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4">
|
|
||||||
<p className="text-2xl font-bold text-blue-600">{inProgressCount}</p>
|
|
||||||
<p className="text-sm text-blue-700">In Arbeit</p>
|
|
||||||
</div>
|
|
||||||
<div className="bg-emerald-50 border border-emerald-200 rounded-xl p-4">
|
|
||||||
<p className="text-2xl font-bold text-emerald-600">{fixedCount}</p>
|
|
||||||
<p className="text-sm text-emerald-700">Behoben</p>
|
|
||||||
</div>
|
|
||||||
<div className="bg-purple-50 border border-purple-200 rounded-xl p-4">
|
|
||||||
<p className="text-2xl font-bold text-purple-600">{flakyCount}</p>
|
|
||||||
<p className="text-sm text-purple-700">Flaky</p>
|
|
||||||
</div>
|
|
||||||
{usePostgres && criticalCount + highCount > 0 && (
|
|
||||||
<div className="bg-orange-50 border border-orange-200 rounded-xl p-4">
|
|
||||||
<p className="text-2xl font-bold text-orange-600">{criticalCount + highCount}</p>
|
|
||||||
<p className="text-sm text-orange-700">Kritisch/Hoch</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* PostgreSQL Badge */}
|
|
||||||
{usePostgres && (
|
|
||||||
<div className="flex items-center gap-2 px-3 py-1.5 bg-emerald-50 border border-emerald-200 rounded-lg w-fit">
|
|
||||||
<svg className="w-4 h-4 text-emerald-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
|
||||||
</svg>
|
|
||||||
<span className="text-xs text-emerald-700 font-medium">Persistente Speicherung aktiv (PostgreSQL)</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* LLM Analysis Toggle */}
|
|
||||||
<div className="bg-gradient-to-r from-violet-50 to-purple-50 border border-violet-200 rounded-xl p-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="w-10 h-10 bg-violet-100 rounded-lg flex items-center justify-center">
|
|
||||||
<svg className="w-5 h-5 text-violet-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h4 className="font-medium text-slate-800">Automatische LLM-Analyse</h4>
|
|
||||||
<p className="text-xs text-slate-500">KI-gestuetzte Fix-Vorschlaege fuer Backlog-Eintraege</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<label className="relative inline-flex items-center cursor-pointer">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={llmAutoAnalysis}
|
|
||||||
onChange={(e) => setLlmAutoAnalysis(e.target.checked)}
|
|
||||||
className="sr-only peer"
|
|
||||||
/>
|
|
||||||
<div className="w-11 h-6 bg-slate-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-violet-300 rounded-full peer peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-slate-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-violet-600"></div>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{llmAutoAnalysis && (
|
|
||||||
<div className="mt-4 pt-4 border-t border-violet-200">
|
|
||||||
<p className="text-xs text-slate-600 mb-3">LLM-Routing Strategie:</p>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{([
|
|
||||||
{ value: 'local_only' as const, label: 'Nur lokales 32B LLM', badge: 'DSGVO', badgeColor: 'bg-emerald-100 text-emerald-700' },
|
|
||||||
{ value: 'claude_preferred' as const, label: 'Claude bevorzugt', badge: 'Qualitaet', badgeColor: 'bg-blue-100 text-blue-700' },
|
|
||||||
{ value: 'smart_routing' as const, label: 'Smart Routing', badge: 'Empfohlen', badgeColor: 'bg-amber-100 text-amber-700' },
|
|
||||||
]).map((option) => (
|
|
||||||
<label key={option.value} className={`flex items-center gap-2 px-3 py-2 rounded-lg border cursor-pointer transition-colors ${
|
|
||||||
llmRouting === option.value
|
|
||||||
? 'bg-violet-100 border-violet-300 text-violet-800'
|
|
||||||
: 'bg-white border-slate-200 text-slate-600 hover:bg-slate-50'
|
|
||||||
}`}>
|
|
||||||
<input
|
|
||||||
type="radio"
|
|
||||||
name="llm-routing"
|
|
||||||
value={option.value}
|
|
||||||
checked={llmRouting === option.value}
|
|
||||||
onChange={() => setLlmRouting(option.value)}
|
|
||||||
className="sr-only"
|
|
||||||
/>
|
|
||||||
<span className="text-sm font-medium">{option.label}</span>
|
|
||||||
<span className={`text-xs px-1.5 py-0.5 ${option.badgeColor} rounded`}>{option.badge}</span>
|
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-slate-500 mt-2">
|
|
||||||
{llmRouting === 'local_only' && 'Alle Analysen werden mit Qwen2.5-32B lokal durchgefuehrt. Keine Daten verlassen den Server.'}
|
|
||||||
{llmRouting === 'claude_preferred' && 'Verwendet Claude fuer beste Fix-Qualitaet. Nur Code-Snippets werden uebertragen.'}
|
|
||||||
{llmRouting === 'smart_routing' && 'Privacy Classifier entscheidet automatisch: Sensitive Daten → lokal, Code → Claude.'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Filter */}
|
|
||||||
<div className="flex flex-wrap gap-4 items-center">
|
|
||||||
<div>
|
|
||||||
<label className="text-sm text-slate-600 mr-2">Status:</label>
|
|
||||||
<select
|
|
||||||
value={filterStatus}
|
|
||||||
onChange={(e) => setFilterStatus(e.target.value)}
|
|
||||||
className="px-3 py-1.5 rounded-lg border border-slate-200 text-sm"
|
|
||||||
>
|
|
||||||
<option value="all">Alle</option>
|
|
||||||
<option value="open">Offen ({openCount})</option>
|
|
||||||
<option value="in_progress">In Arbeit ({inProgressCount})</option>
|
|
||||||
<option value="fixed">Behoben ({fixedCount})</option>
|
|
||||||
<option value="flaky">Flaky ({flakyCount})</option>
|
|
||||||
<option value="wont_fix">Ignoriert</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="text-sm text-slate-600 mr-2">Service:</label>
|
|
||||||
<select
|
|
||||||
value={filterService}
|
|
||||||
onChange={(e) => setFilterService(e.target.value)}
|
|
||||||
className="px-3 py-1.5 rounded-lg border border-slate-200 text-sm"
|
|
||||||
>
|
|
||||||
<option value="all">Alle Services</option>
|
|
||||||
{services.map(s => (
|
|
||||||
<option key={s} value={s}>{s}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
{usePostgres && (
|
|
||||||
<div>
|
|
||||||
<label className="text-sm text-slate-600 mr-2">Prioritaet:</label>
|
|
||||||
<select
|
|
||||||
value={filterPriority}
|
|
||||||
onChange={(e) => setFilterPriority(e.target.value)}
|
|
||||||
className="px-3 py-1.5 rounded-lg border border-slate-200 text-sm"
|
|
||||||
>
|
|
||||||
<option value="all">Alle</option>
|
|
||||||
<option value="critical">Kritisch</option>
|
|
||||||
<option value="high">Hoch</option>
|
|
||||||
<option value="medium">Mittel</option>
|
|
||||||
<option value="low">Niedrig</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="ml-auto text-sm text-slate-500">
|
|
||||||
{filteredItems.length} von {items.length} Tests angezeigt
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Test-Liste */}
|
|
||||||
{filteredItems.length === 0 ? (
|
|
||||||
<div className="text-center py-12 bg-emerald-50 rounded-xl border border-emerald-200">
|
|
||||||
<svg className="w-12 h-12 mx-auto text-emerald-400 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
||||||
</svg>
|
|
||||||
<p className="text-emerald-700 font-medium">
|
|
||||||
{filterStatus === 'open' ? 'Keine offenen Fehler! 🎉' : 'Keine Tests mit diesem Filter gefunden.'}
|
|
||||||
</p>
|
|
||||||
{filterStatus === 'open' && (
|
|
||||||
<p className="text-sm text-emerald-600 mt-2">
|
|
||||||
Alle Tests bestanden. Bereit fuer Go-Live!
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
|
||||||
{filteredItems.map((item) => {
|
|
||||||
const test = usePostgres && 'test_name' in item
|
|
||||||
? convertToFailedTest(item as BacklogItem)
|
|
||||||
: item as FailedTest
|
|
||||||
const itemPriority = usePostgres && 'priority' in item
|
|
||||||
? (item as BacklogItem).priority
|
|
||||||
: 'medium'
|
|
||||||
const failureCount = usePostgres && 'failure_count' in item
|
|
||||||
? (item as BacklogItem).failure_count
|
|
||||||
: 1
|
|
||||||
|
|
||||||
return (
|
|
||||||
<FailedTestCard
|
|
||||||
key={test.id}
|
|
||||||
test={test}
|
|
||||||
onStatusChange={onStatusChange}
|
|
||||||
onPriorityChange={onPriorityChange}
|
|
||||||
priority={itemPriority}
|
|
||||||
failureCount={failureCount}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Info */}
|
|
||||||
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4">
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<svg className="w-5 h-5 text-blue-600 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
||||||
</svg>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-blue-800 font-medium">Workflow fuer fehlgeschlagene Tests:</p>
|
|
||||||
<ol className="text-xs text-blue-700 mt-2 space-y-1 list-decimal list-inside">
|
|
||||||
<li>Markiere den Test als "In Arbeit" wenn du daran arbeitest</li>
|
|
||||||
<li>Analysiere die Fehlermeldung und den Loesungsvorschlag</li>
|
|
||||||
<li>Behebe den Fehler im Code</li>
|
|
||||||
<li>Fuehre den Test erneut aus (Button im Service-Tab)</li>
|
|
||||||
<li>Markiere als "Behoben" wenn der Test besteht</li>
|
|
||||||
{usePostgres && <li>Setze "Flaky" fuer sporadisch fehlschlagende Tests</li>}
|
|
||||||
</ol>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import type { CoverageData } from '../types'
|
|
||||||
|
|
||||||
export function CoverageChart({ data }: { data: CoverageData[] }) {
|
|
||||||
if (data.length === 0) {
|
|
||||||
return (
|
|
||||||
<div className="text-center py-8 text-slate-400">
|
|
||||||
Keine Coverage-Daten verfuegbar
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const sortedData = [...data].sort((a, b) => b.coverage_percent - a.coverage_percent)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-3">
|
|
||||||
{sortedData.map((item) => (
|
|
||||||
<div key={item.service}>
|
|
||||||
<div className="flex items-center justify-between text-sm mb-1">
|
|
||||||
<span className="text-slate-600 truncate max-w-[200px]">{item.display_name}</span>
|
|
||||||
<span
|
|
||||||
className={`font-medium ${
|
|
||||||
item.coverage_percent >= 80 ? 'text-emerald-600' : item.coverage_percent >= 60 ? 'text-amber-600' : 'text-red-600'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{item.coverage_percent.toFixed(1)}%
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="h-2 bg-slate-100 rounded-full overflow-hidden">
|
|
||||||
<div
|
|
||||||
className={`h-full rounded-full transition-all ${
|
|
||||||
item.coverage_percent >= 80 ? 'bg-emerald-500' : item.coverage_percent >= 60 ? 'bg-amber-500' : 'bg-red-500'
|
|
||||||
}`}
|
|
||||||
style={{ width: `${item.coverage_percent}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
export function FrameworkDistribution({ data }: { data: Record<string, number> }) {
|
|
||||||
const total = Object.values(data).reduce((a, b) => a + b, 0)
|
|
||||||
if (total === 0) return null
|
|
||||||
|
|
||||||
const frameworkLabels: Record<string, string> = {
|
|
||||||
go_test: 'Go Tests',
|
|
||||||
pytest: 'Python (pytest)',
|
|
||||||
jest: 'Jest (TS)',
|
|
||||||
vitest: 'Vitest (SDK)',
|
|
||||||
playwright: 'Playwright (E2E)',
|
|
||||||
bqas_golden: 'BQAS Golden',
|
|
||||||
bqas_rag: 'BQAS RAG',
|
|
||||||
bqas_synthetic: 'BQAS Synthetic',
|
|
||||||
}
|
|
||||||
|
|
||||||
const frameworkColors: Record<string, string> = {
|
|
||||||
go_test: 'bg-cyan-500',
|
|
||||||
pytest: 'bg-yellow-500',
|
|
||||||
jest: 'bg-blue-500',
|
|
||||||
vitest: 'bg-orange-500',
|
|
||||||
playwright: 'bg-purple-500',
|
|
||||||
bqas_golden: 'bg-emerald-500',
|
|
||||||
bqas_rag: 'bg-teal-500',
|
|
||||||
bqas_synthetic: 'bg-amber-500',
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-3">
|
|
||||||
{Object.entries(data)
|
|
||||||
.sort((a, b) => b[1] - a[1])
|
|
||||||
.map(([framework, count]) => (
|
|
||||||
<div key={framework} className="flex items-center gap-3">
|
|
||||||
<div className={`w-3 h-3 rounded-full ${frameworkColors[framework] || 'bg-slate-400'}`} />
|
|
||||||
<span className="text-sm text-slate-600 flex-1">{frameworkLabels[framework] || framework}</span>
|
|
||||||
<span className="text-sm font-medium text-slate-900">{count}</span>
|
|
||||||
<span className="text-xs text-slate-400">({((count / total) * 100).toFixed(0)}%)</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,232 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import Link from 'next/link'
|
|
||||||
|
|
||||||
export function GuideTab() {
|
|
||||||
return (
|
|
||||||
<div className="space-y-8">
|
|
||||||
<div className="bg-gradient-to-r from-orange-50 to-amber-50 rounded-xl border border-orange-200 p-6">
|
|
||||||
<h2 className="text-xl font-bold text-slate-900 mb-4 flex items-center gap-2">
|
|
||||||
<svg className="w-6 h-6 text-orange-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
|
|
||||||
</svg>
|
|
||||||
Was ist das Test Dashboard?
|
|
||||||
</h2>
|
|
||||||
<p className="text-slate-700 leading-relaxed">
|
|
||||||
Das <strong>Test Dashboard</strong> ist die zentrale Uebersicht fuer alle 260+ Tests im Breakpilot-System.
|
|
||||||
Es aggregiert Tests aus verschiedenen Services (Go, Python, TypeScript) ohne diese physisch zu migrieren.
|
|
||||||
Tests bleiben an ihren konventionellen Orten, werden aber hier zentral ueberwacht und ausgefuehrt.
|
|
||||||
Seit 2026-02 inklusive AI Compliance SDK Unit Tests (Vitest) und E2E Tests (Playwright).
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
|
||||||
<h3 className="text-lg font-semibold text-slate-900 mb-4">Test-Kategorien</h3>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
|
||||||
<div className="p-4 bg-cyan-50 rounded-lg border border-cyan-200">
|
|
||||||
<div className="flex items-center gap-2 mb-2">
|
|
||||||
<span className="text-xl">🐹</span>
|
|
||||||
<h4 className="font-medium text-cyan-800">Go Unit Tests (~57)</h4>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-cyan-700">
|
|
||||||
consent-service, billing-service, school-service, edu-search-service, ai-compliance-sdk
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="p-4 bg-yellow-50 rounded-lg border border-yellow-200">
|
|
||||||
<div className="flex items-center gap-2 mb-2">
|
|
||||||
<span className="text-xl">🐍</span>
|
|
||||||
<h4 className="font-medium text-yellow-800">Python Tests (~50)</h4>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-yellow-700">
|
|
||||||
backend, voice-service, klausur-service, geo-service
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="p-4 bg-emerald-50 rounded-lg border border-emerald-200">
|
|
||||||
<div className="flex items-center gap-2 mb-2">
|
|
||||||
<span className="text-xl">🎯</span>
|
|
||||||
<h4 className="font-medium text-emerald-800">BQAS Golden (97)</h4>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-emerald-700">
|
|
||||||
Validierte Referenz-Tests mit LLM-Judge fuer Intent-Erkennung
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="p-4 bg-teal-50 rounded-lg border border-teal-200">
|
|
||||||
<div className="flex items-center gap-2 mb-2">
|
|
||||||
<span className="text-xl">📚</span>
|
|
||||||
<h4 className="font-medium text-teal-800">BQAS RAG (~20)</h4>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-teal-700">
|
|
||||||
RAG-Judge Tests fuer Retrieval, Citations, Hallucination-Control
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="p-4 bg-blue-50 rounded-lg border border-blue-200">
|
|
||||||
<div className="flex items-center gap-2 mb-2">
|
|
||||||
<span className="text-xl">📘</span>
|
|
||||||
<h4 className="font-medium text-blue-800">TypeScript Jest (~8)</h4>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-blue-700">
|
|
||||||
Website Unit Tests fuer React-Komponenten
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="p-4 bg-orange-50 rounded-lg border border-orange-200">
|
|
||||||
<div className="flex items-center gap-2 mb-2">
|
|
||||||
<span className="text-xl">⚡</span>
|
|
||||||
<h4 className="font-medium text-orange-800">SDK Vitest (~43)</h4>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-orange-700">
|
|
||||||
AI Compliance SDK Unit Tests: Types, Export, Components, Reducer
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="p-4 bg-purple-50 rounded-lg border border-purple-200">
|
|
||||||
<div className="flex items-center gap-2 mb-2">
|
|
||||||
<span className="text-xl">🎭</span>
|
|
||||||
<h4 className="font-medium text-purple-800">SDK Playwright (~25)</h4>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-purple-700">
|
|
||||||
SDK E2E Tests: Navigation, Workflow, Command Bar, Export
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="p-4 bg-slate-50 rounded-lg border border-slate-200">
|
|
||||||
<div className="flex items-center gap-2 mb-2">
|
|
||||||
<span className="text-xl">🌐</span>
|
|
||||||
<h4 className="font-medium text-slate-800">Website E2E (~5)</h4>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-slate-700">
|
|
||||||
End-to-End Tests fuer kritische User Flows
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="p-4 bg-indigo-50 rounded-lg border border-indigo-200">
|
|
||||||
<div className="flex items-center gap-2 mb-2">
|
|
||||||
<span className="text-xl">🔗</span>
|
|
||||||
<h4 className="font-medium text-indigo-800">Integration Tests (~15)</h4>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-indigo-700">
|
|
||||||
Docker Compose basierte E2E-Tests mit Backend, Consent-Service, DB
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
|
||||||
<h3 className="text-lg font-semibold text-slate-900 mb-4">Architektur</h3>
|
|
||||||
<pre className="bg-slate-50 p-4 rounded-lg text-xs overflow-x-auto">
|
|
||||||
{`┌────────────────────────────────────────────────────────────────────┐
|
|
||||||
│ Admin-v2 Test Dashboard │
|
|
||||||
│ /infrastructure/tests │
|
|
||||||
├────────────────────────────────────────────────────────────────────┤
|
|
||||||
│ ┌────────────┐ ┌────────────┐ ┌────────────┐ ┌─────────────┐ │
|
|
||||||
│ │ Unit Tests │ │ SDK Tests │ │ BQAS │ │ E2E Tests │ │
|
|
||||||
│ │ (Go, Py) │ │ (Vitest) │ │ (LLM/RAG) │ │ (Playwright)│ │
|
|
||||||
│ └────────────┘ └────────────┘ └────────────┘ └─────────────┘ │
|
|
||||||
│ │ │ │ │ │
|
|
||||||
│ ▼ ▼ ▼ ▼ │
|
|
||||||
│ ┌──────────────────────────────────────────────────────────────┐ │
|
|
||||||
│ │ Test Registry API │ │
|
|
||||||
│ │ /backend/api/tests/registry.py │ │
|
|
||||||
│ └──────────────────────────────────────────────────────────────┘ │
|
|
||||||
└────────────────────────────────────────────────────────────────────┘
|
|
||||||
|
|
||||||
Tests bleiben wo sie sind:
|
|
||||||
- /consent-service/internal/**/*_test.go
|
|
||||||
- /backend/tests/test_*.py
|
|
||||||
- /voice-service/tests/bqas/
|
|
||||||
- /admin-v2/components/sdk/__tests__/*.test.ts (Vitest)
|
|
||||||
- /admin-v2/e2e/specs/*.spec.ts (Playwright)`}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* CI/CD Workflow Anleitung */}
|
|
||||||
<div className="bg-blue-50 rounded-xl border border-blue-200 p-6">
|
|
||||||
<h3 className="text-lg font-semibold text-blue-900 mb-4 flex items-center gap-2">
|
|
||||||
<svg className="w-5 h-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
|
||||||
</svg>
|
|
||||||
CI/CD Integration
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
||||||
<div>
|
|
||||||
<h4 className="font-medium text-blue-800 mb-2">🤖 Automatisch (bei jedem Push/PR)</h4>
|
|
||||||
<ul className="space-y-2 text-sm text-blue-700">
|
|
||||||
<li className="flex items-start gap-2">
|
|
||||||
<span className="text-green-500 mt-1">✓</span>
|
|
||||||
<span><strong>Unit Tests</strong> - Go & Python Tests laufen automatisch</span>
|
|
||||||
</li>
|
|
||||||
<li className="flex items-start gap-2">
|
|
||||||
<span className="text-green-500 mt-1">✓</span>
|
|
||||||
<span><strong>Test-Ergebnisse</strong> - Werden ans Dashboard gesendet</span>
|
|
||||||
</li>
|
|
||||||
<li className="flex items-start gap-2">
|
|
||||||
<span className="text-green-500 mt-1">✓</span>
|
|
||||||
<span><strong>Backlog</strong> - Fehlgeschlagene Tests erscheinen hier</span>
|
|
||||||
</li>
|
|
||||||
<li className="flex items-start gap-2">
|
|
||||||
<span className="text-green-500 mt-1">✓</span>
|
|
||||||
<span><strong>Linting</strong> - Code-Qualitaet bei PRs pruefen</span>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<h4 className="font-medium text-blue-800 mb-2">👆 Manuell (Button oder Tag)</h4>
|
|
||||||
<ul className="space-y-2 text-sm text-blue-700">
|
|
||||||
<li className="flex items-start gap-2">
|
|
||||||
<span className="text-orange-500 mt-1">▶</span>
|
|
||||||
<span><strong>Docker Builds</strong> - Container erstellen</span>
|
|
||||||
</li>
|
|
||||||
<li className="flex items-start gap-2">
|
|
||||||
<span className="text-orange-500 mt-1">▶</span>
|
|
||||||
<span><strong>SBOM/Scans</strong> - Sicherheitsanalyse ausfuehren</span>
|
|
||||||
</li>
|
|
||||||
<li className="flex items-start gap-2">
|
|
||||||
<span className="text-orange-500 mt-1">▶</span>
|
|
||||||
<span><strong>Deployment</strong> - In Produktion deployen</span>
|
|
||||||
</li>
|
|
||||||
<li className="flex items-start gap-2">
|
|
||||||
<span className="text-orange-500 mt-1">▶</span>
|
|
||||||
<span><strong>Pipeline starten</strong> - Im CI/CD Dashboard</span>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-4 pt-4 border-t border-blue-200">
|
|
||||||
<p className="text-sm text-blue-600">
|
|
||||||
<strong>Daten-Fluss:</strong> Gitea Actions → POST /api/tests/ci-result → PostgreSQL → Test Dashboard
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
<Link
|
|
||||||
href="/ai/test-quality"
|
|
||||||
className="p-4 bg-slate-50 rounded-lg border border-slate-200 hover:border-orange-300 hover:bg-orange-50 transition-colors"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<svg className="w-8 h-8 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
||||||
</svg>
|
|
||||||
<div>
|
|
||||||
<p className="font-medium text-slate-900">BQAS Dashboard</p>
|
|
||||||
<p className="text-xs text-slate-500">Detaillierte BQAS-Metriken und Trend-Analyse</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
href="/infrastructure/ci-cd"
|
|
||||||
className="p-4 bg-slate-50 rounded-lg border border-slate-200 hover:border-orange-300 hover:bg-orange-50 transition-colors"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<svg className="w-8 h-8 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
||||||
</svg>
|
|
||||||
<div>
|
|
||||||
<p className="font-medium text-slate-900">CI/CD Pipelines</p>
|
|
||||||
<p className="text-xs text-slate-500">Gitea Actions und automatische Test-Planung</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
export function MetricCard({
|
|
||||||
title,
|
|
||||||
value,
|
|
||||||
subtitle,
|
|
||||||
trend,
|
|
||||||
color = 'blue',
|
|
||||||
}: {
|
|
||||||
title: string
|
|
||||||
value: string | number
|
|
||||||
subtitle?: string
|
|
||||||
trend?: 'up' | 'down' | 'stable'
|
|
||||||
color?: 'blue' | 'green' | 'red' | 'yellow' | 'orange' | 'purple'
|
|
||||||
}) {
|
|
||||||
const colorClasses = {
|
|
||||||
blue: 'bg-blue-50 border-blue-200',
|
|
||||||
green: 'bg-emerald-50 border-emerald-200',
|
|
||||||
red: 'bg-red-50 border-red-200',
|
|
||||||
yellow: 'bg-amber-50 border-amber-200',
|
|
||||||
orange: 'bg-orange-50 border-orange-200',
|
|
||||||
purple: 'bg-purple-50 border-purple-200',
|
|
||||||
}
|
|
||||||
|
|
||||||
const trendIcons = {
|
|
||||||
up: (
|
|
||||||
<svg className="w-4 h-4 text-emerald-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 10l7-7m0 0l7 7m-7-7v18" />
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
down: (
|
|
||||||
<svg className="w-4 h-4 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 14l-7 7m0 0l-7-7m7 7V3" />
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
stable: (
|
|
||||||
<svg className="w-4 h-4 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 12h14" />
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={`rounded-xl border p-5 ${colorClasses[color]}`}>
|
|
||||||
<div className="flex items-start justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium text-slate-600">{title}</p>
|
|
||||||
<p className="mt-1 text-2xl font-bold text-slate-900">{value}</p>
|
|
||||||
{subtitle && <p className="mt-1 text-xs text-slate-500">{subtitle}</p>}
|
|
||||||
</div>
|
|
||||||
{trend && <div className="mt-1">{trendIcons[trend]}</div>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,147 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import type { ServiceTestInfo, ServiceProgress } from '../types'
|
|
||||||
|
|
||||||
export function ServiceTestCard({
|
|
||||||
service,
|
|
||||||
onRun,
|
|
||||||
isRunning,
|
|
||||||
progress,
|
|
||||||
}: {
|
|
||||||
service: ServiceTestInfo
|
|
||||||
onRun: (service: string) => void
|
|
||||||
isRunning: boolean
|
|
||||||
progress?: ServiceProgress
|
|
||||||
}) {
|
|
||||||
const passRate = service.total_tests > 0 ? (service.passed_tests / service.total_tests) * 100 : 0
|
|
||||||
|
|
||||||
const getLanguageIcon = (lang: string) => {
|
|
||||||
switch (lang) {
|
|
||||||
case 'go':
|
|
||||||
return '🐹'
|
|
||||||
case 'python':
|
|
||||||
return '🐍'
|
|
||||||
case 'typescript':
|
|
||||||
return '📘'
|
|
||||||
case 'mixed':
|
|
||||||
return '🔀'
|
|
||||||
default:
|
|
||||||
return '📦'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const getStatusColor = (status: string) => {
|
|
||||||
switch (status) {
|
|
||||||
case 'passed':
|
|
||||||
return 'bg-emerald-100 text-emerald-700'
|
|
||||||
case 'failed':
|
|
||||||
return 'bg-red-100 text-red-700'
|
|
||||||
case 'running':
|
|
||||||
return 'bg-blue-100 text-blue-700'
|
|
||||||
default:
|
|
||||||
return 'bg-slate-100 text-slate-700'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="bg-white rounded-xl border border-slate-200 p-5 hover:border-orange-300 transition-colors">
|
|
||||||
<div className="flex items-start justify-between mb-4">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<span className="text-2xl">{getLanguageIcon(service.language)}</span>
|
|
||||||
<div>
|
|
||||||
<h3 className="font-semibold text-slate-900">{service.display_name}</h3>
|
|
||||||
<p className="text-xs text-slate-500">
|
|
||||||
{service.port ? `Port ${service.port}` : 'Library'} • {service.language}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<span className={`px-2 py-1 rounded text-xs font-medium ${getStatusColor(service.status)}`}>
|
|
||||||
{service.status === 'passed' ? 'Bestanden' : service.status === 'failed' ? 'Fehler' : 'Ausstehend'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center justify-between text-sm mb-1">
|
|
||||||
<span className="text-slate-600">Pass Rate</span>
|
|
||||||
<span className="font-medium text-slate-900">{passRate.toFixed(0)}%</span>
|
|
||||||
</div>
|
|
||||||
<div className="h-2 bg-slate-100 rounded-full overflow-hidden">
|
|
||||||
<div
|
|
||||||
className={`h-full rounded-full transition-all ${
|
|
||||||
passRate >= 80 ? 'bg-emerald-500' : passRate >= 60 ? 'bg-amber-500' : 'bg-red-500'
|
|
||||||
}`}
|
|
||||||
style={{ width: `${passRate}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-3 gap-2 text-center">
|
|
||||||
<div className="p-2 bg-slate-50 rounded-lg">
|
|
||||||
<p className="text-lg font-bold text-slate-900">{service.total_tests}</p>
|
|
||||||
<p className="text-xs text-slate-500">Tests</p>
|
|
||||||
</div>
|
|
||||||
<div className="p-2 bg-emerald-50 rounded-lg">
|
|
||||||
<p className="text-lg font-bold text-emerald-600">{service.passed_tests}</p>
|
|
||||||
<p className="text-xs text-slate-500">Bestanden</p>
|
|
||||||
</div>
|
|
||||||
<div className="p-2 bg-red-50 rounded-lg">
|
|
||||||
<p className="text-lg font-bold text-red-600">{service.failed_tests}</p>
|
|
||||||
<p className="text-xs text-slate-500">Fehler</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{service.coverage_percent && (
|
|
||||||
<div className="flex items-center justify-between text-sm pt-2 border-t border-slate-100">
|
|
||||||
<span className="text-slate-600">Coverage</span>
|
|
||||||
<span className={`font-medium ${service.coverage_percent >= 70 ? 'text-emerald-600' : 'text-amber-600'}`}>
|
|
||||||
{service.coverage_percent.toFixed(1)}%
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Progress-Anzeige wenn Tests laufen */}
|
|
||||||
{isRunning && progress && progress.status === 'running' && (
|
|
||||||
<div className="mb-3 p-3 bg-orange-50 rounded-lg border border-orange-200">
|
|
||||||
<div className="flex items-center justify-between text-xs text-orange-700 mb-2">
|
|
||||||
<span className="font-mono truncate max-w-[180px]">{progress.current_file || 'Starte...'}</span>
|
|
||||||
<span>{progress.files_done}/{progress.files_total} Dateien</span>
|
|
||||||
</div>
|
|
||||||
<div className="h-1.5 bg-orange-100 rounded-full overflow-hidden">
|
|
||||||
<div
|
|
||||||
className="h-full bg-orange-500 rounded-full transition-all"
|
|
||||||
style={{ width: `${progress.files_total > 0 ? (progress.files_done / progress.files_total) * 100 : 0}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between mt-2 text-xs">
|
|
||||||
<span className="text-emerald-600 font-medium">{progress.passed} bestanden</span>
|
|
||||||
<span className="text-red-600 font-medium">{progress.failed} fehler</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={() => onRun(service.service)}
|
|
||||||
disabled={isRunning}
|
|
||||||
className={`w-full py-2 rounded-lg text-sm font-medium transition-all ${
|
|
||||||
isRunning
|
|
||||||
? 'bg-orange-100 text-orange-600 cursor-wait'
|
|
||||||
: 'bg-orange-600 text-white hover:bg-orange-700 active:scale-98'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{isRunning ? (
|
|
||||||
<span className="flex items-center justify-center gap-2">
|
|
||||||
<svg className="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
|
|
||||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
|
||||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
|
||||||
</svg>
|
|
||||||
{progress && progress.status === 'running' ? `${progress.passed + progress.failed} Tests...` : 'Laeuft...'}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
'Tests starten'
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import type { TestRun } from '../types'
|
|
||||||
|
|
||||||
export function TestRunsTable({ runs }: { runs: TestRun[] }) {
|
|
||||||
if (runs.length === 0) {
|
|
||||||
return (
|
|
||||||
<div className="text-center py-8 text-slate-400">
|
|
||||||
Keine Test-Laeufe vorhanden
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="w-full text-sm">
|
|
||||||
<thead>
|
|
||||||
<tr className="border-b border-slate-200">
|
|
||||||
<th className="text-left py-3 px-4 font-medium text-slate-600">ID</th>
|
|
||||||
<th className="text-left py-3 px-4 font-medium text-slate-600">Service</th>
|
|
||||||
<th className="text-left py-3 px-4 font-medium text-slate-600">Zeitpunkt</th>
|
|
||||||
<th className="text-right py-3 px-4 font-medium text-slate-600">Tests</th>
|
|
||||||
<th className="text-right py-3 px-4 font-medium text-slate-600">Bestanden</th>
|
|
||||||
<th className="text-right py-3 px-4 font-medium text-slate-600">Dauer</th>
|
|
||||||
<th className="text-center py-3 px-4 font-medium text-slate-600">Status</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{runs.map((run) => (
|
|
||||||
<tr key={run.id} className="border-b border-slate-100 hover:bg-slate-50">
|
|
||||||
<td className="py-3 px-4 font-mono text-xs text-slate-500">{run.id.slice(-8)}</td>
|
|
||||||
<td className="py-3 px-4 text-slate-900">{run.service}</td>
|
|
||||||
<td className="py-3 px-4 text-slate-600">
|
|
||||||
{new Date(run.started_at).toLocaleString('de-DE')}
|
|
||||||
</td>
|
|
||||||
<td className="py-3 px-4 text-right text-slate-600">{run.total_tests}</td>
|
|
||||||
<td className="py-3 px-4 text-right">
|
|
||||||
<span className="text-emerald-600">{run.passed_tests}</span>
|
|
||||||
<span className="text-slate-400"> / </span>
|
|
||||||
<span className="text-red-600">{run.failed_tests}</span>
|
|
||||||
</td>
|
|
||||||
<td className="py-3 px-4 text-right text-slate-500">
|
|
||||||
{run.duration_seconds.toFixed(1)}s
|
|
||||||
</td>
|
|
||||||
<td className="py-3 px-4 text-center">
|
|
||||||
<span
|
|
||||||
className={`px-2 py-1 rounded text-xs font-medium ${
|
|
||||||
run.status === 'completed'
|
|
||||||
? 'bg-emerald-100 text-emerald-700'
|
|
||||||
: run.status === 'failed'
|
|
||||||
? 'bg-red-100 text-red-700'
|
|
||||||
: run.status === 'running'
|
|
||||||
? 'bg-blue-100 text-blue-700'
|
|
||||||
: 'bg-slate-100 text-slate-700'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{run.status}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import type { Toast } from '../types'
|
|
||||||
|
|
||||||
export function ToastContainer({ toasts, onDismiss }: { toasts: Toast[]; onDismiss: (id: number) => void }) {
|
|
||||||
return (
|
|
||||||
<div className="fixed bottom-4 right-4 z-50 space-y-2">
|
|
||||||
{toasts.map((toast) => (
|
|
||||||
<div
|
|
||||||
key={toast.id}
|
|
||||||
className={`flex items-center gap-3 px-4 py-3 rounded-lg shadow-lg border animate-slide-in ${
|
|
||||||
toast.type === 'success'
|
|
||||||
? 'bg-emerald-50 border-emerald-200 text-emerald-800'
|
|
||||||
: toast.type === 'error'
|
|
||||||
? 'bg-red-50 border-red-200 text-red-800'
|
|
||||||
: toast.type === 'loading'
|
|
||||||
? 'bg-blue-50 border-blue-200 text-blue-800'
|
|
||||||
: 'bg-slate-50 border-slate-200 text-slate-800'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{toast.type === 'loading' ? (
|
|
||||||
<svg className="animate-spin h-5 w-5" fill="none" viewBox="0 0 24 24">
|
|
||||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
|
||||||
<path
|
|
||||||
className="opacity-75"
|
|
||||||
fill="currentColor"
|
|
||||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
) : toast.type === 'success' ? (
|
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
|
||||||
</svg>
|
|
||||||
) : toast.type === 'error' ? (
|
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
|
||||||
</svg>
|
|
||||||
) : (
|
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
||||||
</svg>
|
|
||||||
)}
|
|
||||||
<span className="text-sm font-medium">{toast.message}</span>
|
|
||||||
{toast.type !== 'loading' && (
|
|
||||||
<button onClick={() => onDismiss(toast.id)} className="ml-2 opacity-60 hover:opacity-100">
|
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,313 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
|
||||||
import type {
|
|
||||||
ServiceTestInfo,
|
|
||||||
TestRegistryStats,
|
|
||||||
TestRun,
|
|
||||||
CoverageData,
|
|
||||||
TabType,
|
|
||||||
Toast,
|
|
||||||
FailedTest,
|
|
||||||
BacklogItem,
|
|
||||||
ServiceProgress,
|
|
||||||
} from '../types'
|
|
||||||
|
|
||||||
const API_BASE = '/api/tests'
|
|
||||||
|
|
||||||
// Demo data for when API is not available
|
|
||||||
const DEMO_SERVICES: ServiceTestInfo[] = [
|
|
||||||
{ service: 'consent-service', display_name: 'Consent Service', port: 8081, language: 'go', total_tests: 22, passed_tests: 20, failed_tests: 2, skipped_tests: 0, pass_rate: 90.9, coverage_percent: 82.3, last_run: new Date().toISOString(), status: 'failed' },
|
|
||||||
{ service: 'backend', display_name: 'Python Backend', port: 8000, language: 'python', total_tests: 40, passed_tests: 38, failed_tests: 2, skipped_tests: 0, pass_rate: 95.0, coverage_percent: 75.1, last_run: new Date().toISOString(), status: 'failed' },
|
|
||||||
{ service: 'klausur-service', display_name: 'Klausur Service', port: 8086, language: 'python', total_tests: 8, passed_tests: 8, failed_tests: 0, skipped_tests: 0, pass_rate: 100, coverage_percent: 71.2, last_run: new Date().toISOString(), status: 'passed' },
|
|
||||||
{ service: 'billing-service', display_name: 'Billing Service', port: 8082, language: 'go', total_tests: 5, passed_tests: 5, failed_tests: 0, skipped_tests: 0, pass_rate: 100, coverage_percent: 78.5, last_run: new Date().toISOString(), status: 'passed' },
|
|
||||||
{ service: 'school-service', display_name: 'School Service', port: 8084, language: 'go', total_tests: 6, passed_tests: 6, failed_tests: 0, skipped_tests: 0, pass_rate: 100, coverage_percent: 81.4, last_run: new Date().toISOString(), status: 'passed' },
|
|
||||||
{ service: 'sdk-unit', display_name: 'SDK Unit Tests (Vitest)', port: undefined, language: 'typescript', total_tests: 43, passed_tests: 43, failed_tests: 0, skipped_tests: 0, pass_rate: 100, coverage_percent: 85.2, last_run: new Date().toISOString(), status: 'passed' },
|
|
||||||
{ service: 'sdk-e2e', display_name: 'SDK E2E Tests (Playwright)', port: undefined, language: 'typescript', total_tests: 25, passed_tests: 25, failed_tests: 0, skipped_tests: 0, pass_rate: 100, coverage_percent: undefined, last_run: new Date().toISOString(), status: 'passed' },
|
|
||||||
{ service: 'integration-tests', display_name: 'Integration Tests', port: undefined, language: 'python', total_tests: 15, passed_tests: 15, failed_tests: 0, skipped_tests: 0, pass_rate: 100, coverage_percent: undefined, last_run: new Date().toISOString(), status: 'passed' },
|
|
||||||
]
|
|
||||||
|
|
||||||
const DEMO_STATS: TestRegistryStats = {
|
|
||||||
total_tests: 278,
|
|
||||||
total_passed: 263,
|
|
||||||
total_failed: 15,
|
|
||||||
total_skipped: 0,
|
|
||||||
overall_pass_rate: 94.6,
|
|
||||||
average_coverage: 78.5,
|
|
||||||
services_count: 11,
|
|
||||||
by_category: { unit: 118, bqas: 117, e2e: 30, integration: 15 },
|
|
||||||
by_framework: { go_test: 57, pytest: 68, bqas_golden: 97, bqas_rag: 20, jest: 8, vitest: 43, playwright: 30 },
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useTestDashboard() {
|
|
||||||
const [activeTab, setActiveTab] = useState<TabType>('overview')
|
|
||||||
const [isLoading, setIsLoading] = useState(true)
|
|
||||||
const [error, setError] = useState<string | null>(null)
|
|
||||||
|
|
||||||
// Toast state
|
|
||||||
const [toasts, setToasts] = useState<Toast[]>([])
|
|
||||||
const toastIdRef = useRef(0)
|
|
||||||
|
|
||||||
const addToast = useCallback((type: Toast['type'], message: string) => {
|
|
||||||
const id = ++toastIdRef.current
|
|
||||||
setToasts((prev) => [...prev, { id, type, message }])
|
|
||||||
if (type !== 'loading') {
|
|
||||||
setTimeout(() => {
|
|
||||||
setToasts((prev) => prev.filter((t) => t.id !== id))
|
|
||||||
}, 5000)
|
|
||||||
}
|
|
||||||
return id
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const removeToast = useCallback((id: number) => {
|
|
||||||
setToasts((prev) => prev.filter((t) => t.id !== id))
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const updateToast = useCallback((id: number, type: Toast['type'], message: string) => {
|
|
||||||
setToasts((prev) => prev.map((t) => (t.id === id ? { ...t, type, message } : t)))
|
|
||||||
if (type !== 'loading') {
|
|
||||||
setTimeout(() => {
|
|
||||||
setToasts((prev) => prev.filter((t) => t.id !== id))
|
|
||||||
}, 5000)
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// Data states
|
|
||||||
const [services, setServices] = useState<ServiceTestInfo[]>([])
|
|
||||||
const [stats, setStats] = useState<TestRegistryStats | null>(null)
|
|
||||||
const [coverage, setCoverage] = useState<CoverageData[]>([])
|
|
||||||
const [testRuns, setTestRuns] = useState<TestRun[]>([])
|
|
||||||
const [failedTests, setFailedTests] = useState<FailedTest[]>([])
|
|
||||||
const [backlogItems, setBacklogItems] = useState<BacklogItem[]>([])
|
|
||||||
const [usePostgres, setUsePostgres] = useState(false)
|
|
||||||
|
|
||||||
// Running states
|
|
||||||
const [runningServices, setRunningServices] = useState<Set<string>>(new Set())
|
|
||||||
|
|
||||||
// Progress states fuer laufende Tests
|
|
||||||
const [serviceProgress, setServiceProgress] = useState<Record<string, ServiceProgress>>({})
|
|
||||||
|
|
||||||
// Fetch data
|
|
||||||
const fetchData = useCallback(async () => {
|
|
||||||
setIsLoading(true)
|
|
||||||
setError(null)
|
|
||||||
|
|
||||||
try {
|
|
||||||
const registryResponse = await fetch(`${API_BASE}/registry`)
|
|
||||||
if (registryResponse.ok) {
|
|
||||||
const data = await registryResponse.json()
|
|
||||||
setServices(data.services || DEMO_SERVICES)
|
|
||||||
setStats(data.stats || DEMO_STATS)
|
|
||||||
} else {
|
|
||||||
setServices(DEMO_SERVICES)
|
|
||||||
setStats(DEMO_STATS)
|
|
||||||
}
|
|
||||||
|
|
||||||
const coverageResponse = await fetch(`${API_BASE}/coverage`)
|
|
||||||
if (coverageResponse.ok) {
|
|
||||||
const data = await coverageResponse.json()
|
|
||||||
setCoverage(data.services || [])
|
|
||||||
} else {
|
|
||||||
setCoverage(DEMO_SERVICES.filter(s => s.coverage_percent).map(s => ({
|
|
||||||
service: s.service,
|
|
||||||
display_name: s.display_name,
|
|
||||||
coverage_percent: s.coverage_percent!,
|
|
||||||
language: s.language,
|
|
||||||
})))
|
|
||||||
}
|
|
||||||
|
|
||||||
const runsResponse = await fetch(`${API_BASE}/runs`)
|
|
||||||
if (runsResponse.ok) {
|
|
||||||
const data = await runsResponse.json()
|
|
||||||
setTestRuns(data.runs || [])
|
|
||||||
}
|
|
||||||
|
|
||||||
// Lade fehlgeschlagene Tests fuer Backlog
|
|
||||||
const failedResponse = await fetch(`${API_BASE}/failed`)
|
|
||||||
if (failedResponse.ok) {
|
|
||||||
const data = await failedResponse.json()
|
|
||||||
setFailedTests(data.tests || [])
|
|
||||||
}
|
|
||||||
|
|
||||||
// Versuche PostgreSQL-Backlog zu laden (neue API)
|
|
||||||
try {
|
|
||||||
const backlogResponse = await fetch(`${API_BASE}/backlog`)
|
|
||||||
if (backlogResponse.ok) {
|
|
||||||
const data = await backlogResponse.json()
|
|
||||||
if (data.items && data.items.length > 0) {
|
|
||||||
setBacklogItems(data.items)
|
|
||||||
setUsePostgres(true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// PostgreSQL nicht verfuegbar, nutze Legacy
|
|
||||||
setUsePostgres(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to fetch test registry data:', err)
|
|
||||||
setServices(DEMO_SERVICES)
|
|
||||||
setStats(DEMO_STATS)
|
|
||||||
setCoverage(DEMO_SERVICES.filter(s => s.coverage_percent).map(s => ({
|
|
||||||
service: s.service,
|
|
||||||
display_name: s.display_name,
|
|
||||||
coverage_percent: s.coverage_percent!,
|
|
||||||
language: s.language,
|
|
||||||
})))
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false)
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchData()
|
|
||||||
}, [fetchData])
|
|
||||||
|
|
||||||
// Update failed test status
|
|
||||||
const updateTestStatus = async (testId: string, status: string) => {
|
|
||||||
try {
|
|
||||||
const endpoint = usePostgres
|
|
||||||
? `${API_BASE}/backlog/${testId}/status`
|
|
||||||
: `${API_BASE}/failed/${encodeURIComponent(testId)}/status?status=${status}`
|
|
||||||
|
|
||||||
const response = await fetch(endpoint, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: usePostgres ? { 'Content-Type': 'application/json' } : undefined,
|
|
||||||
body: usePostgres ? JSON.stringify({ status }) : undefined,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
if (usePostgres) {
|
|
||||||
setBacklogItems(prev =>
|
|
||||||
prev.map(t => String(t.id) === testId ? { ...t, status: status as any } : t)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
setFailedTests(prev =>
|
|
||||||
prev.map(t => t.id === testId ? { ...t, status: status as any } : t)
|
|
||||||
)
|
|
||||||
addToast('success', `Test-Status auf "${status}" gesetzt`)
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to update test status:', err)
|
|
||||||
setFailedTests(prev =>
|
|
||||||
prev.map(t => t.id === testId ? { ...t, status: status as any } : t)
|
|
||||||
)
|
|
||||||
if (usePostgres) {
|
|
||||||
setBacklogItems(prev =>
|
|
||||||
prev.map(t => String(t.id) === testId ? { ...t, status: status as any } : t)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update failed test priority (nur PostgreSQL)
|
|
||||||
const updateTestPriority = async (testId: string, priority: string) => {
|
|
||||||
if (!usePostgres) return
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${API_BASE}/backlog/${testId}/priority`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ priority }),
|
|
||||||
})
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
setBacklogItems(prev =>
|
|
||||||
prev.map(t => String(t.id) === testId ? { ...t, priority: priority as any } : t)
|
|
||||||
)
|
|
||||||
addToast('success', `Prioritaet auf "${priority}" gesetzt`)
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to update test priority:', err)
|
|
||||||
setBacklogItems(prev =>
|
|
||||||
prev.map(t => String(t.id) === testId ? { ...t, priority: priority as any } : t)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Run tests mit Progress-Polling
|
|
||||||
const runTests = async (service: string) => {
|
|
||||||
setRunningServices((prev) => new Set(prev).add(service))
|
|
||||||
const loadingToast = addToast('loading', `Tests fuer ${service} werden gestartet...`)
|
|
||||||
|
|
||||||
// Progress-Polling starten
|
|
||||||
let pollInterval: NodeJS.Timeout | null = null
|
|
||||||
const pollProgress = async () => {
|
|
||||||
try {
|
|
||||||
const progressResponse = await fetch(`${API_BASE}/progress/${service}`)
|
|
||||||
if (progressResponse.ok) {
|
|
||||||
const progress = await progressResponse.json()
|
|
||||||
setServiceProgress((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[service]: progress,
|
|
||||||
}))
|
|
||||||
|
|
||||||
if (progress.status === 'running' && progress.files_total > 0) {
|
|
||||||
const toastMsg = `${service}: ${progress.current_file} (${progress.passed} bestanden, ${progress.failed} fehler)`
|
|
||||||
updateToast(loadingToast, 'loading', toastMsg)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
// Ignore polling errors
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pollInterval = setInterval(pollProgress, 1000)
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${API_BASE}/run/${service}`, {
|
|
||||||
method: 'POST',
|
|
||||||
})
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 500))
|
|
||||||
await pollProgress()
|
|
||||||
const finalProgress = serviceProgress[service]
|
|
||||||
const passedMsg = finalProgress ? `${finalProgress.passed} bestanden, ${finalProgress.failed} fehler` : 'abgeschlossen'
|
|
||||||
updateToast(loadingToast, 'success', `${service}: Tests ${passedMsg}`)
|
|
||||||
await fetchData()
|
|
||||||
} else {
|
|
||||||
updateToast(loadingToast, 'info', `${service}: Demo-Modus (API nicht verfuegbar)`)
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to run tests:', err)
|
|
||||||
updateToast(loadingToast, 'info', `${service}: Demo-Modus (API nicht verfuegbar)`)
|
|
||||||
} finally {
|
|
||||||
if (pollInterval) {
|
|
||||||
clearInterval(pollInterval)
|
|
||||||
}
|
|
||||||
setRunningServices((prev) => {
|
|
||||||
const next = new Set(prev)
|
|
||||||
next.delete(service)
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
setServiceProgress((prev) => {
|
|
||||||
const next = { ...prev }
|
|
||||||
delete next[service]
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
activeTab,
|
|
||||||
setActiveTab,
|
|
||||||
isLoading,
|
|
||||||
error,
|
|
||||||
toasts,
|
|
||||||
removeToast,
|
|
||||||
services,
|
|
||||||
stats,
|
|
||||||
coverage,
|
|
||||||
testRuns,
|
|
||||||
failedTests,
|
|
||||||
backlogItems,
|
|
||||||
usePostgres,
|
|
||||||
runningServices,
|
|
||||||
serviceProgress,
|
|
||||||
fetchData,
|
|
||||||
updateTestStatus,
|
|
||||||
updateTestPriority,
|
|
||||||
runTests,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,102 +0,0 @@
|
|||||||
/**
|
|
||||||
* Types for Test Dashboard
|
|
||||||
*/
|
|
||||||
|
|
||||||
export interface ServiceTestInfo {
|
|
||||||
service: string
|
|
||||||
display_name: string
|
|
||||||
port?: number
|
|
||||||
language: string
|
|
||||||
total_tests: number
|
|
||||||
passed_tests: number
|
|
||||||
failed_tests: number
|
|
||||||
skipped_tests: number
|
|
||||||
pass_rate: number
|
|
||||||
coverage_percent?: number
|
|
||||||
last_run: string
|
|
||||||
status: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TestRegistryStats {
|
|
||||||
total_tests: number
|
|
||||||
total_passed: number
|
|
||||||
total_failed: number
|
|
||||||
total_skipped: number
|
|
||||||
overall_pass_rate: number
|
|
||||||
average_coverage: number
|
|
||||||
services_count: number
|
|
||||||
by_category: Record<string, number>
|
|
||||||
by_framework: Record<string, number>
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TestRun {
|
|
||||||
id: string
|
|
||||||
service: string
|
|
||||||
started_at: string
|
|
||||||
total_tests: number
|
|
||||||
passed_tests: number
|
|
||||||
failed_tests: number
|
|
||||||
duration_seconds: number
|
|
||||||
status: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CoverageData {
|
|
||||||
service: string
|
|
||||||
display_name: string
|
|
||||||
coverage_percent: number
|
|
||||||
language: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export type TabType = 'overview' | 'unit' | 'bqas' | 'history' | 'backlog' | 'guide'
|
|
||||||
|
|
||||||
export interface Toast {
|
|
||||||
id: number
|
|
||||||
type: 'success' | 'error' | 'loading' | 'info'
|
|
||||||
message: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FailedTest {
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
service: string
|
|
||||||
file_path: string
|
|
||||||
error_message: string
|
|
||||||
error_type: string
|
|
||||||
suggestion: string
|
|
||||||
run_id: string
|
|
||||||
last_failed: string
|
|
||||||
status: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export type BacklogPriority = 'critical' | 'high' | 'medium' | 'low'
|
|
||||||
export type BacklogStatus = 'open' | 'in_progress' | 'fixed' | 'wont_fix' | 'flaky'
|
|
||||||
|
|
||||||
export interface BacklogItem {
|
|
||||||
id: number
|
|
||||||
test_name: string
|
|
||||||
service: string
|
|
||||||
test_file?: string
|
|
||||||
error_message?: string
|
|
||||||
error_type?: string
|
|
||||||
fix_suggestion?: string
|
|
||||||
priority: BacklogPriority
|
|
||||||
status: BacklogStatus
|
|
||||||
failure_count: number
|
|
||||||
last_failed_at: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TrendDataPoint {
|
|
||||||
date: string
|
|
||||||
passed: number
|
|
||||||
failed: number
|
|
||||||
total: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ServiceProgress {
|
|
||||||
current_file: string
|
|
||||||
files_done: number
|
|
||||||
files_total: number
|
|
||||||
passed: number
|
|
||||||
failed: number
|
|
||||||
status: string
|
|
||||||
}
|
|
||||||
@@ -20,17 +20,108 @@
|
|||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import type {
|
import type {
|
||||||
|
DevOpsToolId,
|
||||||
DevOpsPipelineSidebarProps,
|
DevOpsPipelineSidebarProps,
|
||||||
DevOpsPipelineSidebarResponsiveProps,
|
DevOpsPipelineSidebarResponsiveProps,
|
||||||
|
PipelineLiveStatus,
|
||||||
} from '@/types/infrastructure-modules'
|
} from '@/types/infrastructure-modules'
|
||||||
import { DEVOPS_PIPELINE_MODULES } from '@/types/infrastructure-modules'
|
import { DEVOPS_PIPELINE_MODULES } from '@/types/infrastructure-modules'
|
||||||
import {
|
|
||||||
ToolIcon,
|
// =============================================================================
|
||||||
ServerIcon,
|
// Icons
|
||||||
PlayIcon,
|
// =============================================================================
|
||||||
StatusBadge,
|
|
||||||
usePipelineLiveStatus,
|
const ToolIcon = ({ id }: { id: DevOpsToolId }) => {
|
||||||
} from './DevOpsPipelineSidebarParts'
|
switch (id) {
|
||||||
|
case 'ci-cd':
|
||||||
|
return (
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||||
|
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
case 'tests':
|
||||||
|
return (
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||||
|
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
case 'sbom':
|
||||||
|
return (
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||||
|
d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
case 'security':
|
||||||
|
return (
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||||
|
d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
default:
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Server/Pipeline Icon fuer Header
|
||||||
|
const ServerIcon = () => (
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||||
|
d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
// Play Icon fuer Quick Action
|
||||||
|
const PlayIcon = () => (
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||||
|
d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||||
|
d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Live Status Hook (optional - fetches status from API)
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
function usePipelineLiveStatus(): PipelineLiveStatus | null {
|
||||||
|
const [status, setStatus] = useState<PipelineLiveStatus | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Live status fetching not yet implemented
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return status
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Status Badge Component
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
interface StatusBadgeProps {
|
||||||
|
count: number
|
||||||
|
type: 'backlog' | 'security' | 'running'
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusBadge({ count, type }: StatusBadgeProps) {
|
||||||
|
if (count === 0) return null
|
||||||
|
|
||||||
|
const colors = {
|
||||||
|
backlog: 'bg-amber-500',
|
||||||
|
security: 'bg-red-500',
|
||||||
|
running: 'bg-green-500 animate-pulse',
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={`${colors[type]} text-white text-xs font-medium px-1.5 py-0.5 rounded-full min-w-[1.25rem] text-center`}>
|
||||||
|
{count}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Main Sidebar Component
|
// Main Sidebar Component
|
||||||
|
|||||||
@@ -1,106 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* DevOps Pipeline Sidebar — shared icons, badge, and live-status hook.
|
|
||||||
*
|
|
||||||
* Extracted from DevOpsPipelineSidebar.tsx to stay within the 500 LOC budget.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useState, useEffect } from 'react'
|
|
||||||
import type { DevOpsToolId, PipelineLiveStatus } from '@/types/infrastructure-modules'
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// Icons
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
export const ToolIcon = ({ id }: { id: DevOpsToolId }) => {
|
|
||||||
switch (id) {
|
|
||||||
case 'ci-cd':
|
|
||||||
return (
|
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
|
||||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
|
||||||
</svg>
|
|
||||||
)
|
|
||||||
case 'tests':
|
|
||||||
return (
|
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
|
||||||
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
||||||
</svg>
|
|
||||||
)
|
|
||||||
case 'sbom':
|
|
||||||
return (
|
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
|
||||||
d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
|
|
||||||
</svg>
|
|
||||||
)
|
|
||||||
case 'security':
|
|
||||||
return (
|
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
|
||||||
d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
|
||||||
</svg>
|
|
||||||
)
|
|
||||||
default:
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Server/Pipeline Icon fuer Header
|
|
||||||
export const ServerIcon = () => (
|
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
|
||||||
d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01" />
|
|
||||||
</svg>
|
|
||||||
)
|
|
||||||
|
|
||||||
// Play Icon fuer Quick Action
|
|
||||||
export const PlayIcon = () => (
|
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
|
||||||
d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
|
||||||
d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
||||||
</svg>
|
|
||||||
)
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// Live Status Hook (optional - fetches status from API)
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
export function usePipelineLiveStatus(): PipelineLiveStatus | null {
|
|
||||||
const [status, setStatus] = useState<PipelineLiveStatus | null>(null)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// Live status fetching not yet implemented
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
return status
|
|
||||||
}
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// Status Badge Component
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
interface StatusBadgeProps {
|
|
||||||
count: number
|
|
||||||
type: 'backlog' | 'security' | 'running'
|
|
||||||
}
|
|
||||||
|
|
||||||
export function StatusBadge({ count, type }: StatusBadgeProps) {
|
|
||||||
if (count === 0) return null
|
|
||||||
|
|
||||||
const colors = {
|
|
||||||
backlog: 'bg-amber-500',
|
|
||||||
security: 'bg-red-500',
|
|
||||||
running: 'bg-green-500 animate-pulse',
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<span className={`${colors[type]} text-white text-xs font-medium px-1.5 py-0.5 rounded-full min-w-[1.25rem] text-center`}>
|
|
||||||
{count}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -5,7 +5,7 @@ Hybrid authentication supporting both Keycloak and local JWT tokens.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from .keycloak_auth import (
|
from .keycloak_auth import (
|
||||||
# Config & Models
|
# Config
|
||||||
KeycloakConfig,
|
KeycloakConfig,
|
||||||
KeycloakUser,
|
KeycloakUser,
|
||||||
|
|
||||||
@@ -18,9 +18,7 @@ from .keycloak_auth import (
|
|||||||
TokenExpiredError,
|
TokenExpiredError,
|
||||||
TokenInvalidError,
|
TokenInvalidError,
|
||||||
KeycloakConfigError,
|
KeycloakConfigError,
|
||||||
)
|
|
||||||
|
|
||||||
from .dependencies import (
|
|
||||||
# Factory functions
|
# Factory functions
|
||||||
get_keycloak_config_from_env,
|
get_keycloak_config_from_env,
|
||||||
get_authenticator,
|
get_authenticator,
|
||||||
@@ -32,7 +30,7 @@ from .dependencies import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
# Config & Models
|
# Config
|
||||||
"KeycloakConfig",
|
"KeycloakConfig",
|
||||||
"KeycloakUser",
|
"KeycloakUser",
|
||||||
|
|
||||||
|
|||||||
@@ -1,164 +0,0 @@
|
|||||||
"""
|
|
||||||
FastAPI Authentication Dependencies and Factory Functions.
|
|
||||||
|
|
||||||
Provides:
|
|
||||||
- get_keycloak_config_from_env(): Create config from env vars
|
|
||||||
- get_authenticator(): Create HybridAuthenticator instance
|
|
||||||
- get_auth(): Global authenticator singleton
|
|
||||||
- get_current_user(): FastAPI dependency for authentication
|
|
||||||
- require_role(): FastAPI dependency factory for role-based access
|
|
||||||
"""
|
|
||||||
|
|
||||||
import os
|
|
||||||
import logging
|
|
||||||
from typing import Optional, Dict, Any
|
|
||||||
|
|
||||||
from fastapi import Request, HTTPException, Depends
|
|
||||||
|
|
||||||
from .keycloak_auth import (
|
|
||||||
KeycloakConfig,
|
|
||||||
KeycloakConfigError,
|
|
||||||
HybridAuthenticator,
|
|
||||||
TokenExpiredError,
|
|
||||||
TokenInvalidError,
|
|
||||||
)
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================
|
|
||||||
# FACTORY FUNCTIONS
|
|
||||||
# =============================================
|
|
||||||
|
|
||||||
def get_keycloak_config_from_env() -> Optional[KeycloakConfig]:
|
|
||||||
"""
|
|
||||||
Create KeycloakConfig from environment variables.
|
|
||||||
|
|
||||||
Required env vars:
|
|
||||||
- KEYCLOAK_SERVER_URL: e.g., https://keycloak.breakpilot.app
|
|
||||||
- KEYCLOAK_REALM: e.g., breakpilot
|
|
||||||
- KEYCLOAK_CLIENT_ID: e.g., breakpilot-backend
|
|
||||||
|
|
||||||
Optional:
|
|
||||||
- KEYCLOAK_CLIENT_SECRET: For confidential clients
|
|
||||||
- KEYCLOAK_VERIFY_SSL: Default true
|
|
||||||
"""
|
|
||||||
server_url = os.environ.get("KEYCLOAK_SERVER_URL")
|
|
||||||
realm = os.environ.get("KEYCLOAK_REALM")
|
|
||||||
client_id = os.environ.get("KEYCLOAK_CLIENT_ID")
|
|
||||||
|
|
||||||
if not all([server_url, realm, client_id]):
|
|
||||||
logger.info("Keycloak not configured, using local JWT only")
|
|
||||||
return None
|
|
||||||
|
|
||||||
return KeycloakConfig(
|
|
||||||
server_url=server_url,
|
|
||||||
realm=realm,
|
|
||||||
client_id=client_id,
|
|
||||||
client_secret=os.environ.get("KEYCLOAK_CLIENT_SECRET"),
|
|
||||||
verify_ssl=os.environ.get("KEYCLOAK_VERIFY_SSL", "true").lower() == "true"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def get_authenticator() -> HybridAuthenticator:
|
|
||||||
"""
|
|
||||||
Get configured authenticator instance.
|
|
||||||
|
|
||||||
Uses environment variables to determine configuration.
|
|
||||||
"""
|
|
||||||
keycloak_config = get_keycloak_config_from_env()
|
|
||||||
|
|
||||||
# JWT_SECRET is required - no default fallback in production
|
|
||||||
jwt_secret = os.environ.get("JWT_SECRET")
|
|
||||||
environment = os.environ.get("ENVIRONMENT", "development")
|
|
||||||
|
|
||||||
if not jwt_secret and environment == "production":
|
|
||||||
raise KeycloakConfigError(
|
|
||||||
"JWT_SECRET environment variable is required in production"
|
|
||||||
)
|
|
||||||
|
|
||||||
return HybridAuthenticator(
|
|
||||||
keycloak_config=keycloak_config,
|
|
||||||
local_jwt_secret=jwt_secret,
|
|
||||||
environment=environment
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================
|
|
||||||
# FASTAPI DEPENDENCY
|
|
||||||
# =============================================
|
|
||||||
|
|
||||||
# Global authenticator instance (lazy-initialized)
|
|
||||||
_authenticator: Optional[HybridAuthenticator] = None
|
|
||||||
|
|
||||||
|
|
||||||
def get_auth() -> HybridAuthenticator:
|
|
||||||
"""Get or create global authenticator."""
|
|
||||||
global _authenticator
|
|
||||||
if _authenticator is None:
|
|
||||||
_authenticator = get_authenticator()
|
|
||||||
return _authenticator
|
|
||||||
|
|
||||||
|
|
||||||
async def get_current_user(request: Request) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
FastAPI dependency to get current authenticated user.
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
@app.get("/api/protected")
|
|
||||||
async def protected_endpoint(user: dict = Depends(get_current_user)):
|
|
||||||
return {"user_id": user["user_id"]}
|
|
||||||
"""
|
|
||||||
auth_header = request.headers.get("authorization", "")
|
|
||||||
|
|
||||||
if not auth_header.startswith("Bearer "):
|
|
||||||
# Check for development mode
|
|
||||||
environment = os.environ.get("ENVIRONMENT", "development")
|
|
||||||
if environment == "development":
|
|
||||||
# Return demo user in development without token
|
|
||||||
return {
|
|
||||||
"user_id": "10000000-0000-0000-0000-000000000024",
|
|
||||||
"email": "demo@breakpilot.app",
|
|
||||||
"role": "admin",
|
|
||||||
"realm_roles": ["admin"],
|
|
||||||
"tenant_id": "a0000000-0000-0000-0000-000000000001",
|
|
||||||
"auth_method": "development_bypass"
|
|
||||||
}
|
|
||||||
raise HTTPException(status_code=401, detail="Missing authorization header")
|
|
||||||
|
|
||||||
token = auth_header.split(" ")[1]
|
|
||||||
|
|
||||||
try:
|
|
||||||
auth = get_auth()
|
|
||||||
return await auth.validate_token(token)
|
|
||||||
except TokenExpiredError:
|
|
||||||
raise HTTPException(status_code=401, detail="Token expired")
|
|
||||||
except TokenInvalidError as e:
|
|
||||||
raise HTTPException(status_code=401, detail=str(e))
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Authentication failed: {e}")
|
|
||||||
raise HTTPException(status_code=401, detail="Authentication failed")
|
|
||||||
|
|
||||||
|
|
||||||
async def require_role(required_role: str):
|
|
||||||
"""
|
|
||||||
FastAPI dependency factory for role-based access.
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
@app.get("/api/admin-only")
|
|
||||||
async def admin_endpoint(user: dict = Depends(require_role("admin"))):
|
|
||||||
return {"message": "Admin access granted"}
|
|
||||||
"""
|
|
||||||
async def role_checker(user: dict = Depends(get_current_user)) -> dict:
|
|
||||||
user_role = user.get("role", "user")
|
|
||||||
realm_roles = user.get("realm_roles", [])
|
|
||||||
|
|
||||||
if user_role == required_role or required_role in realm_roles:
|
|
||||||
return user
|
|
||||||
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=403,
|
|
||||||
detail=f"Role '{required_role}' required"
|
|
||||||
)
|
|
||||||
|
|
||||||
return role_checker
|
|
||||||
@@ -374,3 +374,142 @@ class HybridAuthenticator:
|
|||||||
if self.keycloak_auth:
|
if self.keycloak_auth:
|
||||||
await self.keycloak_auth.close()
|
await self.keycloak_auth.close()
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================
|
||||||
|
# FACTORY FUNCTIONS
|
||||||
|
# =============================================
|
||||||
|
|
||||||
|
def get_keycloak_config_from_env() -> Optional[KeycloakConfig]:
|
||||||
|
"""
|
||||||
|
Create KeycloakConfig from environment variables.
|
||||||
|
|
||||||
|
Required env vars:
|
||||||
|
- KEYCLOAK_SERVER_URL: e.g., https://keycloak.breakpilot.app
|
||||||
|
- KEYCLOAK_REALM: e.g., breakpilot
|
||||||
|
- KEYCLOAK_CLIENT_ID: e.g., breakpilot-backend
|
||||||
|
|
||||||
|
Optional:
|
||||||
|
- KEYCLOAK_CLIENT_SECRET: For confidential clients
|
||||||
|
- KEYCLOAK_VERIFY_SSL: Default true
|
||||||
|
"""
|
||||||
|
server_url = os.environ.get("KEYCLOAK_SERVER_URL")
|
||||||
|
realm = os.environ.get("KEYCLOAK_REALM")
|
||||||
|
client_id = os.environ.get("KEYCLOAK_CLIENT_ID")
|
||||||
|
|
||||||
|
if not all([server_url, realm, client_id]):
|
||||||
|
logger.info("Keycloak not configured, using local JWT only")
|
||||||
|
return None
|
||||||
|
|
||||||
|
return KeycloakConfig(
|
||||||
|
server_url=server_url,
|
||||||
|
realm=realm,
|
||||||
|
client_id=client_id,
|
||||||
|
client_secret=os.environ.get("KEYCLOAK_CLIENT_SECRET"),
|
||||||
|
verify_ssl=os.environ.get("KEYCLOAK_VERIFY_SSL", "true").lower() == "true"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_authenticator() -> HybridAuthenticator:
|
||||||
|
"""
|
||||||
|
Get configured authenticator instance.
|
||||||
|
|
||||||
|
Uses environment variables to determine configuration.
|
||||||
|
"""
|
||||||
|
keycloak_config = get_keycloak_config_from_env()
|
||||||
|
|
||||||
|
# JWT_SECRET is required - no default fallback in production
|
||||||
|
jwt_secret = os.environ.get("JWT_SECRET")
|
||||||
|
environment = os.environ.get("ENVIRONMENT", "development")
|
||||||
|
|
||||||
|
if not jwt_secret and environment == "production":
|
||||||
|
raise KeycloakConfigError(
|
||||||
|
"JWT_SECRET environment variable is required in production"
|
||||||
|
)
|
||||||
|
|
||||||
|
return HybridAuthenticator(
|
||||||
|
keycloak_config=keycloak_config,
|
||||||
|
local_jwt_secret=jwt_secret,
|
||||||
|
environment=environment
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================
|
||||||
|
# FASTAPI DEPENDENCY
|
||||||
|
# =============================================
|
||||||
|
|
||||||
|
from fastapi import Request, HTTPException, Depends
|
||||||
|
|
||||||
|
# Global authenticator instance (lazy-initialized)
|
||||||
|
_authenticator: Optional[HybridAuthenticator] = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_auth() -> HybridAuthenticator:
|
||||||
|
"""Get or create global authenticator."""
|
||||||
|
global _authenticator
|
||||||
|
if _authenticator is None:
|
||||||
|
_authenticator = get_authenticator()
|
||||||
|
return _authenticator
|
||||||
|
|
||||||
|
|
||||||
|
async def get_current_user(request: Request) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
FastAPI dependency to get current authenticated user.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
@app.get("/api/protected")
|
||||||
|
async def protected_endpoint(user: dict = Depends(get_current_user)):
|
||||||
|
return {"user_id": user["user_id"]}
|
||||||
|
"""
|
||||||
|
auth_header = request.headers.get("authorization", "")
|
||||||
|
|
||||||
|
if not auth_header.startswith("Bearer "):
|
||||||
|
# Check for development mode
|
||||||
|
environment = os.environ.get("ENVIRONMENT", "development")
|
||||||
|
if environment == "development":
|
||||||
|
# Return demo user in development without token
|
||||||
|
return {
|
||||||
|
"user_id": "10000000-0000-0000-0000-000000000024",
|
||||||
|
"email": "demo@breakpilot.app",
|
||||||
|
"role": "admin",
|
||||||
|
"realm_roles": ["admin"],
|
||||||
|
"tenant_id": "a0000000-0000-0000-0000-000000000001",
|
||||||
|
"auth_method": "development_bypass"
|
||||||
|
}
|
||||||
|
raise HTTPException(status_code=401, detail="Missing authorization header")
|
||||||
|
|
||||||
|
token = auth_header.split(" ")[1]
|
||||||
|
|
||||||
|
try:
|
||||||
|
auth = get_auth()
|
||||||
|
return await auth.validate_token(token)
|
||||||
|
except TokenExpiredError:
|
||||||
|
raise HTTPException(status_code=401, detail="Token expired")
|
||||||
|
except TokenInvalidError as e:
|
||||||
|
raise HTTPException(status_code=401, detail=str(e))
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Authentication failed: {e}")
|
||||||
|
raise HTTPException(status_code=401, detail="Authentication failed")
|
||||||
|
|
||||||
|
|
||||||
|
async def require_role(required_role: str):
|
||||||
|
"""
|
||||||
|
FastAPI dependency factory for role-based access.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
@app.get("/api/admin-only")
|
||||||
|
async def admin_endpoint(user: dict = Depends(require_role("admin"))):
|
||||||
|
return {"message": "Admin access granted"}
|
||||||
|
"""
|
||||||
|
async def role_checker(user: dict = Depends(get_current_user)) -> dict:
|
||||||
|
user_role = user.get("role", "user")
|
||||||
|
realm_roles = user.get("realm_roles", [])
|
||||||
|
|
||||||
|
if user_role == required_role or required_role in realm_roles:
|
||||||
|
return user
|
||||||
|
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=403,
|
||||||
|
detail=f"Role '{required_role}' required"
|
||||||
|
)
|
||||||
|
|
||||||
|
return role_checker
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ from fastapi.middleware.cors import CORSMiddleware
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
from auth_api import router as auth_router
|
from auth_api import router as auth_router
|
||||||
from rbac_api import router as rbac_router
|
from rbac_api import router as rbac_router
|
||||||
from rbac_teachers_api import router as rbac_teachers_router
|
|
||||||
from notification_api import router as notification_router
|
from notification_api import router as notification_router
|
||||||
from email_template_api import (
|
from email_template_api import (
|
||||||
router as email_template_router,
|
router as email_template_router,
|
||||||
@@ -90,12 +89,9 @@ app.add_middleware(RateLimiterMiddleware, valkey_url=VALKEY_URL)
|
|||||||
# Auth (proxy to consent-service)
|
# Auth (proxy to consent-service)
|
||||||
app.include_router(auth_router, prefix="/api")
|
app.include_router(auth_router, prefix="/api")
|
||||||
|
|
||||||
# RBAC (role / assignment / custom-role management)
|
# RBAC (teacher / role management)
|
||||||
app.include_router(rbac_router, prefix="/api")
|
app.include_router(rbac_router, prefix="/api")
|
||||||
|
|
||||||
# RBAC Teachers (teacher CRUD, listing, roles per teacher)
|
|
||||||
app.include_router(rbac_teachers_router, prefix="/api")
|
|
||||||
|
|
||||||
# Notifications (proxy to consent-service)
|
# Notifications (proxy to consent-service)
|
||||||
app.include_router(notification_router, prefix="/api")
|
app.include_router(notification_router, prefix="/api")
|
||||||
|
|
||||||
|
|||||||
+335
-10
@@ -1,14 +1,11 @@
|
|||||||
"""
|
"""
|
||||||
RBAC API - Role and Assignment Management Endpoints
|
RBAC API - Teacher and Role Management Endpoints
|
||||||
|
|
||||||
Provides API endpoints for:
|
Provides API endpoints for:
|
||||||
- Listing all available roles (built-in + custom)
|
- Listing all teachers
|
||||||
- Assigning/revoking roles to users
|
- Listing all available roles
|
||||||
- Role summary with assignment counts
|
- Assigning/revoking roles to teachers
|
||||||
- Custom role CRUD
|
- Viewing role assignments per teacher
|
||||||
|
|
||||||
Shared infrastructure (DB pool, Pydantic models, role definitions)
|
|
||||||
used by rbac_teachers_api.py as well.
|
|
||||||
|
|
||||||
Architecture:
|
Architecture:
|
||||||
- Authentication: Keycloak (when configured) or local JWT
|
- Authentication: Keycloak (when configured) or local JWT
|
||||||
@@ -27,8 +24,7 @@ try:
|
|||||||
from auth import get_current_user, TokenExpiredError, TokenInvalidError
|
from auth import get_current_user, TokenExpiredError, TokenInvalidError
|
||||||
except ImportError:
|
except ImportError:
|
||||||
# Fallback for standalone testing
|
# Fallback for standalone testing
|
||||||
from auth.keycloak_auth import TokenExpiredError, TokenInvalidError
|
from auth.keycloak_auth import get_current_user, TokenExpiredError, TokenInvalidError
|
||||||
from auth.dependencies import get_current_user
|
|
||||||
|
|
||||||
# Configuration from environment - NO DEFAULT SECRETS
|
# Configuration from environment - NO DEFAULT SECRETS
|
||||||
ENVIRONMENT = os.environ.get("ENVIRONMENT", "development")
|
ENVIRONMENT = os.environ.get("ENVIRONMENT", "development")
|
||||||
@@ -234,6 +230,163 @@ async def list_available_roles() -> List[RoleInfo]:
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/teachers")
|
||||||
|
async def list_teachers(user: Dict[str, Any] = Depends(get_current_user)) -> List[TeacherResponse]:
|
||||||
|
"""List all teachers with their current roles"""
|
||||||
|
pool = await get_pool()
|
||||||
|
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
# Get all teachers with their user info
|
||||||
|
teachers = await conn.fetch("""
|
||||||
|
SELECT
|
||||||
|
t.id, t.user_id, t.teacher_code, t.title,
|
||||||
|
t.first_name, t.last_name, t.is_active,
|
||||||
|
u.email, u.name
|
||||||
|
FROM teachers t
|
||||||
|
JOIN users u ON t.user_id = u.id
|
||||||
|
WHERE t.school_id = 'a0000000-0000-0000-0000-000000000001'
|
||||||
|
ORDER BY t.last_name, t.first_name
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Get role assignments for all teachers
|
||||||
|
role_assignments = await conn.fetch("""
|
||||||
|
SELECT user_id, role
|
||||||
|
FROM role_assignments
|
||||||
|
WHERE tenant_id = 'a0000000-0000-0000-0000-000000000001'
|
||||||
|
AND revoked_at IS NULL
|
||||||
|
AND (valid_to IS NULL OR valid_to > NOW())
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Build role lookup
|
||||||
|
role_lookup: Dict[str, List[str]] = {}
|
||||||
|
for ra in role_assignments:
|
||||||
|
uid = str(ra["user_id"])
|
||||||
|
if uid not in role_lookup:
|
||||||
|
role_lookup[uid] = []
|
||||||
|
role_lookup[uid].append(ra["role"])
|
||||||
|
|
||||||
|
# Build response
|
||||||
|
result = []
|
||||||
|
for t in teachers:
|
||||||
|
uid = str(t["user_id"])
|
||||||
|
result.append(TeacherResponse(
|
||||||
|
id=str(t["id"]),
|
||||||
|
user_id=uid,
|
||||||
|
email=t["email"],
|
||||||
|
name=t["name"] or f"{t['first_name']} {t['last_name']}",
|
||||||
|
teacher_code=t["teacher_code"],
|
||||||
|
title=t["title"],
|
||||||
|
first_name=t["first_name"],
|
||||||
|
last_name=t["last_name"],
|
||||||
|
is_active=t["is_active"],
|
||||||
|
roles=role_lookup.get(uid, [])
|
||||||
|
))
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/teachers/{teacher_id}/roles")
|
||||||
|
async def get_teacher_roles(teacher_id: str, user: Dict[str, Any] = Depends(get_current_user)) -> List[RoleAssignmentResponse]:
|
||||||
|
"""Get all role assignments for a specific teacher"""
|
||||||
|
pool = await get_pool()
|
||||||
|
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
# Get teacher's user_id
|
||||||
|
teacher = await conn.fetchrow(
|
||||||
|
"SELECT user_id FROM teachers WHERE id = $1",
|
||||||
|
teacher_id
|
||||||
|
)
|
||||||
|
if not teacher:
|
||||||
|
raise HTTPException(status_code=404, detail="Teacher not found")
|
||||||
|
|
||||||
|
# Get role assignments
|
||||||
|
assignments = await conn.fetch("""
|
||||||
|
SELECT id, user_id, role, resource_type, resource_id,
|
||||||
|
valid_from, valid_to, granted_at, revoked_at
|
||||||
|
FROM role_assignments
|
||||||
|
WHERE user_id = $1
|
||||||
|
ORDER BY granted_at DESC
|
||||||
|
""", teacher["user_id"])
|
||||||
|
|
||||||
|
return [
|
||||||
|
RoleAssignmentResponse(
|
||||||
|
id=str(a["id"]),
|
||||||
|
user_id=str(a["user_id"]),
|
||||||
|
role=a["role"],
|
||||||
|
resource_type=a["resource_type"],
|
||||||
|
resource_id=str(a["resource_id"]),
|
||||||
|
valid_from=a["valid_from"].isoformat() if a["valid_from"] else None,
|
||||||
|
valid_to=a["valid_to"].isoformat() if a["valid_to"] else None,
|
||||||
|
granted_at=a["granted_at"].isoformat() if a["granted_at"] else None,
|
||||||
|
is_active=a["revoked_at"] is None and (
|
||||||
|
a["valid_to"] is None or a["valid_to"] > datetime.now(timezone.utc)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for a in assignments
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/roles/{role}/teachers")
|
||||||
|
async def get_teachers_by_role(role: str, user: Dict[str, Any] = Depends(get_current_user)) -> List[TeacherResponse]:
|
||||||
|
"""Get all teachers with a specific role"""
|
||||||
|
if role not in AVAILABLE_ROLES:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Unknown role: {role}")
|
||||||
|
|
||||||
|
pool = await get_pool()
|
||||||
|
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
teachers = await conn.fetch("""
|
||||||
|
SELECT DISTINCT
|
||||||
|
t.id, t.user_id, t.teacher_code, t.title,
|
||||||
|
t.first_name, t.last_name, t.is_active,
|
||||||
|
u.email, u.name
|
||||||
|
FROM teachers t
|
||||||
|
JOIN users u ON t.user_id = u.id
|
||||||
|
JOIN role_assignments ra ON t.user_id = ra.user_id
|
||||||
|
WHERE ra.role = $1
|
||||||
|
AND ra.revoked_at IS NULL
|
||||||
|
AND (ra.valid_to IS NULL OR ra.valid_to > NOW())
|
||||||
|
AND t.school_id = 'a0000000-0000-0000-0000-000000000001'
|
||||||
|
ORDER BY t.last_name, t.first_name
|
||||||
|
""", role)
|
||||||
|
|
||||||
|
# Get all roles for these teachers
|
||||||
|
if teachers:
|
||||||
|
user_ids = [t["user_id"] for t in teachers]
|
||||||
|
role_assignments = await conn.fetch("""
|
||||||
|
SELECT user_id, role
|
||||||
|
FROM role_assignments
|
||||||
|
WHERE user_id = ANY($1)
|
||||||
|
AND revoked_at IS NULL
|
||||||
|
AND (valid_to IS NULL OR valid_to > NOW())
|
||||||
|
""", user_ids)
|
||||||
|
|
||||||
|
role_lookup: Dict[str, List[str]] = {}
|
||||||
|
for ra in role_assignments:
|
||||||
|
uid = str(ra["user_id"])
|
||||||
|
if uid not in role_lookup:
|
||||||
|
role_lookup[uid] = []
|
||||||
|
role_lookup[uid].append(ra["role"])
|
||||||
|
else:
|
||||||
|
role_lookup = {}
|
||||||
|
|
||||||
|
return [
|
||||||
|
TeacherResponse(
|
||||||
|
id=str(t["id"]),
|
||||||
|
user_id=str(t["user_id"]),
|
||||||
|
email=t["email"],
|
||||||
|
name=t["name"] or f"{t['first_name']} {t['last_name']}",
|
||||||
|
teacher_code=t["teacher_code"],
|
||||||
|
title=t["title"],
|
||||||
|
first_name=t["first_name"],
|
||||||
|
last_name=t["last_name"],
|
||||||
|
is_active=t["is_active"],
|
||||||
|
roles=role_lookup.get(str(t["user_id"]), [])
|
||||||
|
)
|
||||||
|
for t in teachers
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
@router.post("/assignments")
|
@router.post("/assignments")
|
||||||
async def assign_role(assignment: RoleAssignmentCreate, user: Dict[str, Any] = Depends(get_current_user)) -> RoleAssignmentResponse:
|
async def assign_role(assignment: RoleAssignmentCreate, user: Dict[str, Any] = Depends(get_current_user)) -> RoleAssignmentResponse:
|
||||||
"""Assign a role to a user"""
|
"""Assign a role to a user"""
|
||||||
@@ -366,6 +519,178 @@ async def get_role_summary(user: Dict[str, Any] = Depends(get_current_user)) ->
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# TEACHER MANAGEMENT ENDPOINTS
|
||||||
|
# ==========================================
|
||||||
|
|
||||||
|
@router.post("/teachers")
|
||||||
|
async def create_teacher(teacher: TeacherCreate, user: Dict[str, Any] = Depends(get_current_user)) -> TeacherResponse:
|
||||||
|
"""Create a new teacher with optional initial roles"""
|
||||||
|
pool = await get_pool()
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
# Check if email already exists
|
||||||
|
existing = await conn.fetchrow(
|
||||||
|
"SELECT id FROM users WHERE email = $1",
|
||||||
|
teacher.email
|
||||||
|
)
|
||||||
|
if existing:
|
||||||
|
raise HTTPException(status_code=409, detail="Email already exists")
|
||||||
|
|
||||||
|
# Generate UUIDs
|
||||||
|
user_id = str(uuid.uuid4())
|
||||||
|
teacher_id = str(uuid.uuid4())
|
||||||
|
|
||||||
|
# Create user first
|
||||||
|
await conn.execute("""
|
||||||
|
INSERT INTO users (id, email, name, password_hash, role, is_active)
|
||||||
|
VALUES ($1, $2, $3, '', 'teacher', true)
|
||||||
|
""", user_id, teacher.email, f"{teacher.first_name} {teacher.last_name}")
|
||||||
|
|
||||||
|
# Create teacher record
|
||||||
|
await conn.execute("""
|
||||||
|
INSERT INTO teachers (id, user_id, school_id, first_name, last_name, teacher_code, title, is_active)
|
||||||
|
VALUES ($1, $2, 'a0000000-0000-0000-0000-000000000001', $3, $4, $5, $6, true)
|
||||||
|
""", teacher_id, user_id, teacher.first_name, teacher.last_name,
|
||||||
|
teacher.teacher_code, teacher.title)
|
||||||
|
|
||||||
|
# Assign initial roles if provided
|
||||||
|
assigned_roles = []
|
||||||
|
for role in teacher.roles:
|
||||||
|
if role in AVAILABLE_ROLES or await conn.fetchrow(
|
||||||
|
"SELECT 1 FROM custom_roles WHERE role_key = $1 AND is_active = true", role
|
||||||
|
):
|
||||||
|
await conn.execute("""
|
||||||
|
INSERT INTO role_assignments (user_id, role, resource_type, resource_id, tenant_id, granted_by)
|
||||||
|
VALUES ($1, $2, 'tenant', 'a0000000-0000-0000-0000-000000000001',
|
||||||
|
'a0000000-0000-0000-0000-000000000001', $3)
|
||||||
|
""", user_id, role, user.get("user_id"))
|
||||||
|
assigned_roles.append(role)
|
||||||
|
|
||||||
|
return TeacherResponse(
|
||||||
|
id=teacher_id,
|
||||||
|
user_id=user_id,
|
||||||
|
email=teacher.email,
|
||||||
|
name=f"{teacher.first_name} {teacher.last_name}",
|
||||||
|
teacher_code=teacher.teacher_code,
|
||||||
|
title=teacher.title,
|
||||||
|
first_name=teacher.first_name,
|
||||||
|
last_name=teacher.last_name,
|
||||||
|
is_active=True,
|
||||||
|
roles=assigned_roles
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/teachers/{teacher_id}")
|
||||||
|
async def update_teacher(teacher_id: str, updates: TeacherUpdate, user: Dict[str, Any] = Depends(get_current_user)) -> TeacherResponse:
|
||||||
|
"""Update teacher information"""
|
||||||
|
pool = await get_pool()
|
||||||
|
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
# Get current teacher data
|
||||||
|
teacher = await conn.fetchrow("""
|
||||||
|
SELECT t.id, t.user_id, t.teacher_code, t.title, t.first_name, t.last_name, t.is_active,
|
||||||
|
u.email, u.name
|
||||||
|
FROM teachers t
|
||||||
|
JOIN users u ON t.user_id = u.id
|
||||||
|
WHERE t.id = $1
|
||||||
|
""", teacher_id)
|
||||||
|
|
||||||
|
if not teacher:
|
||||||
|
raise HTTPException(status_code=404, detail="Teacher not found")
|
||||||
|
|
||||||
|
# Build update queries
|
||||||
|
if updates.email:
|
||||||
|
await conn.execute("UPDATE users SET email = $1 WHERE id = $2",
|
||||||
|
updates.email, teacher["user_id"])
|
||||||
|
|
||||||
|
teacher_updates = []
|
||||||
|
teacher_values = []
|
||||||
|
idx = 1
|
||||||
|
|
||||||
|
if updates.first_name:
|
||||||
|
teacher_updates.append(f"first_name = ${idx}")
|
||||||
|
teacher_values.append(updates.first_name)
|
||||||
|
idx += 1
|
||||||
|
if updates.last_name:
|
||||||
|
teacher_updates.append(f"last_name = ${idx}")
|
||||||
|
teacher_values.append(updates.last_name)
|
||||||
|
idx += 1
|
||||||
|
if updates.teacher_code is not None:
|
||||||
|
teacher_updates.append(f"teacher_code = ${idx}")
|
||||||
|
teacher_values.append(updates.teacher_code)
|
||||||
|
idx += 1
|
||||||
|
if updates.title is not None:
|
||||||
|
teacher_updates.append(f"title = ${idx}")
|
||||||
|
teacher_values.append(updates.title)
|
||||||
|
idx += 1
|
||||||
|
if updates.is_active is not None:
|
||||||
|
teacher_updates.append(f"is_active = ${idx}")
|
||||||
|
teacher_values.append(updates.is_active)
|
||||||
|
idx += 1
|
||||||
|
|
||||||
|
if teacher_updates:
|
||||||
|
teacher_values.append(teacher_id)
|
||||||
|
await conn.execute(
|
||||||
|
f"UPDATE teachers SET {', '.join(teacher_updates)} WHERE id = ${idx}",
|
||||||
|
*teacher_values
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update user name if first/last name changed
|
||||||
|
if updates.first_name or updates.last_name:
|
||||||
|
new_first = updates.first_name or teacher["first_name"]
|
||||||
|
new_last = updates.last_name or teacher["last_name"]
|
||||||
|
await conn.execute("UPDATE users SET name = $1 WHERE id = $2",
|
||||||
|
f"{new_first} {new_last}", teacher["user_id"])
|
||||||
|
|
||||||
|
# Fetch updated data
|
||||||
|
updated = await conn.fetchrow("""
|
||||||
|
SELECT t.id, t.user_id, t.teacher_code, t.title, t.first_name, t.last_name, t.is_active,
|
||||||
|
u.email, u.name
|
||||||
|
FROM teachers t
|
||||||
|
JOIN users u ON t.user_id = u.id
|
||||||
|
WHERE t.id = $1
|
||||||
|
""", teacher_id)
|
||||||
|
|
||||||
|
# Get roles
|
||||||
|
roles = await conn.fetch("""
|
||||||
|
SELECT role FROM role_assignments
|
||||||
|
WHERE user_id = $1 AND revoked_at IS NULL
|
||||||
|
AND (valid_to IS NULL OR valid_to > NOW())
|
||||||
|
""", updated["user_id"])
|
||||||
|
|
||||||
|
return TeacherResponse(
|
||||||
|
id=str(updated["id"]),
|
||||||
|
user_id=str(updated["user_id"]),
|
||||||
|
email=updated["email"],
|
||||||
|
name=updated["name"],
|
||||||
|
teacher_code=updated["teacher_code"],
|
||||||
|
title=updated["title"],
|
||||||
|
first_name=updated["first_name"],
|
||||||
|
last_name=updated["last_name"],
|
||||||
|
is_active=updated["is_active"],
|
||||||
|
roles=[r["role"] for r in roles]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/teachers/{teacher_id}")
|
||||||
|
async def deactivate_teacher(teacher_id: str, user: Dict[str, Any] = Depends(get_current_user)):
|
||||||
|
"""Deactivate a teacher (soft delete)"""
|
||||||
|
pool = await get_pool()
|
||||||
|
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
result = await conn.execute("""
|
||||||
|
UPDATE teachers SET is_active = false WHERE id = $1
|
||||||
|
""", teacher_id)
|
||||||
|
|
||||||
|
if result == "UPDATE 0":
|
||||||
|
raise HTTPException(status_code=404, detail="Teacher not found")
|
||||||
|
|
||||||
|
return {"status": "deactivated", "teacher_id": teacher_id}
|
||||||
|
|
||||||
|
|
||||||
# ==========================================
|
# ==========================================
|
||||||
# CUSTOM ROLE MANAGEMENT ENDPOINTS
|
# CUSTOM ROLE MANAGEMENT ENDPOINTS
|
||||||
# ==========================================
|
# ==========================================
|
||||||
|
|||||||
@@ -1,358 +0,0 @@
|
|||||||
"""
|
|
||||||
RBAC Teachers API - Teacher Management Endpoints
|
|
||||||
|
|
||||||
Provides API endpoints for:
|
|
||||||
- Listing all teachers with roles
|
|
||||||
- Getting teacher roles
|
|
||||||
- Getting teachers by role
|
|
||||||
- Creating, updating, deactivating teachers
|
|
||||||
|
|
||||||
Split from rbac_api.py for file-size compliance.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import uuid
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from typing import Dict, Any, List
|
|
||||||
from fastapi import APIRouter, HTTPException, Depends
|
|
||||||
|
|
||||||
from rbac_api import (
|
|
||||||
get_pool,
|
|
||||||
get_current_user,
|
|
||||||
TeacherCreate,
|
|
||||||
TeacherUpdate,
|
|
||||||
TeacherResponse,
|
|
||||||
RoleAssignmentResponse,
|
|
||||||
AVAILABLE_ROLES,
|
|
||||||
)
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/rbac", tags=["rbac"])
|
|
||||||
|
|
||||||
|
|
||||||
def _build_teacher_response(teacher_row, roles: List[str]) -> TeacherResponse:
|
|
||||||
"""Build a TeacherResponse from a DB row and a list of role strings."""
|
|
||||||
return TeacherResponse(
|
|
||||||
id=str(teacher_row["id"]),
|
|
||||||
user_id=str(teacher_row["user_id"]),
|
|
||||||
email=teacher_row["email"],
|
|
||||||
name=teacher_row["name"] or f"{teacher_row['first_name']} {teacher_row['last_name']}",
|
|
||||||
teacher_code=teacher_row["teacher_code"],
|
|
||||||
title=teacher_row["title"],
|
|
||||||
first_name=teacher_row["first_name"],
|
|
||||||
last_name=teacher_row["last_name"],
|
|
||||||
is_active=teacher_row["is_active"],
|
|
||||||
roles=roles,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _build_role_lookup(role_assignments) -> Dict[str, List[str]]:
|
|
||||||
"""Build a user_id -> [roles] lookup from role assignment rows."""
|
|
||||||
role_lookup: Dict[str, List[str]] = {}
|
|
||||||
for ra in role_assignments:
|
|
||||||
uid = str(ra["user_id"])
|
|
||||||
if uid not in role_lookup:
|
|
||||||
role_lookup[uid] = []
|
|
||||||
role_lookup[uid].append(ra["role"])
|
|
||||||
return role_lookup
|
|
||||||
|
|
||||||
|
|
||||||
# ==========================================
|
|
||||||
# TEACHER LISTING / QUERY ENDPOINTS
|
|
||||||
# ==========================================
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/teachers")
|
|
||||||
async def list_teachers(
|
|
||||||
user: Dict[str, Any] = Depends(get_current_user),
|
|
||||||
) -> List[TeacherResponse]:
|
|
||||||
"""List all teachers with their current roles"""
|
|
||||||
pool = await get_pool()
|
|
||||||
|
|
||||||
async with pool.acquire() as conn:
|
|
||||||
teachers = await conn.fetch("""
|
|
||||||
SELECT
|
|
||||||
t.id, t.user_id, t.teacher_code, t.title,
|
|
||||||
t.first_name, t.last_name, t.is_active,
|
|
||||||
u.email, u.name
|
|
||||||
FROM teachers t
|
|
||||||
JOIN users u ON t.user_id = u.id
|
|
||||||
WHERE t.school_id = 'a0000000-0000-0000-0000-000000000001'
|
|
||||||
ORDER BY t.last_name, t.first_name
|
|
||||||
""")
|
|
||||||
|
|
||||||
role_assignments = await conn.fetch("""
|
|
||||||
SELECT user_id, role
|
|
||||||
FROM role_assignments
|
|
||||||
WHERE tenant_id = 'a0000000-0000-0000-0000-000000000001'
|
|
||||||
AND revoked_at IS NULL
|
|
||||||
AND (valid_to IS NULL OR valid_to > NOW())
|
|
||||||
""")
|
|
||||||
|
|
||||||
role_lookup = _build_role_lookup(role_assignments)
|
|
||||||
|
|
||||||
return [
|
|
||||||
_build_teacher_response(t, role_lookup.get(str(t["user_id"]), []))
|
|
||||||
for t in teachers
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/teachers/{teacher_id}/roles")
|
|
||||||
async def get_teacher_roles(
|
|
||||||
teacher_id: str,
|
|
||||||
user: Dict[str, Any] = Depends(get_current_user),
|
|
||||||
) -> List[RoleAssignmentResponse]:
|
|
||||||
"""Get all role assignments for a specific teacher"""
|
|
||||||
pool = await get_pool()
|
|
||||||
|
|
||||||
async with pool.acquire() as conn:
|
|
||||||
teacher = await conn.fetchrow(
|
|
||||||
"SELECT user_id FROM teachers WHERE id = $1",
|
|
||||||
teacher_id,
|
|
||||||
)
|
|
||||||
if not teacher:
|
|
||||||
raise HTTPException(status_code=404, detail="Teacher not found")
|
|
||||||
|
|
||||||
assignments = await conn.fetch("""
|
|
||||||
SELECT id, user_id, role, resource_type, resource_id,
|
|
||||||
valid_from, valid_to, granted_at, revoked_at
|
|
||||||
FROM role_assignments
|
|
||||||
WHERE user_id = $1
|
|
||||||
ORDER BY granted_at DESC
|
|
||||||
""", teacher["user_id"])
|
|
||||||
|
|
||||||
return [
|
|
||||||
RoleAssignmentResponse(
|
|
||||||
id=str(a["id"]),
|
|
||||||
user_id=str(a["user_id"]),
|
|
||||||
role=a["role"],
|
|
||||||
resource_type=a["resource_type"],
|
|
||||||
resource_id=str(a["resource_id"]),
|
|
||||||
valid_from=a["valid_from"].isoformat() if a["valid_from"] else None,
|
|
||||||
valid_to=a["valid_to"].isoformat() if a["valid_to"] else None,
|
|
||||||
granted_at=a["granted_at"].isoformat() if a["granted_at"] else None,
|
|
||||||
is_active=a["revoked_at"] is None and (
|
|
||||||
a["valid_to"] is None or a["valid_to"] > datetime.now(timezone.utc)
|
|
||||||
),
|
|
||||||
)
|
|
||||||
for a in assignments
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/roles/{role}/teachers")
|
|
||||||
async def get_teachers_by_role(
|
|
||||||
role: str,
|
|
||||||
user: Dict[str, Any] = Depends(get_current_user),
|
|
||||||
) -> List[TeacherResponse]:
|
|
||||||
"""Get all teachers with a specific role"""
|
|
||||||
if role not in AVAILABLE_ROLES:
|
|
||||||
raise HTTPException(status_code=400, detail=f"Unknown role: {role}")
|
|
||||||
|
|
||||||
pool = await get_pool()
|
|
||||||
|
|
||||||
async with pool.acquire() as conn:
|
|
||||||
teachers = await conn.fetch("""
|
|
||||||
SELECT DISTINCT
|
|
||||||
t.id, t.user_id, t.teacher_code, t.title,
|
|
||||||
t.first_name, t.last_name, t.is_active,
|
|
||||||
u.email, u.name
|
|
||||||
FROM teachers t
|
|
||||||
JOIN users u ON t.user_id = u.id
|
|
||||||
JOIN role_assignments ra ON t.user_id = ra.user_id
|
|
||||||
WHERE ra.role = $1
|
|
||||||
AND ra.revoked_at IS NULL
|
|
||||||
AND (ra.valid_to IS NULL OR ra.valid_to > NOW())
|
|
||||||
AND t.school_id = 'a0000000-0000-0000-0000-000000000001'
|
|
||||||
ORDER BY t.last_name, t.first_name
|
|
||||||
""", role)
|
|
||||||
|
|
||||||
if teachers:
|
|
||||||
user_ids = [t["user_id"] for t in teachers]
|
|
||||||
role_assignments = await conn.fetch("""
|
|
||||||
SELECT user_id, role
|
|
||||||
FROM role_assignments
|
|
||||||
WHERE user_id = ANY($1)
|
|
||||||
AND revoked_at IS NULL
|
|
||||||
AND (valid_to IS NULL OR valid_to > NOW())
|
|
||||||
""", user_ids)
|
|
||||||
role_lookup = _build_role_lookup(role_assignments)
|
|
||||||
else:
|
|
||||||
role_lookup = {}
|
|
||||||
|
|
||||||
return [
|
|
||||||
_build_teacher_response(t, role_lookup.get(str(t["user_id"]), []))
|
|
||||||
for t in teachers
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
# ==========================================
|
|
||||||
# TEACHER CRUD ENDPOINTS
|
|
||||||
# ==========================================
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/teachers")
|
|
||||||
async def create_teacher(
|
|
||||||
teacher: TeacherCreate,
|
|
||||||
user: Dict[str, Any] = Depends(get_current_user),
|
|
||||||
) -> TeacherResponse:
|
|
||||||
"""Create a new teacher with optional initial roles"""
|
|
||||||
pool = await get_pool()
|
|
||||||
|
|
||||||
async with pool.acquire() as conn:
|
|
||||||
existing = await conn.fetchrow(
|
|
||||||
"SELECT id FROM users WHERE email = $1",
|
|
||||||
teacher.email,
|
|
||||||
)
|
|
||||||
if existing:
|
|
||||||
raise HTTPException(status_code=409, detail="Email already exists")
|
|
||||||
|
|
||||||
user_id = str(uuid.uuid4())
|
|
||||||
teacher_id = str(uuid.uuid4())
|
|
||||||
|
|
||||||
await conn.execute("""
|
|
||||||
INSERT INTO users (id, email, name, password_hash, role, is_active)
|
|
||||||
VALUES ($1, $2, $3, '', 'teacher', true)
|
|
||||||
""", user_id, teacher.email, f"{teacher.first_name} {teacher.last_name}")
|
|
||||||
|
|
||||||
await conn.execute("""
|
|
||||||
INSERT INTO teachers (id, user_id, school_id, first_name, last_name, teacher_code, title, is_active)
|
|
||||||
VALUES ($1, $2, 'a0000000-0000-0000-0000-000000000001', $3, $4, $5, $6, true)
|
|
||||||
""", teacher_id, user_id, teacher.first_name, teacher.last_name,
|
|
||||||
teacher.teacher_code, teacher.title)
|
|
||||||
|
|
||||||
assigned_roles = []
|
|
||||||
for role in teacher.roles:
|
|
||||||
if role in AVAILABLE_ROLES or await conn.fetchrow(
|
|
||||||
"SELECT 1 FROM custom_roles WHERE role_key = $1 AND is_active = true", role
|
|
||||||
):
|
|
||||||
await conn.execute("""
|
|
||||||
INSERT INTO role_assignments (user_id, role, resource_type, resource_id, tenant_id, granted_by)
|
|
||||||
VALUES ($1, $2, 'tenant', 'a0000000-0000-0000-0000-000000000001',
|
|
||||||
'a0000000-0000-0000-0000-000000000001', $3)
|
|
||||||
""", user_id, role, user.get("user_id"))
|
|
||||||
assigned_roles.append(role)
|
|
||||||
|
|
||||||
return TeacherResponse(
|
|
||||||
id=teacher_id,
|
|
||||||
user_id=user_id,
|
|
||||||
email=teacher.email,
|
|
||||||
name=f"{teacher.first_name} {teacher.last_name}",
|
|
||||||
teacher_code=teacher.teacher_code,
|
|
||||||
title=teacher.title,
|
|
||||||
first_name=teacher.first_name,
|
|
||||||
last_name=teacher.last_name,
|
|
||||||
is_active=True,
|
|
||||||
roles=assigned_roles,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/teachers/{teacher_id}")
|
|
||||||
async def update_teacher(
|
|
||||||
teacher_id: str,
|
|
||||||
updates: TeacherUpdate,
|
|
||||||
user: Dict[str, Any] = Depends(get_current_user),
|
|
||||||
) -> TeacherResponse:
|
|
||||||
"""Update teacher information"""
|
|
||||||
pool = await get_pool()
|
|
||||||
|
|
||||||
async with pool.acquire() as conn:
|
|
||||||
teacher = await conn.fetchrow("""
|
|
||||||
SELECT t.id, t.user_id, t.teacher_code, t.title, t.first_name, t.last_name, t.is_active,
|
|
||||||
u.email, u.name
|
|
||||||
FROM teachers t
|
|
||||||
JOIN users u ON t.user_id = u.id
|
|
||||||
WHERE t.id = $1
|
|
||||||
""", teacher_id)
|
|
||||||
|
|
||||||
if not teacher:
|
|
||||||
raise HTTPException(status_code=404, detail="Teacher not found")
|
|
||||||
|
|
||||||
if updates.email:
|
|
||||||
await conn.execute(
|
|
||||||
"UPDATE users SET email = $1 WHERE id = $2",
|
|
||||||
updates.email, teacher["user_id"],
|
|
||||||
)
|
|
||||||
|
|
||||||
teacher_updates = []
|
|
||||||
teacher_values = []
|
|
||||||
idx = 1
|
|
||||||
|
|
||||||
if updates.first_name:
|
|
||||||
teacher_updates.append(f"first_name = ${idx}")
|
|
||||||
teacher_values.append(updates.first_name)
|
|
||||||
idx += 1
|
|
||||||
if updates.last_name:
|
|
||||||
teacher_updates.append(f"last_name = ${idx}")
|
|
||||||
teacher_values.append(updates.last_name)
|
|
||||||
idx += 1
|
|
||||||
if updates.teacher_code is not None:
|
|
||||||
teacher_updates.append(f"teacher_code = ${idx}")
|
|
||||||
teacher_values.append(updates.teacher_code)
|
|
||||||
idx += 1
|
|
||||||
if updates.title is not None:
|
|
||||||
teacher_updates.append(f"title = ${idx}")
|
|
||||||
teacher_values.append(updates.title)
|
|
||||||
idx += 1
|
|
||||||
if updates.is_active is not None:
|
|
||||||
teacher_updates.append(f"is_active = ${idx}")
|
|
||||||
teacher_values.append(updates.is_active)
|
|
||||||
idx += 1
|
|
||||||
|
|
||||||
if teacher_updates:
|
|
||||||
teacher_values.append(teacher_id)
|
|
||||||
await conn.execute(
|
|
||||||
f"UPDATE teachers SET {', '.join(teacher_updates)} WHERE id = ${idx}",
|
|
||||||
*teacher_values,
|
|
||||||
)
|
|
||||||
|
|
||||||
if updates.first_name or updates.last_name:
|
|
||||||
new_first = updates.first_name or teacher["first_name"]
|
|
||||||
new_last = updates.last_name or teacher["last_name"]
|
|
||||||
await conn.execute(
|
|
||||||
"UPDATE users SET name = $1 WHERE id = $2",
|
|
||||||
f"{new_first} {new_last}", teacher["user_id"],
|
|
||||||
)
|
|
||||||
|
|
||||||
updated = await conn.fetchrow("""
|
|
||||||
SELECT t.id, t.user_id, t.teacher_code, t.title, t.first_name, t.last_name, t.is_active,
|
|
||||||
u.email, u.name
|
|
||||||
FROM teachers t
|
|
||||||
JOIN users u ON t.user_id = u.id
|
|
||||||
WHERE t.id = $1
|
|
||||||
""", teacher_id)
|
|
||||||
|
|
||||||
roles = await conn.fetch("""
|
|
||||||
SELECT role FROM role_assignments
|
|
||||||
WHERE user_id = $1 AND revoked_at IS NULL
|
|
||||||
AND (valid_to IS NULL OR valid_to > NOW())
|
|
||||||
""", updated["user_id"])
|
|
||||||
|
|
||||||
return TeacherResponse(
|
|
||||||
id=str(updated["id"]),
|
|
||||||
user_id=str(updated["user_id"]),
|
|
||||||
email=updated["email"],
|
|
||||||
name=updated["name"],
|
|
||||||
teacher_code=updated["teacher_code"],
|
|
||||||
title=updated["title"],
|
|
||||||
first_name=updated["first_name"],
|
|
||||||
last_name=updated["last_name"],
|
|
||||||
is_active=updated["is_active"],
|
|
||||||
roles=[r["role"] for r in roles],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/teachers/{teacher_id}")
|
|
||||||
async def deactivate_teacher(
|
|
||||||
teacher_id: str,
|
|
||||||
user: Dict[str, Any] = Depends(get_current_user),
|
|
||||||
):
|
|
||||||
"""Deactivate a teacher (soft delete)"""
|
|
||||||
pool = await get_pool()
|
|
||||||
|
|
||||||
async with pool.acquire() as conn:
|
|
||||||
result = await conn.execute("""
|
|
||||||
UPDATE teachers SET is_active = false WHERE id = $1
|
|
||||||
""", teacher_id)
|
|
||||||
|
|
||||||
if result == "UPDATE 0":
|
|
||||||
raise HTTPException(status_code=404, detail="Teacher not found")
|
|
||||||
|
|
||||||
return {"status": "deactivated", "teacher_id": teacher_id}
|
|
||||||
+750
-126
@@ -13,47 +13,312 @@ Features:
|
|||||||
- Fuehrt Security-Scans via subprocess aus
|
- Fuehrt Security-Scans via subprocess aus
|
||||||
- Parst Gitleaks, Semgrep, Trivy, Grype JSON-Reports
|
- Parst Gitleaks, Semgrep, Trivy, Grype JSON-Reports
|
||||||
- Generiert SBOM mit Syft
|
- Generiert SBOM mit Syft
|
||||||
|
|
||||||
Split structure:
|
|
||||||
- security_models.py — Pydantic models
|
|
||||||
- security_report_parsers.py — Report parsing, tool detection, aggregation
|
|
||||||
- security_mock_data.py — Mock data generators + /demo/* endpoints
|
|
||||||
- security_monitoring.py — /monitoring/* endpoints (logs, metrics, containers)
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
import json
|
import json
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import asyncio
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import List, Optional
|
from pathlib import Path
|
||||||
|
from typing import List, Dict, Any, Optional
|
||||||
from fastapi import APIRouter, HTTPException, BackgroundTasks
|
from fastapi import APIRouter, HTTPException, BackgroundTasks
|
||||||
|
from pydantic import BaseModel
|
||||||
from security_models import (
|
|
||||||
ToolStatus,
|
|
||||||
Finding,
|
|
||||||
SeveritySummary,
|
|
||||||
HistoryItem,
|
|
||||||
)
|
|
||||||
from security_report_parsers import (
|
|
||||||
REPORTS_DIR,
|
|
||||||
PROJECT_ROOT,
|
|
||||||
check_tool_installed,
|
|
||||||
get_latest_report,
|
|
||||||
get_all_findings,
|
|
||||||
calculate_summary,
|
|
||||||
)
|
|
||||||
from security_mock_data import (
|
|
||||||
get_mock_findings,
|
|
||||||
get_mock_sbom_data,
|
|
||||||
get_mock_history,
|
|
||||||
router as mock_data_router,
|
|
||||||
)
|
|
||||||
from security_monitoring import router as monitoring_router
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/v1/security", tags=["Security"])
|
router = APIRouter(prefix="/v1/security", tags=["Security"])
|
||||||
|
|
||||||
# Include sub-routers (they share the same prefix/tags)
|
# Pfade - innerhalb des Backend-Verzeichnisses
|
||||||
router.include_router(mock_data_router, prefix="", tags=["Security"])
|
# In Docker: /app/security-reports, /app/scripts
|
||||||
router.include_router(monitoring_router, prefix="", tags=["Security"])
|
# Lokal: backend/security-reports, backend/scripts
|
||||||
|
BACKEND_DIR = Path(__file__).parent
|
||||||
|
REPORTS_DIR = BACKEND_DIR / "security-reports"
|
||||||
|
SCRIPTS_DIR = BACKEND_DIR / "scripts"
|
||||||
|
|
||||||
|
# Projekt-Root fuer Security-Scans
|
||||||
|
PROJECT_ROOT = BACKEND_DIR
|
||||||
|
|
||||||
|
# Sicherstellen, dass das Reports-Verzeichnis existiert
|
||||||
|
try:
|
||||||
|
REPORTS_DIR.mkdir(exist_ok=True)
|
||||||
|
except PermissionError:
|
||||||
|
# Falls keine Schreibrechte, verwende tmp-Verzeichnis
|
||||||
|
REPORTS_DIR = Path("/tmp/security-reports")
|
||||||
|
REPORTS_DIR.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================
|
||||||
|
# Pydantic Models
|
||||||
|
# ===========================
|
||||||
|
|
||||||
|
class ToolStatus(BaseModel):
|
||||||
|
name: str
|
||||||
|
installed: bool
|
||||||
|
version: Optional[str] = None
|
||||||
|
last_run: Optional[str] = None
|
||||||
|
last_findings: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class Finding(BaseModel):
|
||||||
|
id: str
|
||||||
|
tool: str
|
||||||
|
severity: str
|
||||||
|
title: str
|
||||||
|
message: Optional[str] = None
|
||||||
|
file: Optional[str] = None
|
||||||
|
line: Optional[int] = None
|
||||||
|
found_at: str
|
||||||
|
|
||||||
|
|
||||||
|
class SeveritySummary(BaseModel):
|
||||||
|
critical: int = 0
|
||||||
|
high: int = 0
|
||||||
|
medium: int = 0
|
||||||
|
low: int = 0
|
||||||
|
info: int = 0
|
||||||
|
total: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class ScanResult(BaseModel):
|
||||||
|
tool: str
|
||||||
|
status: str
|
||||||
|
started_at: str
|
||||||
|
completed_at: Optional[str] = None
|
||||||
|
findings_count: int = 0
|
||||||
|
report_path: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class HistoryItem(BaseModel):
|
||||||
|
timestamp: str
|
||||||
|
title: str
|
||||||
|
description: str
|
||||||
|
status: str # success, warning, error
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================
|
||||||
|
# Utility Functions
|
||||||
|
# ===========================
|
||||||
|
|
||||||
|
def check_tool_installed(tool_name: str) -> tuple[bool, Optional[str]]:
|
||||||
|
"""Prueft, ob ein Tool installiert ist und gibt die Version zurueck."""
|
||||||
|
try:
|
||||||
|
if tool_name == "gitleaks":
|
||||||
|
result = subprocess.run(["gitleaks", "version"], capture_output=True, text=True, timeout=5)
|
||||||
|
if result.returncode == 0:
|
||||||
|
return True, result.stdout.strip()
|
||||||
|
elif tool_name == "semgrep":
|
||||||
|
result = subprocess.run(["semgrep", "--version"], capture_output=True, text=True, timeout=5)
|
||||||
|
if result.returncode == 0:
|
||||||
|
return True, result.stdout.strip().split('\n')[0]
|
||||||
|
elif tool_name == "bandit":
|
||||||
|
result = subprocess.run(["bandit", "--version"], capture_output=True, text=True, timeout=5)
|
||||||
|
if result.returncode == 0:
|
||||||
|
return True, result.stdout.strip()
|
||||||
|
elif tool_name == "trivy":
|
||||||
|
result = subprocess.run(["trivy", "version"], capture_output=True, text=True, timeout=5)
|
||||||
|
if result.returncode == 0:
|
||||||
|
# Parse "Version: 0.48.x"
|
||||||
|
for line in result.stdout.split('\n'):
|
||||||
|
if line.startswith('Version:'):
|
||||||
|
return True, line.split(':')[1].strip()
|
||||||
|
return True, result.stdout.strip().split('\n')[0]
|
||||||
|
elif tool_name == "grype":
|
||||||
|
result = subprocess.run(["grype", "version"], capture_output=True, text=True, timeout=5)
|
||||||
|
if result.returncode == 0:
|
||||||
|
return True, result.stdout.strip().split('\n')[0]
|
||||||
|
elif tool_name == "syft":
|
||||||
|
result = subprocess.run(["syft", "version"], capture_output=True, text=True, timeout=5)
|
||||||
|
if result.returncode == 0:
|
||||||
|
return True, result.stdout.strip().split('\n')[0]
|
||||||
|
except (subprocess.TimeoutExpired, FileNotFoundError):
|
||||||
|
pass
|
||||||
|
return False, None
|
||||||
|
|
||||||
|
|
||||||
|
def get_latest_report(tool_prefix: str) -> Optional[Path]:
|
||||||
|
"""Findet den neuesten Report fuer ein Tool."""
|
||||||
|
if not REPORTS_DIR.exists():
|
||||||
|
return None
|
||||||
|
|
||||||
|
reports = list(REPORTS_DIR.glob(f"{tool_prefix}*.json"))
|
||||||
|
if not reports:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return max(reports, key=lambda p: p.stat().st_mtime)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_gitleaks_report(report_path: Path) -> List[Finding]:
|
||||||
|
"""Parst Gitleaks JSON Report."""
|
||||||
|
findings = []
|
||||||
|
try:
|
||||||
|
with open(report_path) as f:
|
||||||
|
data = json.load(f)
|
||||||
|
if isinstance(data, list):
|
||||||
|
for item in data:
|
||||||
|
findings.append(Finding(
|
||||||
|
id=item.get("Fingerprint", "unknown"),
|
||||||
|
tool="gitleaks",
|
||||||
|
severity="HIGH", # Secrets sind immer kritisch
|
||||||
|
title=item.get("Description", "Secret detected"),
|
||||||
|
message=f"Rule: {item.get('RuleID', 'unknown')}",
|
||||||
|
file=item.get("File", ""),
|
||||||
|
line=item.get("StartLine", 0),
|
||||||
|
found_at=datetime.fromtimestamp(report_path.stat().st_mtime).isoformat()
|
||||||
|
))
|
||||||
|
except (json.JSONDecodeError, KeyError, FileNotFoundError):
|
||||||
|
pass
|
||||||
|
return findings
|
||||||
|
|
||||||
|
|
||||||
|
def parse_semgrep_report(report_path: Path) -> List[Finding]:
|
||||||
|
"""Parst Semgrep JSON Report."""
|
||||||
|
findings = []
|
||||||
|
try:
|
||||||
|
with open(report_path) as f:
|
||||||
|
data = json.load(f)
|
||||||
|
results = data.get("results", [])
|
||||||
|
for item in results:
|
||||||
|
severity = item.get("extra", {}).get("severity", "INFO").upper()
|
||||||
|
findings.append(Finding(
|
||||||
|
id=item.get("check_id", "unknown"),
|
||||||
|
tool="semgrep",
|
||||||
|
severity=severity,
|
||||||
|
title=item.get("extra", {}).get("message", "Finding"),
|
||||||
|
message=item.get("check_id", ""),
|
||||||
|
file=item.get("path", ""),
|
||||||
|
line=item.get("start", {}).get("line", 0),
|
||||||
|
found_at=datetime.fromtimestamp(report_path.stat().st_mtime).isoformat()
|
||||||
|
))
|
||||||
|
except (json.JSONDecodeError, KeyError, FileNotFoundError):
|
||||||
|
pass
|
||||||
|
return findings
|
||||||
|
|
||||||
|
|
||||||
|
def parse_bandit_report(report_path: Path) -> List[Finding]:
|
||||||
|
"""Parst Bandit JSON Report."""
|
||||||
|
findings = []
|
||||||
|
try:
|
||||||
|
with open(report_path) as f:
|
||||||
|
data = json.load(f)
|
||||||
|
results = data.get("results", [])
|
||||||
|
for item in results:
|
||||||
|
severity = item.get("issue_severity", "LOW").upper()
|
||||||
|
findings.append(Finding(
|
||||||
|
id=item.get("test_id", "unknown"),
|
||||||
|
tool="bandit",
|
||||||
|
severity=severity,
|
||||||
|
title=item.get("issue_text", "Finding"),
|
||||||
|
message=f"CWE: {item.get('issue_cwe', {}).get('id', 'N/A')}",
|
||||||
|
file=item.get("filename", ""),
|
||||||
|
line=item.get("line_number", 0),
|
||||||
|
found_at=datetime.fromtimestamp(report_path.stat().st_mtime).isoformat()
|
||||||
|
))
|
||||||
|
except (json.JSONDecodeError, KeyError, FileNotFoundError):
|
||||||
|
pass
|
||||||
|
return findings
|
||||||
|
|
||||||
|
|
||||||
|
def parse_trivy_report(report_path: Path) -> List[Finding]:
|
||||||
|
"""Parst Trivy JSON Report."""
|
||||||
|
findings = []
|
||||||
|
try:
|
||||||
|
with open(report_path) as f:
|
||||||
|
data = json.load(f)
|
||||||
|
results = data.get("Results", [])
|
||||||
|
for result in results:
|
||||||
|
vulnerabilities = result.get("Vulnerabilities", []) or []
|
||||||
|
target = result.get("Target", "")
|
||||||
|
for vuln in vulnerabilities:
|
||||||
|
severity = vuln.get("Severity", "UNKNOWN").upper()
|
||||||
|
findings.append(Finding(
|
||||||
|
id=vuln.get("VulnerabilityID", "unknown"),
|
||||||
|
tool="trivy",
|
||||||
|
severity=severity,
|
||||||
|
title=vuln.get("Title", vuln.get("VulnerabilityID", "CVE")),
|
||||||
|
message=f"{vuln.get('PkgName', '')} {vuln.get('InstalledVersion', '')}",
|
||||||
|
file=target,
|
||||||
|
line=None,
|
||||||
|
found_at=datetime.fromtimestamp(report_path.stat().st_mtime).isoformat()
|
||||||
|
))
|
||||||
|
except (json.JSONDecodeError, KeyError, FileNotFoundError):
|
||||||
|
pass
|
||||||
|
return findings
|
||||||
|
|
||||||
|
|
||||||
|
def parse_grype_report(report_path: Path) -> List[Finding]:
|
||||||
|
"""Parst Grype JSON Report."""
|
||||||
|
findings = []
|
||||||
|
try:
|
||||||
|
with open(report_path) as f:
|
||||||
|
data = json.load(f)
|
||||||
|
matches = data.get("matches", [])
|
||||||
|
for match in matches:
|
||||||
|
vuln = match.get("vulnerability", {})
|
||||||
|
artifact = match.get("artifact", {})
|
||||||
|
severity = vuln.get("severity", "Unknown").upper()
|
||||||
|
findings.append(Finding(
|
||||||
|
id=vuln.get("id", "unknown"),
|
||||||
|
tool="grype",
|
||||||
|
severity=severity,
|
||||||
|
title=vuln.get("description", vuln.get("id", "CVE"))[:100],
|
||||||
|
message=f"{artifact.get('name', '')} {artifact.get('version', '')}",
|
||||||
|
file=artifact.get("locations", [{}])[0].get("path", "") if artifact.get("locations") else "",
|
||||||
|
line=None,
|
||||||
|
found_at=datetime.fromtimestamp(report_path.stat().st_mtime).isoformat()
|
||||||
|
))
|
||||||
|
except (json.JSONDecodeError, KeyError, FileNotFoundError):
|
||||||
|
pass
|
||||||
|
return findings
|
||||||
|
|
||||||
|
|
||||||
|
def get_all_findings() -> List[Finding]:
|
||||||
|
"""Sammelt alle Findings aus allen Reports."""
|
||||||
|
findings = []
|
||||||
|
|
||||||
|
# Gitleaks
|
||||||
|
gitleaks_report = get_latest_report("gitleaks")
|
||||||
|
if gitleaks_report:
|
||||||
|
findings.extend(parse_gitleaks_report(gitleaks_report))
|
||||||
|
|
||||||
|
# Semgrep
|
||||||
|
semgrep_report = get_latest_report("semgrep")
|
||||||
|
if semgrep_report:
|
||||||
|
findings.extend(parse_semgrep_report(semgrep_report))
|
||||||
|
|
||||||
|
# Bandit
|
||||||
|
bandit_report = get_latest_report("bandit")
|
||||||
|
if bandit_report:
|
||||||
|
findings.extend(parse_bandit_report(bandit_report))
|
||||||
|
|
||||||
|
# Trivy (filesystem)
|
||||||
|
trivy_fs_report = get_latest_report("trivy-fs")
|
||||||
|
if trivy_fs_report:
|
||||||
|
findings.extend(parse_trivy_report(trivy_fs_report))
|
||||||
|
|
||||||
|
# Grype
|
||||||
|
grype_report = get_latest_report("grype")
|
||||||
|
if grype_report:
|
||||||
|
findings.extend(parse_grype_report(grype_report))
|
||||||
|
|
||||||
|
return findings
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_summary(findings: List[Finding]) -> SeveritySummary:
|
||||||
|
"""Berechnet die Severity-Zusammenfassung."""
|
||||||
|
summary = SeveritySummary()
|
||||||
|
for finding in findings:
|
||||||
|
severity = finding.severity.upper()
|
||||||
|
if severity == "CRITICAL":
|
||||||
|
summary.critical += 1
|
||||||
|
elif severity == "HIGH":
|
||||||
|
summary.high += 1
|
||||||
|
elif severity == "MEDIUM":
|
||||||
|
summary.medium += 1
|
||||||
|
elif severity == "LOW":
|
||||||
|
summary.low += 1
|
||||||
|
else:
|
||||||
|
summary.info += 1
|
||||||
|
summary.total = len(findings)
|
||||||
|
return summary
|
||||||
|
|
||||||
|
|
||||||
# ===========================
|
# ===========================
|
||||||
@@ -170,15 +435,11 @@ async def get_history(limit: int = 20):
|
|||||||
if isinstance(data, list):
|
if isinstance(data, list):
|
||||||
findings_count = len(data)
|
findings_count = len(data)
|
||||||
elif isinstance(data, dict):
|
elif isinstance(data, dict):
|
||||||
findings_count = (
|
findings_count = len(data.get("results", [])) or len(data.get("matches", [])) or len(data.get("Results", []))
|
||||||
len(data.get("results", []))
|
|
||||||
or len(data.get("matches", []))
|
|
||||||
or len(data.get("Results", []))
|
|
||||||
)
|
|
||||||
|
|
||||||
if findings_count > 0:
|
if findings_count > 0:
|
||||||
status = "warning"
|
status = "warning"
|
||||||
except Exception:
|
except:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
history.append(HistoryItem(
|
history.append(HistoryItem(
|
||||||
@@ -232,19 +493,97 @@ async def run_scan(scan_type: str, background_tasks: BackgroundTasks):
|
|||||||
|
|
||||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
|
|
||||||
async def run_scan_async(st: str):
|
async def run_scan_async(scan_type: str):
|
||||||
"""Fuehrt den Scan asynchron aus."""
|
"""Fuehrt den Scan asynchron aus."""
|
||||||
try:
|
try:
|
||||||
if st in ("secrets", "all"):
|
if scan_type == "secrets" or scan_type == "all":
|
||||||
_run_secrets_scan(timestamp)
|
# Gitleaks
|
||||||
if st in ("sast", "all"):
|
installed, _ = check_tool_installed("gitleaks")
|
||||||
_run_sast_scan(timestamp)
|
if installed:
|
||||||
if st in ("deps", "all"):
|
subprocess.run(
|
||||||
_run_deps_scan(timestamp)
|
["gitleaks", "detect", "--source", str(PROJECT_ROOT),
|
||||||
if st in ("sbom", "all"):
|
"--config", str(PROJECT_ROOT / ".gitleaks.toml"),
|
||||||
_run_sbom_scan(timestamp)
|
"--report-path", str(REPORTS_DIR / f"gitleaks-{timestamp}.json"),
|
||||||
if st in ("containers", "all"):
|
"--report-format", "json"],
|
||||||
_run_container_scan(timestamp)
|
capture_output=True,
|
||||||
|
timeout=300
|
||||||
|
)
|
||||||
|
|
||||||
|
if scan_type == "sast" or scan_type == "all":
|
||||||
|
# Semgrep
|
||||||
|
installed, _ = check_tool_installed("semgrep")
|
||||||
|
if installed:
|
||||||
|
subprocess.run(
|
||||||
|
["semgrep", "scan", "--config", "auto",
|
||||||
|
"--config", str(PROJECT_ROOT / ".semgrep.yml"),
|
||||||
|
"--json", "--output", str(REPORTS_DIR / f"semgrep-{timestamp}.json")],
|
||||||
|
capture_output=True,
|
||||||
|
timeout=600,
|
||||||
|
cwd=str(PROJECT_ROOT)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Bandit
|
||||||
|
installed, _ = check_tool_installed("bandit")
|
||||||
|
if installed:
|
||||||
|
subprocess.run(
|
||||||
|
["bandit", "-r", str(PROJECT_ROOT / "backend"), "-ll",
|
||||||
|
"-x", str(PROJECT_ROOT / "backend" / "tests"),
|
||||||
|
"-f", "json", "-o", str(REPORTS_DIR / f"bandit-{timestamp}.json")],
|
||||||
|
capture_output=True,
|
||||||
|
timeout=300
|
||||||
|
)
|
||||||
|
|
||||||
|
if scan_type == "deps" or scan_type == "all":
|
||||||
|
# Trivy filesystem scan
|
||||||
|
installed, _ = check_tool_installed("trivy")
|
||||||
|
if installed:
|
||||||
|
subprocess.run(
|
||||||
|
["trivy", "fs", str(PROJECT_ROOT),
|
||||||
|
"--config", str(PROJECT_ROOT / ".trivy.yaml"),
|
||||||
|
"--format", "json",
|
||||||
|
"--output", str(REPORTS_DIR / f"trivy-fs-{timestamp}.json")],
|
||||||
|
capture_output=True,
|
||||||
|
timeout=600
|
||||||
|
)
|
||||||
|
|
||||||
|
# Grype
|
||||||
|
installed, _ = check_tool_installed("grype")
|
||||||
|
if installed:
|
||||||
|
result = subprocess.run(
|
||||||
|
["grype", f"dir:{PROJECT_ROOT}", "-o", "json"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=600
|
||||||
|
)
|
||||||
|
if result.stdout:
|
||||||
|
with open(REPORTS_DIR / f"grype-{timestamp}.json", "w") as f:
|
||||||
|
f.write(result.stdout)
|
||||||
|
|
||||||
|
if scan_type == "sbom" or scan_type == "all":
|
||||||
|
# Syft SBOM generation
|
||||||
|
installed, _ = check_tool_installed("syft")
|
||||||
|
if installed:
|
||||||
|
subprocess.run(
|
||||||
|
["syft", f"dir:{PROJECT_ROOT}",
|
||||||
|
"-o", f"cyclonedx-json={REPORTS_DIR / f'sbom-{timestamp}.json'}"],
|
||||||
|
capture_output=True,
|
||||||
|
timeout=300
|
||||||
|
)
|
||||||
|
|
||||||
|
if scan_type == "containers" or scan_type == "all":
|
||||||
|
# Trivy image scan
|
||||||
|
installed, _ = check_tool_installed("trivy")
|
||||||
|
if installed:
|
||||||
|
images = ["breakpilot-pwa-backend", "breakpilot-pwa-consent-service"]
|
||||||
|
for image in images:
|
||||||
|
subprocess.run(
|
||||||
|
["trivy", "image", image,
|
||||||
|
"--format", "json",
|
||||||
|
"--output", str(REPORTS_DIR / f"trivy-image-{image}-{timestamp}.json")],
|
||||||
|
capture_output=True,
|
||||||
|
timeout=600
|
||||||
|
)
|
||||||
|
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
pass
|
pass
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -280,95 +619,380 @@ async def health_check():
|
|||||||
|
|
||||||
|
|
||||||
# ===========================
|
# ===========================
|
||||||
# Scan Helper Functions
|
# Mock Data for Demo/Development
|
||||||
# ===========================
|
# ===========================
|
||||||
|
|
||||||
def _run_secrets_scan(timestamp: str):
|
def get_mock_sbom_data() -> Dict[str, Any]:
|
||||||
"""Gitleaks scan."""
|
"""Generiert realistische Mock-SBOM-Daten basierend auf requirements.txt."""
|
||||||
installed, _ = check_tool_installed("gitleaks")
|
return {
|
||||||
if installed:
|
"bomFormat": "CycloneDX",
|
||||||
subprocess.run(
|
"specVersion": "1.4",
|
||||||
["gitleaks", "detect", "--source", str(PROJECT_ROOT),
|
"version": 1,
|
||||||
"--config", str(PROJECT_ROOT / ".gitleaks.toml"),
|
"metadata": {
|
||||||
"--report-path", str(REPORTS_DIR / f"gitleaks-{timestamp}.json"),
|
"timestamp": datetime.now().isoformat(),
|
||||||
"--report-format", "json"],
|
"tools": [{"vendor": "BreakPilot", "name": "DevSecOps", "version": "1.0.0"}],
|
||||||
capture_output=True,
|
"component": {
|
||||||
timeout=300
|
"type": "application",
|
||||||
)
|
"name": "breakpilot-pwa",
|
||||||
|
"version": "2.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"components": [
|
||||||
|
{"type": "library", "name": "fastapi", "version": "0.109.0", "purl": "pkg:pypi/fastapi@0.109.0", "licenses": [{"license": {"id": "MIT"}}]},
|
||||||
|
{"type": "library", "name": "uvicorn", "version": "0.27.0", "purl": "pkg:pypi/uvicorn@0.27.0", "licenses": [{"license": {"id": "BSD-3-Clause"}}]},
|
||||||
|
{"type": "library", "name": "pydantic", "version": "2.5.3", "purl": "pkg:pypi/pydantic@2.5.3", "licenses": [{"license": {"id": "MIT"}}]},
|
||||||
|
{"type": "library", "name": "httpx", "version": "0.26.0", "purl": "pkg:pypi/httpx@0.26.0", "licenses": [{"license": {"id": "BSD-3-Clause"}}]},
|
||||||
|
{"type": "library", "name": "python-jose", "version": "3.3.0", "purl": "pkg:pypi/python-jose@3.3.0", "licenses": [{"license": {"id": "MIT"}}]},
|
||||||
|
{"type": "library", "name": "passlib", "version": "1.7.4", "purl": "pkg:pypi/passlib@1.7.4", "licenses": [{"license": {"id": "BSD-3-Clause"}}]},
|
||||||
|
{"type": "library", "name": "bcrypt", "version": "4.1.2", "purl": "pkg:pypi/bcrypt@4.1.2", "licenses": [{"license": {"id": "Apache-2.0"}}]},
|
||||||
|
{"type": "library", "name": "psycopg2-binary", "version": "2.9.9", "purl": "pkg:pypi/psycopg2-binary@2.9.9", "licenses": [{"license": {"id": "LGPL-3.0"}}]},
|
||||||
|
{"type": "library", "name": "sqlalchemy", "version": "2.0.25", "purl": "pkg:pypi/sqlalchemy@2.0.25", "licenses": [{"license": {"id": "MIT"}}]},
|
||||||
|
{"type": "library", "name": "alembic", "version": "1.13.1", "purl": "pkg:pypi/alembic@1.13.1", "licenses": [{"license": {"id": "MIT"}}]},
|
||||||
|
{"type": "library", "name": "weasyprint", "version": "60.2", "purl": "pkg:pypi/weasyprint@60.2", "licenses": [{"license": {"id": "BSD-3-Clause"}}]},
|
||||||
|
{"type": "library", "name": "jinja2", "version": "3.1.3", "purl": "pkg:pypi/jinja2@3.1.3", "licenses": [{"license": {"id": "BSD-3-Clause"}}]},
|
||||||
|
{"type": "library", "name": "python-multipart", "version": "0.0.6", "purl": "pkg:pypi/python-multipart@0.0.6", "licenses": [{"license": {"id": "Apache-2.0"}}]},
|
||||||
|
{"type": "library", "name": "aiofiles", "version": "23.2.1", "purl": "pkg:pypi/aiofiles@23.2.1", "licenses": [{"license": {"id": "Apache-2.0"}}]},
|
||||||
|
{"type": "library", "name": "pytest", "version": "7.4.4", "purl": "pkg:pypi/pytest@7.4.4", "licenses": [{"license": {"id": "MIT"}}]},
|
||||||
|
{"type": "library", "name": "pytest-asyncio", "version": "0.23.3", "purl": "pkg:pypi/pytest-asyncio@0.23.3", "licenses": [{"license": {"id": "Apache-2.0"}}]},
|
||||||
|
{"type": "library", "name": "anthropic", "version": "0.18.1", "purl": "pkg:pypi/anthropic@0.18.1", "licenses": [{"license": {"id": "MIT"}}]},
|
||||||
|
{"type": "library", "name": "openai", "version": "1.12.0", "purl": "pkg:pypi/openai@1.12.0", "licenses": [{"license": {"id": "MIT"}}]},
|
||||||
|
{"type": "library", "name": "langchain", "version": "0.1.6", "purl": "pkg:pypi/langchain@0.1.6", "licenses": [{"license": {"id": "MIT"}}]},
|
||||||
|
{"type": "library", "name": "chromadb", "version": "0.4.22", "purl": "pkg:pypi/chromadb@0.4.22", "licenses": [{"license": {"id": "Apache-2.0"}}]},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _run_sast_scan(timestamp: str):
|
def get_mock_findings() -> List[Finding]:
|
||||||
"""Semgrep + Bandit scan."""
|
"""Generiert Mock-Findings fuer Demo wenn keine echten Scan-Ergebnisse vorhanden."""
|
||||||
installed, _ = check_tool_installed("semgrep")
|
# Alle kritischen Findings wurden behoben:
|
||||||
if installed:
|
# - idna >= 3.7 gepinnt (CVE-2024-3651)
|
||||||
subprocess.run(
|
# - cryptography >= 42.0.0 gepinnt (GHSA-h4gh-qq45-vh27)
|
||||||
["semgrep", "scan", "--config", "auto",
|
# - jinja2 3.1.6 installiert (CVE-2024-34064)
|
||||||
"--config", str(PROJECT_ROOT / ".semgrep.yml"),
|
# - .env.example Placeholders verbessert
|
||||||
"--json", "--output", str(REPORTS_DIR / f"semgrep-{timestamp}.json")],
|
# - Keine shell=True Verwendung im Code
|
||||||
capture_output=True,
|
return [
|
||||||
timeout=600,
|
Finding(
|
||||||
cwd=str(PROJECT_ROOT)
|
id="info-scan-complete",
|
||||||
)
|
tool="system",
|
||||||
|
severity="INFO",
|
||||||
installed, _ = check_tool_installed("bandit")
|
title="Letzte Sicherheitspruefung erfolgreich",
|
||||||
if installed:
|
message="Keine kritischen Schwachstellen gefunden. Naechster Scan: taeglich 03:00 Uhr.",
|
||||||
subprocess.run(
|
file="",
|
||||||
["bandit", "-r", str(PROJECT_ROOT / "backend"), "-ll",
|
line=None,
|
||||||
"-x", str(PROJECT_ROOT / "backend" / "tests"),
|
found_at=datetime.now().isoformat()
|
||||||
"-f", "json", "-o", str(REPORTS_DIR / f"bandit-{timestamp}.json")],
|
),
|
||||||
capture_output=True,
|
]
|
||||||
timeout=300
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _run_deps_scan(timestamp: str):
|
def get_mock_history() -> List[HistoryItem]:
|
||||||
"""Trivy filesystem + Grype scan."""
|
"""Generiert Mock-Scan-Historie."""
|
||||||
installed, _ = check_tool_installed("trivy")
|
base_time = datetime.now()
|
||||||
if installed:
|
return [
|
||||||
subprocess.run(
|
HistoryItem(
|
||||||
["trivy", "fs", str(PROJECT_ROOT),
|
timestamp=(base_time).isoformat(),
|
||||||
"--config", str(PROJECT_ROOT / ".trivy.yaml"),
|
title="Full Security Scan",
|
||||||
"--format", "json",
|
description="7 Findings (1 High, 3 Medium, 3 Low)",
|
||||||
"--output", str(REPORTS_DIR / f"trivy-fs-{timestamp}.json")],
|
status="warning"
|
||||||
capture_output=True,
|
),
|
||||||
timeout=600
|
HistoryItem(
|
||||||
)
|
timestamp=(base_time.replace(hour=base_time.hour-2)).isoformat(),
|
||||||
|
title="SBOM Generation",
|
||||||
|
description="20 Components analysiert",
|
||||||
|
status="success"
|
||||||
|
),
|
||||||
|
HistoryItem(
|
||||||
|
timestamp=(base_time.replace(hour=base_time.hour-4)).isoformat(),
|
||||||
|
title="Container Scan",
|
||||||
|
description="Keine kritischen CVEs",
|
||||||
|
status="success"
|
||||||
|
),
|
||||||
|
HistoryItem(
|
||||||
|
timestamp=(base_time.replace(day=base_time.day-1)).isoformat(),
|
||||||
|
title="Secrets Scan",
|
||||||
|
description="1 Finding (API Key in .env.example)",
|
||||||
|
status="warning"
|
||||||
|
),
|
||||||
|
HistoryItem(
|
||||||
|
timestamp=(base_time.replace(day=base_time.day-1, hour=10)).isoformat(),
|
||||||
|
title="SAST Scan",
|
||||||
|
description="3 Findings (Bandit, Semgrep)",
|
||||||
|
status="warning"
|
||||||
|
),
|
||||||
|
HistoryItem(
|
||||||
|
timestamp=(base_time.replace(day=base_time.day-2)).isoformat(),
|
||||||
|
title="Dependency Scan",
|
||||||
|
description="3 vulnerable packages",
|
||||||
|
status="warning"
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
installed, _ = check_tool_installed("grype")
|
|
||||||
if installed:
|
# ===========================
|
||||||
|
# Demo-Mode Endpoints (with Mock Data)
|
||||||
|
# ===========================
|
||||||
|
|
||||||
|
@router.get("/demo/sbom")
|
||||||
|
async def get_demo_sbom():
|
||||||
|
"""Gibt Demo-SBOM-Daten zurueck wenn keine echten verfuegbar."""
|
||||||
|
# Erst echte Daten versuchen
|
||||||
|
sbom_report = get_latest_report("sbom")
|
||||||
|
if sbom_report and sbom_report.exists():
|
||||||
|
try:
|
||||||
|
with open(sbom_report) as f:
|
||||||
|
return json.load(f)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
# Fallback zu Mock-Daten
|
||||||
|
return get_mock_sbom_data()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/demo/findings")
|
||||||
|
async def get_demo_findings():
|
||||||
|
"""Gibt Demo-Findings zurueck wenn keine echten verfuegbar."""
|
||||||
|
# Erst echte Daten versuchen
|
||||||
|
real_findings = get_all_findings()
|
||||||
|
if real_findings:
|
||||||
|
return real_findings
|
||||||
|
# Fallback zu Mock-Daten
|
||||||
|
return get_mock_findings()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/demo/summary")
|
||||||
|
async def get_demo_summary():
|
||||||
|
"""Gibt Demo-Summary zurueck."""
|
||||||
|
real_findings = get_all_findings()
|
||||||
|
if real_findings:
|
||||||
|
return calculate_summary(real_findings)
|
||||||
|
# Mock summary
|
||||||
|
mock_findings = get_mock_findings()
|
||||||
|
return calculate_summary(mock_findings)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/demo/history")
|
||||||
|
async def get_demo_history():
|
||||||
|
"""Gibt Demo-Historie zurueck wenn keine echten verfuegbar."""
|
||||||
|
real_history = await get_history()
|
||||||
|
if real_history:
|
||||||
|
return real_history
|
||||||
|
return get_mock_history()
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================
|
||||||
|
# Monitoring Endpoints
|
||||||
|
# ===========================
|
||||||
|
|
||||||
|
class LogEntry(BaseModel):
|
||||||
|
timestamp: str
|
||||||
|
level: str
|
||||||
|
service: str
|
||||||
|
message: str
|
||||||
|
|
||||||
|
|
||||||
|
class MetricValue(BaseModel):
|
||||||
|
name: str
|
||||||
|
value: float
|
||||||
|
unit: str
|
||||||
|
trend: Optional[str] = None # up, down, stable
|
||||||
|
|
||||||
|
|
||||||
|
class ContainerStatus(BaseModel):
|
||||||
|
name: str
|
||||||
|
status: str
|
||||||
|
health: str
|
||||||
|
cpu_percent: float
|
||||||
|
memory_mb: float
|
||||||
|
uptime: str
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceStatus(BaseModel):
|
||||||
|
name: str
|
||||||
|
url: str
|
||||||
|
status: str
|
||||||
|
response_time_ms: int
|
||||||
|
last_check: str
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/monitoring/logs", response_model=List[LogEntry])
|
||||||
|
async def get_logs(service: Optional[str] = None, level: Optional[str] = None, limit: int = 50):
|
||||||
|
"""Gibt Log-Eintraege zurueck (Demo-Daten)."""
|
||||||
|
import random
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
services = ["backend", "consent-service", "postgres", "mailpit"]
|
||||||
|
levels = ["INFO", "INFO", "INFO", "WARNING", "ERROR", "DEBUG"]
|
||||||
|
messages = {
|
||||||
|
"backend": [
|
||||||
|
"Request completed: GET /api/consent/health 200",
|
||||||
|
"Request completed: POST /api/auth/login 200",
|
||||||
|
"Database connection established",
|
||||||
|
"JWT token validated successfully",
|
||||||
|
"Starting background task: email_notification",
|
||||||
|
"Cache miss for key: user_session_abc123",
|
||||||
|
"Request completed: GET /api/v1/security/demo/sbom 200",
|
||||||
|
],
|
||||||
|
"consent-service": [
|
||||||
|
"Health check passed",
|
||||||
|
"Document version created: v1.2.0",
|
||||||
|
"Consent recorded for user: user-12345",
|
||||||
|
"GDPR export job started",
|
||||||
|
"Database query executed in 12ms",
|
||||||
|
],
|
||||||
|
"postgres": [
|
||||||
|
"checkpoint starting: time",
|
||||||
|
"automatic analyze of table completed",
|
||||||
|
"connection authorized: user=breakpilot",
|
||||||
|
"statement: SELECT * FROM documents WHERE...",
|
||||||
|
],
|
||||||
|
"mailpit": [
|
||||||
|
"SMTP connection from 172.18.0.3",
|
||||||
|
"Email received: Consent Confirmation",
|
||||||
|
"Message stored: id=msg-001",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
logs = []
|
||||||
|
base_time = datetime.now()
|
||||||
|
|
||||||
|
for i in range(limit):
|
||||||
|
svc = random.choice(services) if not service else service
|
||||||
|
lvl = random.choice(levels) if not level else level
|
||||||
|
msg_list = messages.get(svc, messages["backend"])
|
||||||
|
msg = random.choice(msg_list)
|
||||||
|
|
||||||
|
# Add some variety to error messages
|
||||||
|
if lvl == "ERROR":
|
||||||
|
msg = random.choice([
|
||||||
|
"Connection timeout after 30s",
|
||||||
|
"Failed to parse JSON response",
|
||||||
|
"Database query failed: connection reset",
|
||||||
|
"Rate limit exceeded for IP 192.168.1.1",
|
||||||
|
])
|
||||||
|
elif lvl == "WARNING":
|
||||||
|
msg = random.choice([
|
||||||
|
"Slow query detected: 523ms",
|
||||||
|
"Memory usage above 80%",
|
||||||
|
"Retry attempt 2/3 for external API",
|
||||||
|
"Deprecated API endpoint called",
|
||||||
|
])
|
||||||
|
|
||||||
|
logs.append(LogEntry(
|
||||||
|
timestamp=(base_time - timedelta(seconds=i*random.randint(1, 30))).isoformat(),
|
||||||
|
level=lvl,
|
||||||
|
service=svc,
|
||||||
|
message=msg
|
||||||
|
))
|
||||||
|
|
||||||
|
# Filter
|
||||||
|
if service:
|
||||||
|
logs = [l for l in logs if l.service == service]
|
||||||
|
if level:
|
||||||
|
logs = [l for l in logs if l.level.upper() == level.upper()]
|
||||||
|
|
||||||
|
return logs[:limit]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/monitoring/metrics", response_model=List[MetricValue])
|
||||||
|
async def get_metrics():
|
||||||
|
"""Gibt System-Metriken zurueck (Demo-Daten)."""
|
||||||
|
import random
|
||||||
|
|
||||||
|
return [
|
||||||
|
MetricValue(name="CPU Usage", value=round(random.uniform(15, 45), 1), unit="%", trend="stable"),
|
||||||
|
MetricValue(name="Memory Usage", value=round(random.uniform(40, 65), 1), unit="%", trend="up"),
|
||||||
|
MetricValue(name="Disk Usage", value=round(random.uniform(25, 40), 1), unit="%", trend="stable"),
|
||||||
|
MetricValue(name="Network In", value=round(random.uniform(1.2, 5.8), 2), unit="MB/s", trend="up"),
|
||||||
|
MetricValue(name="Network Out", value=round(random.uniform(0.5, 2.1), 2), unit="MB/s", trend="stable"),
|
||||||
|
MetricValue(name="Active Connections", value=random.randint(12, 48), unit="", trend="up"),
|
||||||
|
MetricValue(name="Requests/min", value=random.randint(120, 350), unit="req/min", trend="up"),
|
||||||
|
MetricValue(name="Avg Response Time", value=round(random.uniform(45, 120), 0), unit="ms", trend="down"),
|
||||||
|
MetricValue(name="Error Rate", value=round(random.uniform(0.1, 0.8), 2), unit="%", trend="stable"),
|
||||||
|
MetricValue(name="Cache Hit Rate", value=round(random.uniform(85, 98), 1), unit="%", trend="up"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/monitoring/containers", response_model=List[ContainerStatus])
|
||||||
|
async def get_container_status():
|
||||||
|
"""Gibt Container-Status zurueck (versucht Docker, sonst Demo-Daten)."""
|
||||||
|
import random
|
||||||
|
|
||||||
|
# Versuche echte Docker-Daten
|
||||||
|
try:
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
["grype", f"dir:{PROJECT_ROOT}", "-o", "json"],
|
["docker", "ps", "--format", "{{.Names}}\t{{.Status}}\t{{.State}}"],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
timeout=600
|
timeout=5
|
||||||
)
|
)
|
||||||
if result.stdout:
|
if result.returncode == 0 and result.stdout.strip():
|
||||||
with open(REPORTS_DIR / f"grype-{timestamp}.json", "w") as f:
|
containers = []
|
||||||
f.write(result.stdout)
|
for line in result.stdout.strip().split('\n'):
|
||||||
|
parts = line.split('\t')
|
||||||
|
if len(parts) >= 3:
|
||||||
|
name, status, state = parts[0], parts[1], parts[2]
|
||||||
|
# Parse uptime from status like "Up 2 hours"
|
||||||
|
uptime = status if "Up" in status else "N/A"
|
||||||
|
|
||||||
|
containers.append(ContainerStatus(
|
||||||
|
name=name,
|
||||||
|
status=state,
|
||||||
|
health="healthy" if state == "running" else "unhealthy",
|
||||||
|
cpu_percent=round(random.uniform(0.5, 15), 1),
|
||||||
|
memory_mb=round(random.uniform(50, 500), 0),
|
||||||
|
uptime=uptime
|
||||||
|
))
|
||||||
|
if containers:
|
||||||
|
return containers
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Fallback: Demo-Daten
|
||||||
|
return [
|
||||||
|
ContainerStatus(name="breakpilot-pwa-backend", status="running", health="healthy",
|
||||||
|
cpu_percent=round(random.uniform(2, 12), 1), memory_mb=round(random.uniform(180, 280), 0), uptime="Up 4 hours"),
|
||||||
|
ContainerStatus(name="breakpilot-pwa-consent-service", status="running", health="healthy",
|
||||||
|
cpu_percent=round(random.uniform(1, 8), 1), memory_mb=round(random.uniform(80, 150), 0), uptime="Up 4 hours"),
|
||||||
|
ContainerStatus(name="breakpilot-pwa-postgres", status="running", health="healthy",
|
||||||
|
cpu_percent=round(random.uniform(0.5, 5), 1), memory_mb=round(random.uniform(120, 200), 0), uptime="Up 4 hours"),
|
||||||
|
ContainerStatus(name="breakpilot-pwa-mailpit", status="running", health="healthy",
|
||||||
|
cpu_percent=round(random.uniform(0.1, 2), 1), memory_mb=round(random.uniform(30, 60), 0), uptime="Up 4 hours"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def _run_sbom_scan(timestamp: str):
|
@router.get("/monitoring/services", response_model=List[ServiceStatus])
|
||||||
"""Syft SBOM generation."""
|
async def get_service_status():
|
||||||
installed, _ = check_tool_installed("syft")
|
"""Prueft den Status aller Services (Health-Checks)."""
|
||||||
if installed:
|
import random
|
||||||
subprocess.run(
|
|
||||||
["syft", f"dir:{PROJECT_ROOT}",
|
|
||||||
"-o", f"cyclonedx-json={REPORTS_DIR / f'sbom-{timestamp}.json'}"],
|
|
||||||
capture_output=True,
|
|
||||||
timeout=300
|
|
||||||
)
|
|
||||||
|
|
||||||
|
services_to_check = [
|
||||||
|
("Backend API", "http://localhost:8000/api/consent/health"),
|
||||||
|
("Consent Service", "http://consent-service:8081/health"),
|
||||||
|
("School Service", "http://school-service:8084/health"),
|
||||||
|
("Klausur Service", "http://klausur-service:8086/health"),
|
||||||
|
]
|
||||||
|
|
||||||
def _run_container_scan(timestamp: str):
|
results = []
|
||||||
"""Trivy image scan."""
|
for name, url in services_to_check:
|
||||||
installed, _ = check_tool_installed("trivy")
|
status = "healthy"
|
||||||
if installed:
|
response_time = random.randint(15, 150)
|
||||||
images = ["breakpilot-pwa-backend", "breakpilot-pwa-consent-service"]
|
|
||||||
for image in images:
|
# Versuche echten Health-Check fuer Backend
|
||||||
subprocess.run(
|
if "localhost:8000" in url:
|
||||||
["trivy", "image", image,
|
try:
|
||||||
"--format", "json",
|
import httpx
|
||||||
"--output", str(REPORTS_DIR / f"trivy-image-{image}-{timestamp}.json")],
|
async with httpx.AsyncClient() as client:
|
||||||
capture_output=True,
|
start = datetime.now()
|
||||||
timeout=600
|
response = await client.get(url, timeout=5)
|
||||||
)
|
response_time = int((datetime.now() - start).total_seconds() * 1000)
|
||||||
|
status = "healthy" if response.status_code == 200 else "unhealthy"
|
||||||
|
except:
|
||||||
|
status = "healthy" # Assume healthy if we're running
|
||||||
|
|
||||||
|
results.append(ServiceStatus(
|
||||||
|
name=name,
|
||||||
|
url=url,
|
||||||
|
status=status,
|
||||||
|
response_time_ms=response_time,
|
||||||
|
last_check=datetime.now().isoformat()
|
||||||
|
))
|
||||||
|
|
||||||
|
return results
|
||||||
|
|||||||
@@ -1,178 +0,0 @@
|
|||||||
"""
|
|
||||||
Security Mock Data & Demo Endpoints
|
|
||||||
|
|
||||||
Mock/demo data generators for the Security Dashboard.
|
|
||||||
Used as fallback when no real scan reports are available.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from datetime import datetime
|
|
||||||
from typing import List, Dict, Any
|
|
||||||
from fastapi import APIRouter
|
|
||||||
|
|
||||||
from security_models import (
|
|
||||||
Finding,
|
|
||||||
SeveritySummary,
|
|
||||||
HistoryItem,
|
|
||||||
)
|
|
||||||
from security_report_parsers import get_all_findings, get_latest_report, calculate_summary
|
|
||||||
|
|
||||||
import json
|
|
||||||
|
|
||||||
router = APIRouter(tags=["Security"])
|
|
||||||
|
|
||||||
|
|
||||||
# ===========================
|
|
||||||
# Mock Data Generators
|
|
||||||
# ===========================
|
|
||||||
|
|
||||||
def get_mock_sbom_data() -> Dict[str, Any]:
|
|
||||||
"""Generiert realistische Mock-SBOM-Daten basierend auf requirements.txt."""
|
|
||||||
return {
|
|
||||||
"bomFormat": "CycloneDX",
|
|
||||||
"specVersion": "1.4",
|
|
||||||
"version": 1,
|
|
||||||
"metadata": {
|
|
||||||
"timestamp": datetime.now().isoformat(),
|
|
||||||
"tools": [{"vendor": "BreakPilot", "name": "DevSecOps", "version": "1.0.0"}],
|
|
||||||
"component": {
|
|
||||||
"type": "application",
|
|
||||||
"name": "breakpilot-pwa",
|
|
||||||
"version": "2.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"components": [
|
|
||||||
{"type": "library", "name": "fastapi", "version": "0.109.0", "purl": "pkg:pypi/fastapi@0.109.0", "licenses": [{"license": {"id": "MIT"}}]},
|
|
||||||
{"type": "library", "name": "uvicorn", "version": "0.27.0", "purl": "pkg:pypi/uvicorn@0.27.0", "licenses": [{"license": {"id": "BSD-3-Clause"}}]},
|
|
||||||
{"type": "library", "name": "pydantic", "version": "2.5.3", "purl": "pkg:pypi/pydantic@2.5.3", "licenses": [{"license": {"id": "MIT"}}]},
|
|
||||||
{"type": "library", "name": "httpx", "version": "0.26.0", "purl": "pkg:pypi/httpx@0.26.0", "licenses": [{"license": {"id": "BSD-3-Clause"}}]},
|
|
||||||
{"type": "library", "name": "python-jose", "version": "3.3.0", "purl": "pkg:pypi/python-jose@3.3.0", "licenses": [{"license": {"id": "MIT"}}]},
|
|
||||||
{"type": "library", "name": "passlib", "version": "1.7.4", "purl": "pkg:pypi/passlib@1.7.4", "licenses": [{"license": {"id": "BSD-3-Clause"}}]},
|
|
||||||
{"type": "library", "name": "bcrypt", "version": "4.1.2", "purl": "pkg:pypi/bcrypt@4.1.2", "licenses": [{"license": {"id": "Apache-2.0"}}]},
|
|
||||||
{"type": "library", "name": "psycopg2-binary", "version": "2.9.9", "purl": "pkg:pypi/psycopg2-binary@2.9.9", "licenses": [{"license": {"id": "LGPL-3.0"}}]},
|
|
||||||
{"type": "library", "name": "sqlalchemy", "version": "2.0.25", "purl": "pkg:pypi/sqlalchemy@2.0.25", "licenses": [{"license": {"id": "MIT"}}]},
|
|
||||||
{"type": "library", "name": "alembic", "version": "1.13.1", "purl": "pkg:pypi/alembic@1.13.1", "licenses": [{"license": {"id": "MIT"}}]},
|
|
||||||
{"type": "library", "name": "weasyprint", "version": "60.2", "purl": "pkg:pypi/weasyprint@60.2", "licenses": [{"license": {"id": "BSD-3-Clause"}}]},
|
|
||||||
{"type": "library", "name": "jinja2", "version": "3.1.3", "purl": "pkg:pypi/jinja2@3.1.3", "licenses": [{"license": {"id": "BSD-3-Clause"}}]},
|
|
||||||
{"type": "library", "name": "python-multipart", "version": "0.0.6", "purl": "pkg:pypi/python-multipart@0.0.6", "licenses": [{"license": {"id": "Apache-2.0"}}]},
|
|
||||||
{"type": "library", "name": "aiofiles", "version": "23.2.1", "purl": "pkg:pypi/aiofiles@23.2.1", "licenses": [{"license": {"id": "Apache-2.0"}}]},
|
|
||||||
{"type": "library", "name": "pytest", "version": "7.4.4", "purl": "pkg:pypi/pytest@7.4.4", "licenses": [{"license": {"id": "MIT"}}]},
|
|
||||||
{"type": "library", "name": "pytest-asyncio", "version": "0.23.3", "purl": "pkg:pypi/pytest-asyncio@0.23.3", "licenses": [{"license": {"id": "Apache-2.0"}}]},
|
|
||||||
{"type": "library", "name": "anthropic", "version": "0.18.1", "purl": "pkg:pypi/anthropic@0.18.1", "licenses": [{"license": {"id": "MIT"}}]},
|
|
||||||
{"type": "library", "name": "openai", "version": "1.12.0", "purl": "pkg:pypi/openai@1.12.0", "licenses": [{"license": {"id": "MIT"}}]},
|
|
||||||
{"type": "library", "name": "langchain", "version": "0.1.6", "purl": "pkg:pypi/langchain@0.1.6", "licenses": [{"license": {"id": "MIT"}}]},
|
|
||||||
{"type": "library", "name": "chromadb", "version": "0.4.22", "purl": "pkg:pypi/chromadb@0.4.22", "licenses": [{"license": {"id": "Apache-2.0"}}]},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def get_mock_findings() -> List[Finding]:
|
|
||||||
"""Generiert Mock-Findings fuer Demo wenn keine echten Scan-Ergebnisse vorhanden."""
|
|
||||||
# Alle kritischen Findings wurden behoben:
|
|
||||||
# - idna >= 3.7 gepinnt (CVE-2024-3651)
|
|
||||||
# - cryptography >= 42.0.0 gepinnt (GHSA-h4gh-qq45-vh27)
|
|
||||||
# - jinja2 3.1.6 installiert (CVE-2024-34064)
|
|
||||||
# - .env.example Placeholders verbessert
|
|
||||||
# - Keine shell=True Verwendung im Code
|
|
||||||
return [
|
|
||||||
Finding(
|
|
||||||
id="info-scan-complete",
|
|
||||||
tool="system",
|
|
||||||
severity="INFO",
|
|
||||||
title="Letzte Sicherheitspruefung erfolgreich",
|
|
||||||
message="Keine kritischen Schwachstellen gefunden. Naechster Scan: taeglich 03:00 Uhr.",
|
|
||||||
file="",
|
|
||||||
line=None,
|
|
||||||
found_at=datetime.now().isoformat()
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def get_mock_history() -> List[HistoryItem]:
|
|
||||||
"""Generiert Mock-Scan-Historie."""
|
|
||||||
base_time = datetime.now()
|
|
||||||
return [
|
|
||||||
HistoryItem(
|
|
||||||
timestamp=(base_time).isoformat(),
|
|
||||||
title="Full Security Scan",
|
|
||||||
description="7 Findings (1 High, 3 Medium, 3 Low)",
|
|
||||||
status="warning"
|
|
||||||
),
|
|
||||||
HistoryItem(
|
|
||||||
timestamp=(base_time.replace(hour=base_time.hour-2)).isoformat(),
|
|
||||||
title="SBOM Generation",
|
|
||||||
description="20 Components analysiert",
|
|
||||||
status="success"
|
|
||||||
),
|
|
||||||
HistoryItem(
|
|
||||||
timestamp=(base_time.replace(hour=base_time.hour-4)).isoformat(),
|
|
||||||
title="Container Scan",
|
|
||||||
description="Keine kritischen CVEs",
|
|
||||||
status="success"
|
|
||||||
),
|
|
||||||
HistoryItem(
|
|
||||||
timestamp=(base_time.replace(day=base_time.day-1)).isoformat(),
|
|
||||||
title="Secrets Scan",
|
|
||||||
description="1 Finding (API Key in .env.example)",
|
|
||||||
status="warning"
|
|
||||||
),
|
|
||||||
HistoryItem(
|
|
||||||
timestamp=(base_time.replace(day=base_time.day-1, hour=10)).isoformat(),
|
|
||||||
title="SAST Scan",
|
|
||||||
description="3 Findings (Bandit, Semgrep)",
|
|
||||||
status="warning"
|
|
||||||
),
|
|
||||||
HistoryItem(
|
|
||||||
timestamp=(base_time.replace(day=base_time.day-2)).isoformat(),
|
|
||||||
title="Dependency Scan",
|
|
||||||
description="3 vulnerable packages",
|
|
||||||
status="warning"
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
# ===========================
|
|
||||||
# Demo-Mode Endpoints (with Mock Data)
|
|
||||||
# ===========================
|
|
||||||
|
|
||||||
@router.get("/demo/sbom")
|
|
||||||
async def get_demo_sbom():
|
|
||||||
"""Gibt Demo-SBOM-Daten zurueck wenn keine echten verfuegbar."""
|
|
||||||
# Erst echte Daten versuchen
|
|
||||||
sbom_report = get_latest_report("sbom")
|
|
||||||
if sbom_report and sbom_report.exists():
|
|
||||||
try:
|
|
||||||
with open(sbom_report) as f:
|
|
||||||
return json.load(f)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
# Fallback zu Mock-Daten
|
|
||||||
return get_mock_sbom_data()
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/demo/findings")
|
|
||||||
async def get_demo_findings():
|
|
||||||
"""Gibt Demo-Findings zurueck wenn keine echten verfuegbar."""
|
|
||||||
# Erst echte Daten versuchen
|
|
||||||
real_findings = get_all_findings()
|
|
||||||
if real_findings:
|
|
||||||
return real_findings
|
|
||||||
# Fallback zu Mock-Daten
|
|
||||||
return get_mock_findings()
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/demo/summary")
|
|
||||||
async def get_demo_summary():
|
|
||||||
"""Gibt Demo-Summary zurueck."""
|
|
||||||
real_findings = get_all_findings()
|
|
||||||
if real_findings:
|
|
||||||
return calculate_summary(real_findings)
|
|
||||||
# Mock summary
|
|
||||||
mock_findings = get_mock_findings()
|
|
||||||
return calculate_summary(mock_findings)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/demo/history")
|
|
||||||
async def get_demo_history():
|
|
||||||
"""Gibt Demo-Historie zurueck wenn keine echten verfuegbar."""
|
|
||||||
# Note: uses mock data directly instead of calling the main history endpoint
|
|
||||||
return get_mock_history()
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
"""
|
|
||||||
Security API - Shared Pydantic Models
|
|
||||||
|
|
||||||
Data models used across security_api, security_mock_data, and security_monitoring.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from typing import Optional
|
|
||||||
from pydantic import BaseModel
|
|
||||||
|
|
||||||
|
|
||||||
class ToolStatus(BaseModel):
|
|
||||||
name: str
|
|
||||||
installed: bool
|
|
||||||
version: Optional[str] = None
|
|
||||||
last_run: Optional[str] = None
|
|
||||||
last_findings: int = 0
|
|
||||||
|
|
||||||
|
|
||||||
class Finding(BaseModel):
|
|
||||||
id: str
|
|
||||||
tool: str
|
|
||||||
severity: str
|
|
||||||
title: str
|
|
||||||
message: Optional[str] = None
|
|
||||||
file: Optional[str] = None
|
|
||||||
line: Optional[int] = None
|
|
||||||
found_at: str
|
|
||||||
|
|
||||||
|
|
||||||
class SeveritySummary(BaseModel):
|
|
||||||
critical: int = 0
|
|
||||||
high: int = 0
|
|
||||||
medium: int = 0
|
|
||||||
low: int = 0
|
|
||||||
info: int = 0
|
|
||||||
total: int = 0
|
|
||||||
|
|
||||||
|
|
||||||
class ScanResult(BaseModel):
|
|
||||||
tool: str
|
|
||||||
status: str
|
|
||||||
started_at: str
|
|
||||||
completed_at: Optional[str] = None
|
|
||||||
findings_count: int = 0
|
|
||||||
report_path: Optional[str] = None
|
|
||||||
|
|
||||||
|
|
||||||
class HistoryItem(BaseModel):
|
|
||||||
timestamp: str
|
|
||||||
title: str
|
|
||||||
description: str
|
|
||||||
status: str # success, warning, error
|
|
||||||
@@ -1,243 +0,0 @@
|
|||||||
"""
|
|
||||||
Security Monitoring Endpoints
|
|
||||||
|
|
||||||
System monitoring endpoints for the Security Dashboard:
|
|
||||||
- Log viewing (demo data)
|
|
||||||
- System metrics (demo data)
|
|
||||||
- Container status (real Docker data with demo fallback)
|
|
||||||
- Service health checks
|
|
||||||
"""
|
|
||||||
|
|
||||||
import subprocess
|
|
||||||
from datetime import datetime
|
|
||||||
from typing import List, Optional
|
|
||||||
from fastapi import APIRouter
|
|
||||||
from pydantic import BaseModel
|
|
||||||
|
|
||||||
|
|
||||||
router = APIRouter(tags=["Security"])
|
|
||||||
|
|
||||||
|
|
||||||
# ===========================
|
|
||||||
# Pydantic Models
|
|
||||||
# ===========================
|
|
||||||
|
|
||||||
class LogEntry(BaseModel):
|
|
||||||
timestamp: str
|
|
||||||
level: str
|
|
||||||
service: str
|
|
||||||
message: str
|
|
||||||
|
|
||||||
|
|
||||||
class MetricValue(BaseModel):
|
|
||||||
name: str
|
|
||||||
value: float
|
|
||||||
unit: str
|
|
||||||
trend: Optional[str] = None # up, down, stable
|
|
||||||
|
|
||||||
|
|
||||||
class ContainerStatus(BaseModel):
|
|
||||||
name: str
|
|
||||||
status: str
|
|
||||||
health: str
|
|
||||||
cpu_percent: float
|
|
||||||
memory_mb: float
|
|
||||||
uptime: str
|
|
||||||
|
|
||||||
|
|
||||||
class ServiceStatus(BaseModel):
|
|
||||||
name: str
|
|
||||||
url: str
|
|
||||||
status: str
|
|
||||||
response_time_ms: int
|
|
||||||
last_check: str
|
|
||||||
|
|
||||||
|
|
||||||
# ===========================
|
|
||||||
# Monitoring Endpoints
|
|
||||||
# ===========================
|
|
||||||
|
|
||||||
@router.get("/monitoring/logs", response_model=List[LogEntry])
|
|
||||||
async def get_logs(service: Optional[str] = None, level: Optional[str] = None, limit: int = 50):
|
|
||||||
"""Gibt Log-Eintraege zurueck (Demo-Daten)."""
|
|
||||||
import random
|
|
||||||
from datetime import timedelta
|
|
||||||
|
|
||||||
services = ["backend", "consent-service", "postgres", "mailpit"]
|
|
||||||
levels = ["INFO", "INFO", "INFO", "WARNING", "ERROR", "DEBUG"]
|
|
||||||
messages = {
|
|
||||||
"backend": [
|
|
||||||
"Request completed: GET /api/consent/health 200",
|
|
||||||
"Request completed: POST /api/auth/login 200",
|
|
||||||
"Database connection established",
|
|
||||||
"JWT token validated successfully",
|
|
||||||
"Starting background task: email_notification",
|
|
||||||
"Cache miss for key: user_session_abc123",
|
|
||||||
"Request completed: GET /api/v1/security/demo/sbom 200",
|
|
||||||
],
|
|
||||||
"consent-service": [
|
|
||||||
"Health check passed",
|
|
||||||
"Document version created: v1.2.0",
|
|
||||||
"Consent recorded for user: user-12345",
|
|
||||||
"GDPR export job started",
|
|
||||||
"Database query executed in 12ms",
|
|
||||||
],
|
|
||||||
"postgres": [
|
|
||||||
"checkpoint starting: time",
|
|
||||||
"automatic analyze of table completed",
|
|
||||||
"connection authorized: user=breakpilot",
|
|
||||||
"statement: SELECT * FROM documents WHERE...",
|
|
||||||
],
|
|
||||||
"mailpit": [
|
|
||||||
"SMTP connection from 172.18.0.3",
|
|
||||||
"Email received: Consent Confirmation",
|
|
||||||
"Message stored: id=msg-001",
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
logs = []
|
|
||||||
base_time = datetime.now()
|
|
||||||
|
|
||||||
for i in range(limit):
|
|
||||||
svc = random.choice(services) if not service else service
|
|
||||||
lvl = random.choice(levels) if not level else level
|
|
||||||
msg_list = messages.get(svc, messages["backend"])
|
|
||||||
msg = random.choice(msg_list)
|
|
||||||
|
|
||||||
# Add some variety to error messages
|
|
||||||
if lvl == "ERROR":
|
|
||||||
msg = random.choice([
|
|
||||||
"Connection timeout after 30s",
|
|
||||||
"Failed to parse JSON response",
|
|
||||||
"Database query failed: connection reset",
|
|
||||||
"Rate limit exceeded for IP 192.168.1.1",
|
|
||||||
])
|
|
||||||
elif lvl == "WARNING":
|
|
||||||
msg = random.choice([
|
|
||||||
"Slow query detected: 523ms",
|
|
||||||
"Memory usage above 80%",
|
|
||||||
"Retry attempt 2/3 for external API",
|
|
||||||
"Deprecated API endpoint called",
|
|
||||||
])
|
|
||||||
|
|
||||||
logs.append(LogEntry(
|
|
||||||
timestamp=(base_time - timedelta(seconds=i*random.randint(1, 30))).isoformat(),
|
|
||||||
level=lvl,
|
|
||||||
service=svc,
|
|
||||||
message=msg
|
|
||||||
))
|
|
||||||
|
|
||||||
# Filter
|
|
||||||
if service:
|
|
||||||
logs = [log for log in logs if log.service == service]
|
|
||||||
if level:
|
|
||||||
logs = [log for log in logs if log.level.upper() == level.upper()]
|
|
||||||
|
|
||||||
return logs[:limit]
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/monitoring/metrics", response_model=List[MetricValue])
|
|
||||||
async def get_metrics():
|
|
||||||
"""Gibt System-Metriken zurueck (Demo-Daten)."""
|
|
||||||
import random
|
|
||||||
|
|
||||||
return [
|
|
||||||
MetricValue(name="CPU Usage", value=round(random.uniform(15, 45), 1), unit="%", trend="stable"),
|
|
||||||
MetricValue(name="Memory Usage", value=round(random.uniform(40, 65), 1), unit="%", trend="up"),
|
|
||||||
MetricValue(name="Disk Usage", value=round(random.uniform(25, 40), 1), unit="%", trend="stable"),
|
|
||||||
MetricValue(name="Network In", value=round(random.uniform(1.2, 5.8), 2), unit="MB/s", trend="up"),
|
|
||||||
MetricValue(name="Network Out", value=round(random.uniform(0.5, 2.1), 2), unit="MB/s", trend="stable"),
|
|
||||||
MetricValue(name="Active Connections", value=random.randint(12, 48), unit="", trend="up"),
|
|
||||||
MetricValue(name="Requests/min", value=random.randint(120, 350), unit="req/min", trend="up"),
|
|
||||||
MetricValue(name="Avg Response Time", value=round(random.uniform(45, 120), 0), unit="ms", trend="down"),
|
|
||||||
MetricValue(name="Error Rate", value=round(random.uniform(0.1, 0.8), 2), unit="%", trend="stable"),
|
|
||||||
MetricValue(name="Cache Hit Rate", value=round(random.uniform(85, 98), 1), unit="%", trend="up"),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/monitoring/containers", response_model=List[ContainerStatus])
|
|
||||||
async def get_container_status():
|
|
||||||
"""Gibt Container-Status zurueck (versucht Docker, sonst Demo-Daten)."""
|
|
||||||
import random
|
|
||||||
|
|
||||||
# Versuche echte Docker-Daten
|
|
||||||
try:
|
|
||||||
result = subprocess.run(
|
|
||||||
["docker", "ps", "--format", "{{.Names}}\t{{.Status}}\t{{.State}}"],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=5
|
|
||||||
)
|
|
||||||
if result.returncode == 0 and result.stdout.strip():
|
|
||||||
containers = []
|
|
||||||
for line in result.stdout.strip().split('\n'):
|
|
||||||
parts = line.split('\t')
|
|
||||||
if len(parts) >= 3:
|
|
||||||
name, status, state = parts[0], parts[1], parts[2]
|
|
||||||
# Parse uptime from status like "Up 2 hours"
|
|
||||||
uptime = status if "Up" in status else "N/A"
|
|
||||||
|
|
||||||
containers.append(ContainerStatus(
|
|
||||||
name=name,
|
|
||||||
status=state,
|
|
||||||
health="healthy" if state == "running" else "unhealthy",
|
|
||||||
cpu_percent=round(random.uniform(0.5, 15), 1),
|
|
||||||
memory_mb=round(random.uniform(50, 500), 0),
|
|
||||||
uptime=uptime
|
|
||||||
))
|
|
||||||
if containers:
|
|
||||||
return containers
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Fallback: Demo-Daten
|
|
||||||
return [
|
|
||||||
ContainerStatus(name="breakpilot-pwa-backend", status="running", health="healthy",
|
|
||||||
cpu_percent=round(random.uniform(2, 12), 1), memory_mb=round(random.uniform(180, 280), 0), uptime="Up 4 hours"),
|
|
||||||
ContainerStatus(name="breakpilot-pwa-consent-service", status="running", health="healthy",
|
|
||||||
cpu_percent=round(random.uniform(1, 8), 1), memory_mb=round(random.uniform(80, 150), 0), uptime="Up 4 hours"),
|
|
||||||
ContainerStatus(name="breakpilot-pwa-postgres", status="running", health="healthy",
|
|
||||||
cpu_percent=round(random.uniform(0.5, 5), 1), memory_mb=round(random.uniform(120, 200), 0), uptime="Up 4 hours"),
|
|
||||||
ContainerStatus(name="breakpilot-pwa-mailpit", status="running", health="healthy",
|
|
||||||
cpu_percent=round(random.uniform(0.1, 2), 1), memory_mb=round(random.uniform(30, 60), 0), uptime="Up 4 hours"),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/monitoring/services", response_model=List[ServiceStatus])
|
|
||||||
async def get_service_status():
|
|
||||||
"""Prueft den Status aller Services (Health-Checks)."""
|
|
||||||
import random
|
|
||||||
|
|
||||||
services_to_check = [
|
|
||||||
("Backend API", "http://localhost:8000/api/consent/health"),
|
|
||||||
("Consent Service", "http://consent-service:8081/health"),
|
|
||||||
("School Service", "http://school-service:8084/health"),
|
|
||||||
("Klausur Service", "http://klausur-service:8086/health"),
|
|
||||||
]
|
|
||||||
|
|
||||||
results = []
|
|
||||||
for name, url in services_to_check:
|
|
||||||
status = "healthy"
|
|
||||||
response_time = random.randint(15, 150)
|
|
||||||
|
|
||||||
# Versuche echten Health-Check fuer Backend
|
|
||||||
if "localhost:8000" in url:
|
|
||||||
try:
|
|
||||||
import httpx
|
|
||||||
async with httpx.AsyncClient() as client:
|
|
||||||
start = datetime.now()
|
|
||||||
response = await client.get(url, timeout=5)
|
|
||||||
response_time = int((datetime.now() - start).total_seconds() * 1000)
|
|
||||||
status = "healthy" if response.status_code == 200 else "unhealthy"
|
|
||||||
except Exception:
|
|
||||||
status = "healthy" # Assume healthy if we're running
|
|
||||||
|
|
||||||
results.append(ServiceStatus(
|
|
||||||
name=name,
|
|
||||||
url=url,
|
|
||||||
status=status,
|
|
||||||
response_time_ms=response_time,
|
|
||||||
last_check=datetime.now().isoformat()
|
|
||||||
))
|
|
||||||
|
|
||||||
return results
|
|
||||||
@@ -1,268 +0,0 @@
|
|||||||
"""
|
|
||||||
Security Report Parsers & Utility Functions
|
|
||||||
|
|
||||||
Parsing logic for security tool reports (Gitleaks, Semgrep, Bandit, Trivy, Grype).
|
|
||||||
Also contains shared utility functions: tool detection, report lookup, summary calculation.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import json
|
|
||||||
import subprocess
|
|
||||||
from datetime import datetime
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import List, Optional
|
|
||||||
|
|
||||||
from security_models import Finding, SeveritySummary
|
|
||||||
|
|
||||||
|
|
||||||
# Pfade - innerhalb des Backend-Verzeichnisses
|
|
||||||
# In Docker: /app/security-reports, /app/scripts
|
|
||||||
# Lokal: backend/security-reports, backend/scripts
|
|
||||||
BACKEND_DIR = Path(__file__).parent
|
|
||||||
REPORTS_DIR = BACKEND_DIR / "security-reports"
|
|
||||||
SCRIPTS_DIR = BACKEND_DIR / "scripts"
|
|
||||||
|
|
||||||
# Projekt-Root fuer Security-Scans
|
|
||||||
PROJECT_ROOT = BACKEND_DIR
|
|
||||||
|
|
||||||
# Sicherstellen, dass das Reports-Verzeichnis existiert
|
|
||||||
try:
|
|
||||||
REPORTS_DIR.mkdir(exist_ok=True)
|
|
||||||
except PermissionError:
|
|
||||||
# Falls keine Schreibrechte, verwende tmp-Verzeichnis
|
|
||||||
REPORTS_DIR = Path("/tmp/security-reports")
|
|
||||||
REPORTS_DIR.mkdir(exist_ok=True)
|
|
||||||
|
|
||||||
|
|
||||||
# ===========================
|
|
||||||
# Utility Functions
|
|
||||||
# ===========================
|
|
||||||
|
|
||||||
def check_tool_installed(tool_name: str) -> tuple[bool, Optional[str]]:
|
|
||||||
"""Prueft, ob ein Tool installiert ist und gibt die Version zurueck."""
|
|
||||||
try:
|
|
||||||
if tool_name == "gitleaks":
|
|
||||||
result = subprocess.run(["gitleaks", "version"], capture_output=True, text=True, timeout=5)
|
|
||||||
if result.returncode == 0:
|
|
||||||
return True, result.stdout.strip()
|
|
||||||
elif tool_name == "semgrep":
|
|
||||||
result = subprocess.run(["semgrep", "--version"], capture_output=True, text=True, timeout=5)
|
|
||||||
if result.returncode == 0:
|
|
||||||
return True, result.stdout.strip().split('\n')[0]
|
|
||||||
elif tool_name == "bandit":
|
|
||||||
result = subprocess.run(["bandit", "--version"], capture_output=True, text=True, timeout=5)
|
|
||||||
if result.returncode == 0:
|
|
||||||
return True, result.stdout.strip()
|
|
||||||
elif tool_name == "trivy":
|
|
||||||
result = subprocess.run(["trivy", "version"], capture_output=True, text=True, timeout=5)
|
|
||||||
if result.returncode == 0:
|
|
||||||
# Parse "Version: 0.48.x"
|
|
||||||
for line in result.stdout.split('\n'):
|
|
||||||
if line.startswith('Version:'):
|
|
||||||
return True, line.split(':')[1].strip()
|
|
||||||
return True, result.stdout.strip().split('\n')[0]
|
|
||||||
elif tool_name == "grype":
|
|
||||||
result = subprocess.run(["grype", "version"], capture_output=True, text=True, timeout=5)
|
|
||||||
if result.returncode == 0:
|
|
||||||
return True, result.stdout.strip().split('\n')[0]
|
|
||||||
elif tool_name == "syft":
|
|
||||||
result = subprocess.run(["syft", "version"], capture_output=True, text=True, timeout=5)
|
|
||||||
if result.returncode == 0:
|
|
||||||
return True, result.stdout.strip().split('\n')[0]
|
|
||||||
except (subprocess.TimeoutExpired, FileNotFoundError):
|
|
||||||
pass
|
|
||||||
return False, None
|
|
||||||
|
|
||||||
|
|
||||||
def get_latest_report(tool_prefix: str) -> Optional[Path]:
|
|
||||||
"""Findet den neuesten Report fuer ein Tool."""
|
|
||||||
if not REPORTS_DIR.exists():
|
|
||||||
return None
|
|
||||||
|
|
||||||
reports = list(REPORTS_DIR.glob(f"{tool_prefix}*.json"))
|
|
||||||
if not reports:
|
|
||||||
return None
|
|
||||||
|
|
||||||
return max(reports, key=lambda p: p.stat().st_mtime)
|
|
||||||
|
|
||||||
|
|
||||||
# ===========================
|
|
||||||
# Report Parsers
|
|
||||||
# ===========================
|
|
||||||
|
|
||||||
def parse_gitleaks_report(report_path: Path) -> List[Finding]:
|
|
||||||
"""Parst Gitleaks JSON Report."""
|
|
||||||
findings = []
|
|
||||||
try:
|
|
||||||
with open(report_path) as f:
|
|
||||||
data = json.load(f)
|
|
||||||
if isinstance(data, list):
|
|
||||||
for item in data:
|
|
||||||
findings.append(Finding(
|
|
||||||
id=item.get("Fingerprint", "unknown"),
|
|
||||||
tool="gitleaks",
|
|
||||||
severity="HIGH", # Secrets sind immer kritisch
|
|
||||||
title=item.get("Description", "Secret detected"),
|
|
||||||
message=f"Rule: {item.get('RuleID', 'unknown')}",
|
|
||||||
file=item.get("File", ""),
|
|
||||||
line=item.get("StartLine", 0),
|
|
||||||
found_at=datetime.fromtimestamp(report_path.stat().st_mtime).isoformat()
|
|
||||||
))
|
|
||||||
except (json.JSONDecodeError, KeyError, FileNotFoundError):
|
|
||||||
pass
|
|
||||||
return findings
|
|
||||||
|
|
||||||
|
|
||||||
def parse_semgrep_report(report_path: Path) -> List[Finding]:
|
|
||||||
"""Parst Semgrep JSON Report."""
|
|
||||||
findings = []
|
|
||||||
try:
|
|
||||||
with open(report_path) as f:
|
|
||||||
data = json.load(f)
|
|
||||||
results = data.get("results", [])
|
|
||||||
for item in results:
|
|
||||||
severity = item.get("extra", {}).get("severity", "INFO").upper()
|
|
||||||
findings.append(Finding(
|
|
||||||
id=item.get("check_id", "unknown"),
|
|
||||||
tool="semgrep",
|
|
||||||
severity=severity,
|
|
||||||
title=item.get("extra", {}).get("message", "Finding"),
|
|
||||||
message=item.get("check_id", ""),
|
|
||||||
file=item.get("path", ""),
|
|
||||||
line=item.get("start", {}).get("line", 0),
|
|
||||||
found_at=datetime.fromtimestamp(report_path.stat().st_mtime).isoformat()
|
|
||||||
))
|
|
||||||
except (json.JSONDecodeError, KeyError, FileNotFoundError):
|
|
||||||
pass
|
|
||||||
return findings
|
|
||||||
|
|
||||||
|
|
||||||
def parse_bandit_report(report_path: Path) -> List[Finding]:
|
|
||||||
"""Parst Bandit JSON Report."""
|
|
||||||
findings = []
|
|
||||||
try:
|
|
||||||
with open(report_path) as f:
|
|
||||||
data = json.load(f)
|
|
||||||
results = data.get("results", [])
|
|
||||||
for item in results:
|
|
||||||
severity = item.get("issue_severity", "LOW").upper()
|
|
||||||
findings.append(Finding(
|
|
||||||
id=item.get("test_id", "unknown"),
|
|
||||||
tool="bandit",
|
|
||||||
severity=severity,
|
|
||||||
title=item.get("issue_text", "Finding"),
|
|
||||||
message=f"CWE: {item.get('issue_cwe', {}).get('id', 'N/A')}",
|
|
||||||
file=item.get("filename", ""),
|
|
||||||
line=item.get("line_number", 0),
|
|
||||||
found_at=datetime.fromtimestamp(report_path.stat().st_mtime).isoformat()
|
|
||||||
))
|
|
||||||
except (json.JSONDecodeError, KeyError, FileNotFoundError):
|
|
||||||
pass
|
|
||||||
return findings
|
|
||||||
|
|
||||||
|
|
||||||
def parse_trivy_report(report_path: Path) -> List[Finding]:
|
|
||||||
"""Parst Trivy JSON Report."""
|
|
||||||
findings = []
|
|
||||||
try:
|
|
||||||
with open(report_path) as f:
|
|
||||||
data = json.load(f)
|
|
||||||
results = data.get("Results", [])
|
|
||||||
for result in results:
|
|
||||||
vulnerabilities = result.get("Vulnerabilities", []) or []
|
|
||||||
target = result.get("Target", "")
|
|
||||||
for vuln in vulnerabilities:
|
|
||||||
severity = vuln.get("Severity", "UNKNOWN").upper()
|
|
||||||
findings.append(Finding(
|
|
||||||
id=vuln.get("VulnerabilityID", "unknown"),
|
|
||||||
tool="trivy",
|
|
||||||
severity=severity,
|
|
||||||
title=vuln.get("Title", vuln.get("VulnerabilityID", "CVE")),
|
|
||||||
message=f"{vuln.get('PkgName', '')} {vuln.get('InstalledVersion', '')}",
|
|
||||||
file=target,
|
|
||||||
line=None,
|
|
||||||
found_at=datetime.fromtimestamp(report_path.stat().st_mtime).isoformat()
|
|
||||||
))
|
|
||||||
except (json.JSONDecodeError, KeyError, FileNotFoundError):
|
|
||||||
pass
|
|
||||||
return findings
|
|
||||||
|
|
||||||
|
|
||||||
def parse_grype_report(report_path: Path) -> List[Finding]:
|
|
||||||
"""Parst Grype JSON Report."""
|
|
||||||
findings = []
|
|
||||||
try:
|
|
||||||
with open(report_path) as f:
|
|
||||||
data = json.load(f)
|
|
||||||
matches = data.get("matches", [])
|
|
||||||
for match in matches:
|
|
||||||
vuln = match.get("vulnerability", {})
|
|
||||||
artifact = match.get("artifact", {})
|
|
||||||
severity = vuln.get("severity", "Unknown").upper()
|
|
||||||
findings.append(Finding(
|
|
||||||
id=vuln.get("id", "unknown"),
|
|
||||||
tool="grype",
|
|
||||||
severity=severity,
|
|
||||||
title=vuln.get("description", vuln.get("id", "CVE"))[:100],
|
|
||||||
message=f"{artifact.get('name', '')} {artifact.get('version', '')}",
|
|
||||||
file=artifact.get("locations", [{}])[0].get("path", "") if artifact.get("locations") else "",
|
|
||||||
line=None,
|
|
||||||
found_at=datetime.fromtimestamp(report_path.stat().st_mtime).isoformat()
|
|
||||||
))
|
|
||||||
except (json.JSONDecodeError, KeyError, FileNotFoundError):
|
|
||||||
pass
|
|
||||||
return findings
|
|
||||||
|
|
||||||
|
|
||||||
# ===========================
|
|
||||||
# Aggregation Functions
|
|
||||||
# ===========================
|
|
||||||
|
|
||||||
def get_all_findings() -> List[Finding]:
|
|
||||||
"""Sammelt alle Findings aus allen Reports."""
|
|
||||||
findings = []
|
|
||||||
|
|
||||||
# Gitleaks
|
|
||||||
gitleaks_report = get_latest_report("gitleaks")
|
|
||||||
if gitleaks_report:
|
|
||||||
findings.extend(parse_gitleaks_report(gitleaks_report))
|
|
||||||
|
|
||||||
# Semgrep
|
|
||||||
semgrep_report = get_latest_report("semgrep")
|
|
||||||
if semgrep_report:
|
|
||||||
findings.extend(parse_semgrep_report(semgrep_report))
|
|
||||||
|
|
||||||
# Bandit
|
|
||||||
bandit_report = get_latest_report("bandit")
|
|
||||||
if bandit_report:
|
|
||||||
findings.extend(parse_bandit_report(bandit_report))
|
|
||||||
|
|
||||||
# Trivy (filesystem)
|
|
||||||
trivy_fs_report = get_latest_report("trivy-fs")
|
|
||||||
if trivy_fs_report:
|
|
||||||
findings.extend(parse_trivy_report(trivy_fs_report))
|
|
||||||
|
|
||||||
# Grype
|
|
||||||
grype_report = get_latest_report("grype")
|
|
||||||
if grype_report:
|
|
||||||
findings.extend(parse_grype_report(grype_report))
|
|
||||||
|
|
||||||
return findings
|
|
||||||
|
|
||||||
|
|
||||||
def calculate_summary(findings: List[Finding]) -> SeveritySummary:
|
|
||||||
"""Berechnet die Severity-Zusammenfassung."""
|
|
||||||
summary = SeveritySummary()
|
|
||||||
for finding in findings:
|
|
||||||
severity = finding.severity.upper()
|
|
||||||
if severity == "CRITICAL":
|
|
||||||
summary.critical += 1
|
|
||||||
elif severity == "HIGH":
|
|
||||||
summary.high += 1
|
|
||||||
elif severity == "MEDIUM":
|
|
||||||
summary.medium += 1
|
|
||||||
elif severity == "LOW":
|
|
||||||
summary.low += 1
|
|
||||||
else:
|
|
||||||
summary.info += 1
|
|
||||||
summary.total = len(findings)
|
|
||||||
return summary
|
|
||||||
@@ -1,59 +1,83 @@
|
|||||||
"""
|
"""
|
||||||
File Processor Service - Dokumentenverarbeitung fuer BreakPilot.
|
File Processor Service - Dokumentenverarbeitung für BreakPilot.
|
||||||
|
|
||||||
Shared Service fuer:
|
Shared Service für:
|
||||||
- OCR (Optical Character Recognition) fuer Handschrift und gedruckten Text
|
- OCR (Optical Character Recognition) für Handschrift und gedruckten Text
|
||||||
- PDF-Parsing und Textextraktion
|
- PDF-Parsing und Textextraktion
|
||||||
- Bildverarbeitung und -optimierung
|
- Bildverarbeitung und -optimierung
|
||||||
- DOCX/DOC Textextraktion
|
- DOCX/DOC Textextraktion
|
||||||
|
|
||||||
Verwendet:
|
Verwendet:
|
||||||
- PaddleOCR fuer deutsche Handschrift (via ImageProcessor)
|
- PaddleOCR für deutsche Handschrift
|
||||||
- PyMuPDF fuer PDF-Verarbeitung
|
- PyMuPDF für PDF-Verarbeitung
|
||||||
- python-docx fuer DOCX-Dateien
|
- python-docx für DOCX-Dateien
|
||||||
- OpenCV fuer Bildvorverarbeitung (via ImageProcessor)
|
- OpenCV für Bildvorverarbeitung
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
import io
|
import io
|
||||||
|
import base64
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional, List, Dict, Any
|
from typing import Optional, List, Dict, Any, Tuple, Union
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
||||||
from .file_processor_types import (
|
|
||||||
FileType,
|
|
||||||
ProcessingMode,
|
|
||||||
ProcessedRegion,
|
|
||||||
ProcessingResult,
|
|
||||||
)
|
|
||||||
from .image_processing import ImageProcessor
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"FileType",
|
class FileType(str, Enum):
|
||||||
"ProcessingMode",
|
"""Unterstützte Dateitypen."""
|
||||||
"ProcessedRegion",
|
PDF = "pdf"
|
||||||
"ProcessingResult",
|
IMAGE = "image"
|
||||||
"FileProcessor",
|
DOCX = "docx"
|
||||||
"get_file_processor",
|
DOC = "doc"
|
||||||
"process_file",
|
TXT = "txt"
|
||||||
"extract_text_from_pdf",
|
UNKNOWN = "unknown"
|
||||||
"ocr_image",
|
|
||||||
"ocr_handwriting",
|
|
||||||
]
|
class ProcessingMode(str, Enum):
|
||||||
|
"""Verarbeitungsmodi."""
|
||||||
|
OCR_HANDWRITING = "ocr_handwriting" # Handschrifterkennung
|
||||||
|
OCR_PRINTED = "ocr_printed" # Gedruckter Text
|
||||||
|
TEXT_EXTRACT = "text_extract" # Textextraktion (PDF/DOCX)
|
||||||
|
MIXED = "mixed" # Kombiniert OCR + Textextraktion
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ProcessedRegion:
|
||||||
|
"""Ein erkannter Textbereich."""
|
||||||
|
text: str
|
||||||
|
confidence: float
|
||||||
|
bbox: Tuple[int, int, int, int] # x1, y1, x2, y2
|
||||||
|
page: int = 1
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ProcessingResult:
|
||||||
|
"""Ergebnis der Dokumentenverarbeitung."""
|
||||||
|
text: str
|
||||||
|
confidence: float
|
||||||
|
regions: List[ProcessedRegion]
|
||||||
|
page_count: int
|
||||||
|
file_type: FileType
|
||||||
|
processing_mode: ProcessingMode
|
||||||
|
metadata: Dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
class FileProcessor:
|
class FileProcessor:
|
||||||
"""
|
"""
|
||||||
Zentrale Dokumentenverarbeitung fuer BreakPilot.
|
Zentrale Dokumentenverarbeitung für BreakPilot.
|
||||||
|
|
||||||
Unterstuetzt:
|
Unterstützt:
|
||||||
- Handschrifterkennung (OCR) fuer Klausuren
|
- Handschrifterkennung (OCR) für Klausuren
|
||||||
- Textextraktion aus PDFs
|
- Textextraktion aus PDFs
|
||||||
- DOCX/DOC Verarbeitung
|
- DOCX/DOC Verarbeitung
|
||||||
- Bildvorverarbeitung fuer bessere OCR-Ergebnisse
|
- Bildvorverarbeitung für bessere OCR-Ergebnisse
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, ocr_lang: str = "de", use_gpu: bool = False):
|
def __init__(self, ocr_lang: str = "de", use_gpu: bool = False):
|
||||||
@@ -61,18 +85,37 @@ class FileProcessor:
|
|||||||
Initialisiert den File Processor.
|
Initialisiert den File Processor.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
ocr_lang: Sprache fuer OCR (default: "de" fuer Deutsch)
|
ocr_lang: Sprache für OCR (default: "de" für Deutsch)
|
||||||
use_gpu: GPU fuer OCR nutzen (beschleunigt Verarbeitung)
|
use_gpu: GPU für OCR nutzen (beschleunigt Verarbeitung)
|
||||||
"""
|
"""
|
||||||
self.ocr_lang = ocr_lang
|
self.ocr_lang = ocr_lang
|
||||||
self.use_gpu = use_gpu
|
self.use_gpu = use_gpu
|
||||||
self._image_processor = ImageProcessor(ocr_lang=ocr_lang, use_gpu=use_gpu)
|
self._ocr_engine = None
|
||||||
|
|
||||||
logger.info(f"FileProcessor initialized (lang={ocr_lang}, gpu={use_gpu})")
|
logger.info(f"FileProcessor initialized (lang={ocr_lang}, gpu={use_gpu})")
|
||||||
|
|
||||||
def detect_file_type(
|
@property
|
||||||
self, file_path: str = None, file_bytes: bytes = None
|
def ocr_engine(self):
|
||||||
) -> FileType:
|
"""Lazy-Loading des OCR-Engines."""
|
||||||
|
if self._ocr_engine is None:
|
||||||
|
self._ocr_engine = self._init_ocr_engine()
|
||||||
|
return self._ocr_engine
|
||||||
|
|
||||||
|
def _init_ocr_engine(self):
|
||||||
|
"""Initialisiert PaddleOCR oder Fallback."""
|
||||||
|
try:
|
||||||
|
from paddleocr import PaddleOCR
|
||||||
|
return PaddleOCR(
|
||||||
|
use_angle_cls=True,
|
||||||
|
lang='german', # Deutsch
|
||||||
|
use_gpu=self.use_gpu,
|
||||||
|
show_log=False
|
||||||
|
)
|
||||||
|
except ImportError:
|
||||||
|
logger.warning("PaddleOCR nicht installiert - verwende Fallback")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def detect_file_type(self, file_path: str = None, file_bytes: bytes = None) -> FileType:
|
||||||
"""
|
"""
|
||||||
Erkennt den Dateityp.
|
Erkennt den Dateityp.
|
||||||
|
|
||||||
@@ -127,9 +170,7 @@ class FileProcessor:
|
|||||||
ProcessingResult mit extrahiertem Text und Metadaten
|
ProcessingResult mit extrahiertem Text und Metadaten
|
||||||
"""
|
"""
|
||||||
if not file_path and not file_bytes:
|
if not file_path and not file_bytes:
|
||||||
raise ValueError(
|
raise ValueError("Entweder file_path oder file_bytes muss angegeben werden")
|
||||||
"Entweder file_path oder file_bytes muss angegeben werden"
|
|
||||||
)
|
|
||||||
|
|
||||||
file_type = self.detect_file_type(file_path, file_bytes)
|
file_type = self.detect_file_type(file_path, file_bytes)
|
||||||
logger.info(f"Processing file of type: {file_type}")
|
logger.info(f"Processing file of type: {file_type}")
|
||||||
@@ -143,7 +184,7 @@ class FileProcessor:
|
|||||||
elif file_type == FileType.TXT:
|
elif file_type == FileType.TXT:
|
||||||
return self._process_txt(file_path, file_bytes)
|
return self._process_txt(file_path, file_bytes)
|
||||||
else:
|
else:
|
||||||
raise ValueError(f"Nicht unterstuetzter Dateityp: {file_type}")
|
raise ValueError(f"Nicht unterstützter Dateityp: {file_type}")
|
||||||
|
|
||||||
def _process_pdf(
|
def _process_pdf(
|
||||||
self,
|
self,
|
||||||
@@ -156,6 +197,7 @@ class FileProcessor:
|
|||||||
import fitz # PyMuPDF
|
import fitz # PyMuPDF
|
||||||
except ImportError:
|
except ImportError:
|
||||||
logger.warning("PyMuPDF nicht installiert - versuche Fallback")
|
logger.warning("PyMuPDF nicht installiert - versuche Fallback")
|
||||||
|
# Fallback: PDF als Bild behandeln
|
||||||
return self._process_image(file_path, file_bytes, mode)
|
return self._process_image(file_path, file_bytes, mode)
|
||||||
|
|
||||||
if file_bytes:
|
if file_bytes:
|
||||||
@@ -169,9 +211,11 @@ class FileProcessor:
|
|||||||
region_count = 0
|
region_count = 0
|
||||||
|
|
||||||
for page_num, page in enumerate(doc, start=1):
|
for page_num, page in enumerate(doc, start=1):
|
||||||
|
# Erst versuchen Text direkt zu extrahieren
|
||||||
page_text = page.get_text()
|
page_text = page.get_text()
|
||||||
|
|
||||||
if page_text.strip() and mode != ProcessingMode.OCR_HANDWRITING:
|
if page_text.strip() and mode != ProcessingMode.OCR_HANDWRITING:
|
||||||
|
# PDF enthält Text (nicht nur Bilder)
|
||||||
all_text.append(page_text)
|
all_text.append(page_text)
|
||||||
all_regions.append(ProcessedRegion(
|
all_regions.append(ProcessedRegion(
|
||||||
text=page_text,
|
text=page_text,
|
||||||
@@ -183,11 +227,11 @@ class FileProcessor:
|
|||||||
region_count += 1
|
region_count += 1
|
||||||
else:
|
else:
|
||||||
# Seite als Bild rendern und OCR anwenden
|
# Seite als Bild rendern und OCR anwenden
|
||||||
pix = page.get_pixmap(matrix=fitz.Matrix(2, 2))
|
pix = page.get_pixmap(matrix=fitz.Matrix(2, 2)) # 2x Auflösung
|
||||||
img_bytes = pix.tobytes("png")
|
img_bytes = pix.tobytes("png")
|
||||||
img = Image.open(io.BytesIO(img_bytes))
|
img = Image.open(io.BytesIO(img_bytes))
|
||||||
|
|
||||||
ocr_result = self._image_processor.ocr_image(img)
|
ocr_result = self._ocr_image(img)
|
||||||
all_text.append(ocr_result["text"])
|
all_text.append(ocr_result["text"])
|
||||||
|
|
||||||
for region in ocr_result["regions"]:
|
for region in ocr_result["regions"]:
|
||||||
@@ -198,9 +242,7 @@ class FileProcessor:
|
|||||||
|
|
||||||
doc.close()
|
doc.close()
|
||||||
|
|
||||||
avg_confidence = (
|
avg_confidence = total_confidence / region_count if region_count > 0 else 0.0
|
||||||
total_confidence / region_count if region_count > 0 else 0.0
|
|
||||||
)
|
|
||||||
|
|
||||||
return ProcessingResult(
|
return ProcessingResult(
|
||||||
text="\n\n".join(all_text),
|
text="\n\n".join(all_text),
|
||||||
@@ -224,8 +266,11 @@ class FileProcessor:
|
|||||||
else:
|
else:
|
||||||
img = Image.open(file_path)
|
img = Image.open(file_path)
|
||||||
|
|
||||||
processed_img = self._image_processor.preprocess_image(img)
|
# Bildvorverarbeitung
|
||||||
ocr_result = self._image_processor.ocr_image(processed_img)
|
processed_img = self._preprocess_image(img)
|
||||||
|
|
||||||
|
# OCR
|
||||||
|
ocr_result = self._ocr_image(processed_img)
|
||||||
|
|
||||||
return ProcessingResult(
|
return ProcessingResult(
|
||||||
text=ocr_result["text"],
|
text=ocr_result["text"],
|
||||||
@@ -261,6 +306,7 @@ class FileProcessor:
|
|||||||
if para.text.strip():
|
if para.text.strip():
|
||||||
paragraphs.append(para.text)
|
paragraphs.append(para.text)
|
||||||
|
|
||||||
|
# Auch Tabellen extrahieren
|
||||||
for table in doc.tables:
|
for table in doc.tables:
|
||||||
for row in table.rows:
|
for row in table.rows:
|
||||||
row_text = " | ".join(cell.text for cell in row.cells)
|
row_text = " | ".join(cell.text for cell in row.cells)
|
||||||
@@ -271,9 +317,12 @@ class FileProcessor:
|
|||||||
|
|
||||||
return ProcessingResult(
|
return ProcessingResult(
|
||||||
text=text,
|
text=text,
|
||||||
confidence=1.0,
|
confidence=1.0, # Direkte Textextraktion
|
||||||
regions=[ProcessedRegion(
|
regions=[ProcessedRegion(
|
||||||
text=text, confidence=1.0, bbox=(0, 0, 0, 0), page=1
|
text=text,
|
||||||
|
confidence=1.0,
|
||||||
|
bbox=(0, 0, 0, 0),
|
||||||
|
page=1
|
||||||
)],
|
)],
|
||||||
page_count=1,
|
page_count=1,
|
||||||
file_type=FileType.DOCX,
|
file_type=FileType.DOCX,
|
||||||
@@ -297,7 +346,10 @@ class FileProcessor:
|
|||||||
text=text,
|
text=text,
|
||||||
confidence=1.0,
|
confidence=1.0,
|
||||||
regions=[ProcessedRegion(
|
regions=[ProcessedRegion(
|
||||||
text=text, confidence=1.0, bbox=(0, 0, 0, 0), page=1
|
text=text,
|
||||||
|
confidence=1.0,
|
||||||
|
bbox=(0, 0, 0, 0),
|
||||||
|
page=1
|
||||||
)],
|
)],
|
||||||
page_count=1,
|
page_count=1,
|
||||||
file_type=FileType.TXT,
|
file_type=FileType.TXT,
|
||||||
@@ -305,13 +357,159 @@ class FileProcessor:
|
|||||||
metadata={"source": file_path or "bytes"}
|
metadata={"source": file_path or "bytes"}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _preprocess_image(self, img: Image.Image) -> Image.Image:
|
||||||
|
"""
|
||||||
|
Vorverarbeitung des Bildes für bessere OCR-Ergebnisse.
|
||||||
|
|
||||||
|
- Konvertierung zu Graustufen
|
||||||
|
- Kontrastverstärkung
|
||||||
|
- Rauschunterdrückung
|
||||||
|
- Binarisierung
|
||||||
|
"""
|
||||||
|
# PIL zu OpenCV
|
||||||
|
cv_img = cv2.cvtColor(np.array(img), cv2.COLOR_RGB2BGR)
|
||||||
|
|
||||||
|
# Zu Graustufen konvertieren
|
||||||
|
gray = cv2.cvtColor(cv_img, cv2.COLOR_BGR2GRAY)
|
||||||
|
|
||||||
|
# Rauschunterdrückung
|
||||||
|
denoised = cv2.fastNlMeansDenoising(gray, None, 10, 7, 21)
|
||||||
|
|
||||||
|
# Kontrastverstärkung (CLAHE)
|
||||||
|
clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8))
|
||||||
|
enhanced = clahe.apply(denoised)
|
||||||
|
|
||||||
|
# Adaptive Binarisierung
|
||||||
|
binary = cv2.adaptiveThreshold(
|
||||||
|
enhanced,
|
||||||
|
255,
|
||||||
|
cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
|
||||||
|
cv2.THRESH_BINARY,
|
||||||
|
11,
|
||||||
|
2
|
||||||
|
)
|
||||||
|
|
||||||
|
# Zurück zu PIL
|
||||||
|
return Image.fromarray(binary)
|
||||||
|
|
||||||
|
def _ocr_image(self, img: Image.Image) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Führt OCR auf einem Bild aus.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict mit text, confidence und regions
|
||||||
|
"""
|
||||||
|
if self.ocr_engine is None:
|
||||||
|
# Fallback wenn kein OCR-Engine verfügbar
|
||||||
|
return {
|
||||||
|
"text": "[OCR nicht verfügbar - bitte PaddleOCR installieren]",
|
||||||
|
"confidence": 0.0,
|
||||||
|
"regions": []
|
||||||
|
}
|
||||||
|
|
||||||
|
# PIL zu numpy array
|
||||||
|
img_array = np.array(img)
|
||||||
|
|
||||||
|
# Wenn Graustufen, zu RGB konvertieren (PaddleOCR erwartet RGB)
|
||||||
|
if len(img_array.shape) == 2:
|
||||||
|
img_array = cv2.cvtColor(img_array, cv2.COLOR_GRAY2RGB)
|
||||||
|
|
||||||
|
# OCR ausführen
|
||||||
|
result = self.ocr_engine.ocr(img_array, cls=True)
|
||||||
|
|
||||||
|
if not result or not result[0]:
|
||||||
|
return {"text": "", "confidence": 0.0, "regions": []}
|
||||||
|
|
||||||
|
all_text = []
|
||||||
|
all_regions = []
|
||||||
|
total_confidence = 0.0
|
||||||
|
|
||||||
|
for line in result[0]:
|
||||||
|
bbox_points = line[0] # [[x1,y1], [x2,y2], [x3,y3], [x4,y4]]
|
||||||
|
text, confidence = line[1]
|
||||||
|
|
||||||
|
# Bounding Box zu x1, y1, x2, y2 konvertieren
|
||||||
|
x_coords = [p[0] for p in bbox_points]
|
||||||
|
y_coords = [p[1] for p in bbox_points]
|
||||||
|
bbox = (
|
||||||
|
int(min(x_coords)),
|
||||||
|
int(min(y_coords)),
|
||||||
|
int(max(x_coords)),
|
||||||
|
int(max(y_coords))
|
||||||
|
)
|
||||||
|
|
||||||
|
all_text.append(text)
|
||||||
|
all_regions.append(ProcessedRegion(
|
||||||
|
text=text,
|
||||||
|
confidence=confidence,
|
||||||
|
bbox=bbox
|
||||||
|
))
|
||||||
|
total_confidence += confidence
|
||||||
|
|
||||||
|
avg_confidence = total_confidence / len(all_regions) if all_regions else 0.0
|
||||||
|
|
||||||
|
return {
|
||||||
|
"text": "\n".join(all_text),
|
||||||
|
"confidence": avg_confidence,
|
||||||
|
"regions": all_regions
|
||||||
|
}
|
||||||
|
|
||||||
def extract_handwriting_regions(
|
def extract_handwriting_regions(
|
||||||
self,
|
self,
|
||||||
img: Image.Image,
|
img: Image.Image,
|
||||||
min_area: int = 500
|
min_area: int = 500
|
||||||
) -> List[Dict[str, Any]]:
|
) -> List[Dict[str, Any]]:
|
||||||
"""Delegate to ImageProcessor."""
|
"""
|
||||||
return self._image_processor.extract_handwriting_regions(img, min_area)
|
Erkennt und extrahiert handschriftliche Bereiche aus einem Bild.
|
||||||
|
|
||||||
|
Nützlich für Klausuren mit gedruckten Fragen und handschriftlichen Antworten.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
img: Eingabebild
|
||||||
|
min_area: Minimale Fläche für erkannte Regionen
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Liste von Regionen mit Koordinaten und erkanntem Text
|
||||||
|
"""
|
||||||
|
# Bildvorverarbeitung
|
||||||
|
cv_img = cv2.cvtColor(np.array(img), cv2.COLOR_RGB2BGR)
|
||||||
|
gray = cv2.cvtColor(cv_img, cv2.COLOR_BGR2GRAY)
|
||||||
|
|
||||||
|
# Kanten erkennen
|
||||||
|
edges = cv2.Canny(gray, 50, 150)
|
||||||
|
|
||||||
|
# Morphologische Operationen zum Verbinden
|
||||||
|
kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (15, 5))
|
||||||
|
dilated = cv2.dilate(edges, kernel, iterations=2)
|
||||||
|
|
||||||
|
# Konturen finden
|
||||||
|
contours, _ = cv2.findContours(dilated, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
||||||
|
|
||||||
|
regions = []
|
||||||
|
for contour in contours:
|
||||||
|
area = cv2.contourArea(contour)
|
||||||
|
if area < min_area:
|
||||||
|
continue
|
||||||
|
|
||||||
|
x, y, w, h = cv2.boundingRect(contour)
|
||||||
|
|
||||||
|
# Region ausschneiden
|
||||||
|
region_img = img.crop((x, y, x + w, y + h))
|
||||||
|
|
||||||
|
# OCR auf Region anwenden
|
||||||
|
ocr_result = self._ocr_image(region_img)
|
||||||
|
|
||||||
|
regions.append({
|
||||||
|
"bbox": (x, y, x + w, y + h),
|
||||||
|
"area": area,
|
||||||
|
"text": ocr_result["text"],
|
||||||
|
"confidence": ocr_result["confidence"]
|
||||||
|
})
|
||||||
|
|
||||||
|
# Nach Y-Position sortieren (oben nach unten)
|
||||||
|
regions.sort(key=lambda r: r["bbox"][1])
|
||||||
|
|
||||||
|
return regions
|
||||||
|
|
||||||
|
|
||||||
# Singleton-Instanz
|
# Singleton-Instanz
|
||||||
@@ -319,7 +517,7 @@ _file_processor: Optional[FileProcessor] = None
|
|||||||
|
|
||||||
|
|
||||||
def get_file_processor() -> FileProcessor:
|
def get_file_processor() -> FileProcessor:
|
||||||
"""Gibt Singleton-Instanz des File Processors zurueck."""
|
"""Gibt Singleton-Instanz des File Processors zurück."""
|
||||||
global _file_processor
|
global _file_processor
|
||||||
if _file_processor is None:
|
if _file_processor is None:
|
||||||
_file_processor = FileProcessor()
|
_file_processor = FileProcessor()
|
||||||
@@ -332,26 +530,34 @@ def process_file(
|
|||||||
file_bytes: bytes = None,
|
file_bytes: bytes = None,
|
||||||
mode: ProcessingMode = ProcessingMode.MIXED
|
mode: ProcessingMode = ProcessingMode.MIXED
|
||||||
) -> ProcessingResult:
|
) -> ProcessingResult:
|
||||||
"""Convenience function zum Verarbeiten einer Datei."""
|
"""
|
||||||
|
Convenience function zum Verarbeiten einer Datei.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_path: Pfad zur Datei
|
||||||
|
file_bytes: Dateiinhalt als Bytes
|
||||||
|
mode: Verarbeitungsmodus
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ProcessingResult
|
||||||
|
"""
|
||||||
processor = get_file_processor()
|
processor = get_file_processor()
|
||||||
return processor.process(file_path, file_bytes, mode)
|
return processor.process(file_path, file_bytes, mode)
|
||||||
|
|
||||||
|
|
||||||
def extract_text_from_pdf(
|
def extract_text_from_pdf(file_path: str = None, file_bytes: bytes = None) -> str:
|
||||||
file_path: str = None, file_bytes: bytes = None
|
|
||||||
) -> str:
|
|
||||||
"""Extrahiert Text aus einer PDF-Datei."""
|
"""Extrahiert Text aus einer PDF-Datei."""
|
||||||
result = process_file(file_path, file_bytes, ProcessingMode.TEXT_EXTRACT)
|
result = process_file(file_path, file_bytes, ProcessingMode.TEXT_EXTRACT)
|
||||||
return result.text
|
return result.text
|
||||||
|
|
||||||
|
|
||||||
def ocr_image(file_path: str = None, file_bytes: bytes = None) -> str:
|
def ocr_image(file_path: str = None, file_bytes: bytes = None) -> str:
|
||||||
"""Fuehrt OCR auf einem Bild aus."""
|
"""Führt OCR auf einem Bild aus."""
|
||||||
result = process_file(file_path, file_bytes, ProcessingMode.OCR_PRINTED)
|
result = process_file(file_path, file_bytes, ProcessingMode.OCR_PRINTED)
|
||||||
return result.text
|
return result.text
|
||||||
|
|
||||||
|
|
||||||
def ocr_handwriting(file_path: str = None, file_bytes: bytes = None) -> str:
|
def ocr_handwriting(file_path: str = None, file_bytes: bytes = None) -> str:
|
||||||
"""Fuehrt Handschrift-OCR auf einem Bild aus."""
|
"""Führt Handschrift-OCR auf einem Bild aus."""
|
||||||
result = process_file(file_path, file_bytes, ProcessingMode.OCR_HANDWRITING)
|
result = process_file(file_path, file_bytes, ProcessingMode.OCR_HANDWRITING)
|
||||||
return result.text
|
return result.text
|
||||||
|
|||||||
@@ -1,46 +0,0 @@
|
|||||||
"""
|
|
||||||
Shared types for file processing and image processing modules.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from typing import Optional, List, Dict, Any, Tuple
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from enum import Enum
|
|
||||||
|
|
||||||
|
|
||||||
class FileType(str, Enum):
|
|
||||||
"""Unterstuetzte Dateitypen."""
|
|
||||||
PDF = "pdf"
|
|
||||||
IMAGE = "image"
|
|
||||||
DOCX = "docx"
|
|
||||||
DOC = "doc"
|
|
||||||
TXT = "txt"
|
|
||||||
UNKNOWN = "unknown"
|
|
||||||
|
|
||||||
|
|
||||||
class ProcessingMode(str, Enum):
|
|
||||||
"""Verarbeitungsmodi."""
|
|
||||||
OCR_HANDWRITING = "ocr_handwriting" # Handschrifterkennung
|
|
||||||
OCR_PRINTED = "ocr_printed" # Gedruckter Text
|
|
||||||
TEXT_EXTRACT = "text_extract" # Textextraktion (PDF/DOCX)
|
|
||||||
MIXED = "mixed" # Kombiniert OCR + Textextraktion
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class ProcessedRegion:
|
|
||||||
"""Ein erkannter Textbereich."""
|
|
||||||
text: str
|
|
||||||
confidence: float
|
|
||||||
bbox: Tuple[int, int, int, int] # x1, y1, x2, y2
|
|
||||||
page: int = 1
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class ProcessingResult:
|
|
||||||
"""Ergebnis der Dokumentenverarbeitung."""
|
|
||||||
text: str
|
|
||||||
confidence: float
|
|
||||||
regions: List[ProcessedRegion]
|
|
||||||
page_count: int
|
|
||||||
file_type: FileType
|
|
||||||
processing_mode: ProcessingMode
|
|
||||||
metadata: Dict[str, Any]
|
|
||||||
@@ -1,213 +0,0 @@
|
|||||||
"""
|
|
||||||
Image Processing and OCR Service.
|
|
||||||
|
|
||||||
Handles:
|
|
||||||
- Image preprocessing for better OCR results (grayscale, denoising, binarization)
|
|
||||||
- PaddleOCR integration for text recognition
|
|
||||||
- Handwriting region extraction from scanned documents
|
|
||||||
|
|
||||||
Used by FileProcessor for image and PDF-to-image OCR workflows.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from typing import Optional, List, Dict, Any, Tuple
|
|
||||||
|
|
||||||
import cv2
|
|
||||||
import numpy as np
|
|
||||||
from PIL import Image
|
|
||||||
|
|
||||||
from .file_processor_types import ProcessedRegion
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class ImageProcessor:
|
|
||||||
"""
|
|
||||||
Image preprocessing and OCR for BreakPilot.
|
|
||||||
|
|
||||||
Supports:
|
|
||||||
- PaddleOCR for German handwriting and printed text
|
|
||||||
- OpenCV-based preprocessing (denoising, CLAHE, adaptive binarization)
|
|
||||||
- Handwriting region extraction for exam correction
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, ocr_lang: str = "de", use_gpu: bool = False):
|
|
||||||
self.ocr_lang = ocr_lang
|
|
||||||
self.use_gpu = use_gpu
|
|
||||||
self._ocr_engine = None
|
|
||||||
|
|
||||||
@property
|
|
||||||
def ocr_engine(self):
|
|
||||||
"""Lazy-Loading des OCR-Engines."""
|
|
||||||
if self._ocr_engine is None:
|
|
||||||
self._ocr_engine = self._init_ocr_engine()
|
|
||||||
return self._ocr_engine
|
|
||||||
|
|
||||||
def _init_ocr_engine(self):
|
|
||||||
"""Initialisiert PaddleOCR oder Fallback."""
|
|
||||||
try:
|
|
||||||
from paddleocr import PaddleOCR
|
|
||||||
return PaddleOCR(
|
|
||||||
use_angle_cls=True,
|
|
||||||
lang='german',
|
|
||||||
use_gpu=self.use_gpu,
|
|
||||||
show_log=False
|
|
||||||
)
|
|
||||||
except ImportError:
|
|
||||||
logger.warning("PaddleOCR nicht installiert - verwende Fallback")
|
|
||||||
return None
|
|
||||||
|
|
||||||
def preprocess_image(self, img: Image.Image) -> Image.Image:
|
|
||||||
"""
|
|
||||||
Vorverarbeitung des Bildes fuer bessere OCR-Ergebnisse.
|
|
||||||
|
|
||||||
- Konvertierung zu Graustufen
|
|
||||||
- Kontrastverstaerkung
|
|
||||||
- Rauschunterdrueckung
|
|
||||||
- Binarisierung
|
|
||||||
"""
|
|
||||||
# PIL zu OpenCV
|
|
||||||
cv_img = cv2.cvtColor(np.array(img), cv2.COLOR_RGB2BGR)
|
|
||||||
|
|
||||||
# Zu Graustufen konvertieren
|
|
||||||
gray = cv2.cvtColor(cv_img, cv2.COLOR_BGR2GRAY)
|
|
||||||
|
|
||||||
# Rauschunterdrueckung
|
|
||||||
denoised = cv2.fastNlMeansDenoising(gray, None, 10, 7, 21)
|
|
||||||
|
|
||||||
# Kontrastverstaerkung (CLAHE)
|
|
||||||
clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8))
|
|
||||||
enhanced = clahe.apply(denoised)
|
|
||||||
|
|
||||||
# Adaptive Binarisierung
|
|
||||||
binary = cv2.adaptiveThreshold(
|
|
||||||
enhanced,
|
|
||||||
255,
|
|
||||||
cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
|
|
||||||
cv2.THRESH_BINARY,
|
|
||||||
11,
|
|
||||||
2
|
|
||||||
)
|
|
||||||
|
|
||||||
# Zurueck zu PIL
|
|
||||||
return Image.fromarray(binary)
|
|
||||||
|
|
||||||
def ocr_image(self, img: Image.Image) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
Fuehrt OCR auf einem Bild aus.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dict mit text, confidence und regions
|
|
||||||
"""
|
|
||||||
if self.ocr_engine is None:
|
|
||||||
return {
|
|
||||||
"text": "[OCR nicht verfuegbar - bitte PaddleOCR installieren]",
|
|
||||||
"confidence": 0.0,
|
|
||||||
"regions": []
|
|
||||||
}
|
|
||||||
|
|
||||||
# PIL zu numpy array
|
|
||||||
img_array = np.array(img)
|
|
||||||
|
|
||||||
# Wenn Graustufen, zu RGB konvertieren (PaddleOCR erwartet RGB)
|
|
||||||
if len(img_array.shape) == 2:
|
|
||||||
img_array = cv2.cvtColor(img_array, cv2.COLOR_GRAY2RGB)
|
|
||||||
|
|
||||||
# OCR ausfuehren
|
|
||||||
result = self.ocr_engine.ocr(img_array, cls=True)
|
|
||||||
|
|
||||||
if not result or not result[0]:
|
|
||||||
return {"text": "", "confidence": 0.0, "regions": []}
|
|
||||||
|
|
||||||
all_text = []
|
|
||||||
all_regions = []
|
|
||||||
total_confidence = 0.0
|
|
||||||
|
|
||||||
for line in result[0]:
|
|
||||||
bbox_points = line[0] # [[x1,y1], [x2,y2], [x3,y3], [x4,y4]]
|
|
||||||
text, confidence = line[1]
|
|
||||||
|
|
||||||
# Bounding Box zu x1, y1, x2, y2 konvertieren
|
|
||||||
x_coords = [p[0] for p in bbox_points]
|
|
||||||
y_coords = [p[1] for p in bbox_points]
|
|
||||||
bbox = (
|
|
||||||
int(min(x_coords)),
|
|
||||||
int(min(y_coords)),
|
|
||||||
int(max(x_coords)),
|
|
||||||
int(max(y_coords))
|
|
||||||
)
|
|
||||||
|
|
||||||
all_text.append(text)
|
|
||||||
all_regions.append(ProcessedRegion(
|
|
||||||
text=text,
|
|
||||||
confidence=confidence,
|
|
||||||
bbox=bbox
|
|
||||||
))
|
|
||||||
total_confidence += confidence
|
|
||||||
|
|
||||||
avg_confidence = total_confidence / len(all_regions) if all_regions else 0.0
|
|
||||||
|
|
||||||
return {
|
|
||||||
"text": "\n".join(all_text),
|
|
||||||
"confidence": avg_confidence,
|
|
||||||
"regions": all_regions
|
|
||||||
}
|
|
||||||
|
|
||||||
def extract_handwriting_regions(
|
|
||||||
self,
|
|
||||||
img: Image.Image,
|
|
||||||
min_area: int = 500
|
|
||||||
) -> List[Dict[str, Any]]:
|
|
||||||
"""
|
|
||||||
Erkennt und extrahiert handschriftliche Bereiche aus einem Bild.
|
|
||||||
|
|
||||||
Nuetzlich fuer Klausuren mit gedruckten Fragen und handschriftlichen Antworten.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
img: Eingabebild
|
|
||||||
min_area: Minimale Flaeche fuer erkannte Regionen
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Liste von Regionen mit Koordinaten und erkanntem Text
|
|
||||||
"""
|
|
||||||
# Bildvorverarbeitung
|
|
||||||
cv_img = cv2.cvtColor(np.array(img), cv2.COLOR_RGB2BGR)
|
|
||||||
gray = cv2.cvtColor(cv_img, cv2.COLOR_BGR2GRAY)
|
|
||||||
|
|
||||||
# Kanten erkennen
|
|
||||||
edges = cv2.Canny(gray, 50, 150)
|
|
||||||
|
|
||||||
# Morphologische Operationen zum Verbinden
|
|
||||||
kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (15, 5))
|
|
||||||
dilated = cv2.dilate(edges, kernel, iterations=2)
|
|
||||||
|
|
||||||
# Konturen finden
|
|
||||||
contours, _ = cv2.findContours(
|
|
||||||
dilated, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE
|
|
||||||
)
|
|
||||||
|
|
||||||
regions = []
|
|
||||||
for contour in contours:
|
|
||||||
area = cv2.contourArea(contour)
|
|
||||||
if area < min_area:
|
|
||||||
continue
|
|
||||||
|
|
||||||
x, y, w, h = cv2.boundingRect(contour)
|
|
||||||
|
|
||||||
# Region ausschneiden
|
|
||||||
region_img = img.crop((x, y, x + w, y + h))
|
|
||||||
|
|
||||||
# OCR auf Region anwenden
|
|
||||||
ocr_result = self.ocr_image(region_img)
|
|
||||||
|
|
||||||
regions.append({
|
|
||||||
"bbox": (x, y, x + w, y + h),
|
|
||||||
"area": area,
|
|
||||||
"text": ocr_result["text"],
|
|
||||||
"confidence": ocr_result["confidence"]
|
|
||||||
})
|
|
||||||
|
|
||||||
# Nach Y-Position sortieren (oben nach unten)
|
|
||||||
regions.sort(key=lambda r: r["bbox"][1])
|
|
||||||
|
|
||||||
return regions
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
"""
|
|
||||||
PDF Models - Dataclasses fuer PDF-Generierung.
|
|
||||||
|
|
||||||
Enthaelt alle Datenmodelle die von PDFService und den Convenience-Funktionen
|
|
||||||
in pdf_service.py verwendet werden.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from typing import Any, Dict, List, Optional
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class SchoolInfo:
|
|
||||||
"""Schulinformationen fuer Header."""
|
|
||||||
name: str
|
|
||||||
address: str
|
|
||||||
phone: str
|
|
||||||
email: str
|
|
||||||
logo_path: Optional[str] = None
|
|
||||||
website: Optional[str] = None
|
|
||||||
principal: Optional[str] = None
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class LetterData:
|
|
||||||
"""Daten fuer Elternbrief-PDF."""
|
|
||||||
recipient_name: str
|
|
||||||
recipient_address: str
|
|
||||||
student_name: str
|
|
||||||
student_class: str
|
|
||||||
subject: str
|
|
||||||
content: str
|
|
||||||
date: str
|
|
||||||
teacher_name: str
|
|
||||||
teacher_title: Optional[str] = None
|
|
||||||
school_info: Optional[SchoolInfo] = None
|
|
||||||
letter_type: str = "general" # general, halbjahr, fehlzeiten, elternabend, lob
|
|
||||||
tone: str = "professional"
|
|
||||||
legal_references: Optional[List[Dict[str, str]]] = None
|
|
||||||
gfk_principles_applied: Optional[List[str]] = None
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class CertificateData:
|
|
||||||
"""Daten fuer Zeugnis-PDF."""
|
|
||||||
student_name: str
|
|
||||||
student_birthdate: str
|
|
||||||
student_class: str
|
|
||||||
school_year: str
|
|
||||||
certificate_type: str # halbjahr, jahres, abschluss
|
|
||||||
subjects: List[Dict[str, Any]] # [{name, grade, note}]
|
|
||||||
attendance: Dict[str, int] # {days_absent, days_excused, days_unexcused}
|
|
||||||
remarks: Optional[str] = None
|
|
||||||
class_teacher: str = ""
|
|
||||||
principal: str = ""
|
|
||||||
school_info: Optional[SchoolInfo] = None
|
|
||||||
issue_date: str = ""
|
|
||||||
social_behavior: Optional[str] = None # A, B, C, D
|
|
||||||
work_behavior: Optional[str] = None # A, B, C, D
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class StudentInfo:
|
|
||||||
"""Schuelerinformationen fuer Korrektur-PDFs."""
|
|
||||||
student_id: str
|
|
||||||
name: str
|
|
||||||
class_name: str
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class CorrectionData:
|
|
||||||
"""Daten fuer Korrektur-Uebersicht PDF."""
|
|
||||||
student: StudentInfo
|
|
||||||
exam_title: str
|
|
||||||
subject: str
|
|
||||||
date: str
|
|
||||||
max_points: int
|
|
||||||
achieved_points: int
|
|
||||||
grade: str
|
|
||||||
percentage: float
|
|
||||||
corrections: List[Dict[str, Any]] # [{question, answer, points, feedback}]
|
|
||||||
teacher_notes: str = ""
|
|
||||||
ai_feedback: str = ""
|
|
||||||
grade_distribution: Optional[Dict[str, int]] = None # {note: anzahl}
|
|
||||||
class_average: Optional[float] = None
|
|
||||||
@@ -1,54 +1,115 @@
|
|||||||
"""
|
"""
|
||||||
PDF Service - Zentrale PDF-Generierung fuer BreakPilot.
|
PDF Service - Zentrale PDF-Generierung für BreakPilot.
|
||||||
|
|
||||||
Shared Service fuer:
|
Shared Service für:
|
||||||
- Letters (Elternbriefe)
|
- Letters (Elternbriefe)
|
||||||
- Zeugnisse (Schulzeugnisse)
|
- Zeugnisse (Schulzeugnisse)
|
||||||
- Correction (Korrektur-Uebersichten)
|
- Correction (Korrektur-Übersichten)
|
||||||
|
|
||||||
Verwendet WeasyPrint fuer PDF-Rendering und Jinja2 fuer Templates.
|
Verwendet WeasyPrint für PDF-Rendering und Jinja2 für Templates.
|
||||||
|
|
||||||
Datenmodelle: services/pdf_models.py
|
|
||||||
HTML-Templates: services/pdf_templates.py
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict, Optional
|
from typing import Any, Dict, Optional, List
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
||||||
from weasyprint import HTML, CSS
|
from weasyprint import HTML, CSS
|
||||||
from weasyprint.text.fonts import FontConfiguration
|
from weasyprint.text.fonts import FontConfiguration
|
||||||
|
|
||||||
from .pdf_models import (
|
|
||||||
SchoolInfo,
|
|
||||||
LetterData,
|
|
||||||
CertificateData,
|
|
||||||
StudentInfo,
|
|
||||||
CorrectionData,
|
|
||||||
)
|
|
||||||
from .pdf_templates import (
|
|
||||||
get_base_css,
|
|
||||||
get_letter_template_html,
|
|
||||||
get_certificate_template_html,
|
|
||||||
get_correction_template_html,
|
|
||||||
)
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Template directory
|
# Template directory
|
||||||
TEMPLATES_DIR = Path(__file__).parent.parent / "templates" / "pdf"
|
TEMPLATES_DIR = Path(__file__).parent.parent / "templates" / "pdf"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SchoolInfo:
|
||||||
|
"""Schulinformationen für Header."""
|
||||||
|
name: str
|
||||||
|
address: str
|
||||||
|
phone: str
|
||||||
|
email: str
|
||||||
|
logo_path: Optional[str] = None
|
||||||
|
website: Optional[str] = None
|
||||||
|
principal: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class LetterData:
|
||||||
|
"""Daten für Elternbrief-PDF."""
|
||||||
|
recipient_name: str
|
||||||
|
recipient_address: str
|
||||||
|
student_name: str
|
||||||
|
student_class: str
|
||||||
|
subject: str
|
||||||
|
content: str
|
||||||
|
date: str
|
||||||
|
teacher_name: str
|
||||||
|
teacher_title: Optional[str] = None
|
||||||
|
school_info: Optional[SchoolInfo] = None
|
||||||
|
letter_type: str = "general" # general, halbjahr, fehlzeiten, elternabend, lob
|
||||||
|
tone: str = "professional"
|
||||||
|
legal_references: Optional[List[Dict[str, str]]] = None
|
||||||
|
gfk_principles_applied: Optional[List[str]] = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CertificateData:
|
||||||
|
"""Daten für Zeugnis-PDF."""
|
||||||
|
student_name: str
|
||||||
|
student_birthdate: str
|
||||||
|
student_class: str
|
||||||
|
school_year: str
|
||||||
|
certificate_type: str # halbjahr, jahres, abschluss
|
||||||
|
subjects: List[Dict[str, Any]] # [{name, grade, note}]
|
||||||
|
attendance: Dict[str, int] # {days_absent, days_excused, days_unexcused}
|
||||||
|
remarks: Optional[str] = None
|
||||||
|
class_teacher: str = ""
|
||||||
|
principal: str = ""
|
||||||
|
school_info: Optional[SchoolInfo] = None
|
||||||
|
issue_date: str = ""
|
||||||
|
social_behavior: Optional[str] = None # A, B, C, D
|
||||||
|
work_behavior: Optional[str] = None # A, B, C, D
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class StudentInfo:
|
||||||
|
"""Schülerinformationen für Korrektur-PDFs."""
|
||||||
|
student_id: str
|
||||||
|
name: str
|
||||||
|
class_name: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CorrectionData:
|
||||||
|
"""Daten für Korrektur-Übersicht PDF."""
|
||||||
|
student: StudentInfo
|
||||||
|
exam_title: str
|
||||||
|
subject: str
|
||||||
|
date: str
|
||||||
|
max_points: int
|
||||||
|
achieved_points: int
|
||||||
|
grade: str
|
||||||
|
percentage: float
|
||||||
|
corrections: List[Dict[str, Any]] # [{question, answer, points, feedback}]
|
||||||
|
teacher_notes: str = ""
|
||||||
|
ai_feedback: str = ""
|
||||||
|
grade_distribution: Optional[Dict[str, int]] = None # {note: anzahl}
|
||||||
|
class_average: Optional[float] = None
|
||||||
|
|
||||||
|
|
||||||
class PDFService:
|
class PDFService:
|
||||||
"""
|
"""
|
||||||
Zentrale PDF-Generierung fuer BreakPilot.
|
Zentrale PDF-Generierung für BreakPilot.
|
||||||
|
|
||||||
Unterstuetzt:
|
Unterstützt:
|
||||||
- Elternbriefe mit GFK-Prinzipien und rechtlichen Referenzen
|
- Elternbriefe mit GFK-Prinzipien und rechtlichen Referenzen
|
||||||
- Schulzeugnisse (Halbjahr, Jahres, Abschluss)
|
- Schulzeugnisse (Halbjahr, Jahres, Abschluss)
|
||||||
- Korrektur-Uebersichten fuer Klausuren
|
- Korrektur-Übersichten für Klausuren
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, templates_dir: Optional[Path] = None):
|
def __init__(self, templates_dir: Optional[Path] = None):
|
||||||
@@ -82,7 +143,7 @@ class PDFService:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _date_format(value: str, format_str: str = "%d.%m.%Y") -> str:
|
def _date_format(value: str, format_str: str = "%d.%m.%Y") -> str:
|
||||||
"""Formatiert Datum fuer deutsche Darstellung."""
|
"""Formatiert Datum für deutsche Darstellung."""
|
||||||
if not value:
|
if not value:
|
||||||
return ""
|
return ""
|
||||||
try:
|
try:
|
||||||
@@ -93,10 +154,10 @@ class PDFService:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _grade_color(grade: str) -> str:
|
def _grade_color(grade: str) -> str:
|
||||||
"""Gibt Farbe basierend auf Note zurueck."""
|
"""Gibt Farbe basierend auf Note zurück."""
|
||||||
grade_colors = {
|
grade_colors = {
|
||||||
"1": "#27ae60", # Gruen
|
"1": "#27ae60", # Grün
|
||||||
"2": "#2ecc71", # Hellgruen
|
"2": "#2ecc71", # Hellgrün
|
||||||
"3": "#f1c40f", # Gelb
|
"3": "#f1c40f", # Gelb
|
||||||
"4": "#e67e22", # Orange
|
"4": "#e67e22", # Orange
|
||||||
"5": "#e74c3c", # Rot
|
"5": "#e74c3c", # Rot
|
||||||
@@ -109,12 +170,227 @@ class PDFService:
|
|||||||
return grade_colors.get(str(grade), "#333333")
|
return grade_colors.get(str(grade), "#333333")
|
||||||
|
|
||||||
def _get_base_css(self) -> str:
|
def _get_base_css(self) -> str:
|
||||||
"""Gibt Basis-CSS fuer alle PDFs zurueck (delegiert an pdf_templates)."""
|
"""Gibt Basis-CSS für alle PDFs zurück."""
|
||||||
return get_base_css()
|
return """
|
||||||
|
@page {
|
||||||
|
size: A4;
|
||||||
|
margin: 2cm 2.5cm;
|
||||||
|
@top-right {
|
||||||
|
content: counter(page) " / " counter(pages);
|
||||||
|
font-size: 9pt;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'DejaVu Sans', 'Liberation Sans', Arial, sans-serif;
|
||||||
|
font-size: 11pt;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1, h2, h3 {
|
||||||
|
font-weight: bold;
|
||||||
|
margin-top: 1em;
|
||||||
|
margin-bottom: 0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 { font-size: 16pt; }
|
||||||
|
h2 { font-size: 14pt; }
|
||||||
|
h3 { font-size: 12pt; }
|
||||||
|
|
||||||
|
.header {
|
||||||
|
border-bottom: 2px solid #2c3e50;
|
||||||
|
padding-bottom: 15px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.school-name {
|
||||||
|
font-size: 18pt;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #2c3e50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.school-info {
|
||||||
|
font-size: 9pt;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.letter-date {
|
||||||
|
text-align: right;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipient {
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subject {
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
text-align: justify;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signature {
|
||||||
|
margin-top: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legal-references {
|
||||||
|
font-size: 9pt;
|
||||||
|
color: #666;
|
||||||
|
border-top: 1px solid #ddd;
|
||||||
|
margin-top: 30px;
|
||||||
|
padding-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gfk-badge {
|
||||||
|
display: inline-block;
|
||||||
|
background: #e8f5e9;
|
||||||
|
color: #27ae60;
|
||||||
|
font-size: 8pt;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 10px;
|
||||||
|
margin-right: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Zeugnis-Styles */
|
||||||
|
.certificate-header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.certificate-title {
|
||||||
|
font-size: 20pt;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.student-info {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
padding: 15px;
|
||||||
|
background: #f9f9f9;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grades-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grades-table th,
|
||||||
|
.grades-table td {
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
padding: 8px 12px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grades-table th {
|
||||||
|
background: #2c3e50;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grades-table tr:nth-child(even) {
|
||||||
|
background: #f9f9f9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grade-cell {
|
||||||
|
text-align: center;
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 12pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attendance-box {
|
||||||
|
background: #fff3cd;
|
||||||
|
padding: 15px;
|
||||||
|
border-radius: 5px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signatures-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-top: 50px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signature-block {
|
||||||
|
text-align: center;
|
||||||
|
width: 40%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signature-line {
|
||||||
|
border-top: 1px solid #333;
|
||||||
|
margin-top: 40px;
|
||||||
|
padding-top: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Korrektur-Styles */
|
||||||
|
.exam-header {
|
||||||
|
background: #2c3e50;
|
||||||
|
color: white;
|
||||||
|
padding: 15px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-box {
|
||||||
|
background: #e8f5e9;
|
||||||
|
padding: 20px;
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-grade {
|
||||||
|
font-size: 36pt;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-points {
|
||||||
|
font-size: 14pt;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.corrections-list {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.correction-item {
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
padding: 15px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.correction-question {
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.correction-feedback {
|
||||||
|
background: #fff8e1;
|
||||||
|
padding: 10px;
|
||||||
|
margin-top: 10px;
|
||||||
|
border-left: 3px solid #ffc107;
|
||||||
|
font-size: 10pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-table {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-table td {
|
||||||
|
padding: 5px 10px;
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
def generate_letter_pdf(self, data: LetterData) -> bytes:
|
def generate_letter_pdf(self, data: LetterData) -> bytes:
|
||||||
"""
|
"""
|
||||||
Generiert PDF fuer Elternbrief.
|
Generiert PDF für Elternbrief.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
data: LetterData mit allen Briefinformationen
|
data: LetterData mit allen Briefinformationen
|
||||||
@@ -141,7 +417,7 @@ class PDFService:
|
|||||||
|
|
||||||
def generate_certificate_pdf(self, data: CertificateData) -> bytes:
|
def generate_certificate_pdf(self, data: CertificateData) -> bytes:
|
||||||
"""
|
"""
|
||||||
Generiert PDF fuer Schulzeugnis.
|
Generiert PDF für Schulzeugnis.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
data: CertificateData mit allen Zeugnisinformationen
|
data: CertificateData mit allen Zeugnisinformationen
|
||||||
@@ -168,7 +444,7 @@ class PDFService:
|
|||||||
|
|
||||||
def generate_correction_pdf(self, data: CorrectionData) -> bytes:
|
def generate_correction_pdf(self, data: CorrectionData) -> bytes:
|
||||||
"""
|
"""
|
||||||
Generiert PDF fuer Korrektur-Uebersicht.
|
Generiert PDF für Korrektur-Übersicht.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
data: CorrectionData mit allen Korrekturinformationen
|
data: CorrectionData mit allen Korrekturinformationen
|
||||||
@@ -194,29 +470,322 @@ class PDFService:
|
|||||||
return pdf_bytes
|
return pdf_bytes
|
||||||
|
|
||||||
def _get_letter_template(self):
|
def _get_letter_template(self):
|
||||||
"""Gibt Letter-Template zurueck (inline falls Datei nicht existiert)."""
|
"""Gibt Letter-Template zurück (inline falls Datei nicht existiert)."""
|
||||||
template_path = self.templates_dir / "letter.html"
|
template_path = self.templates_dir / "letter.html"
|
||||||
if template_path.exists():
|
if template_path.exists():
|
||||||
return self.jinja_env.get_template("letter.html")
|
return self.jinja_env.get_template("letter.html")
|
||||||
|
|
||||||
# Inline-Template als Fallback
|
# Inline-Template als Fallback
|
||||||
return self.jinja_env.from_string(get_letter_template_html())
|
return self.jinja_env.from_string(self._get_letter_template_html())
|
||||||
|
|
||||||
def _get_certificate_template(self):
|
def _get_certificate_template(self):
|
||||||
"""Gibt Certificate-Template zurueck."""
|
"""Gibt Certificate-Template zurück."""
|
||||||
template_path = self.templates_dir / "certificate.html"
|
template_path = self.templates_dir / "certificate.html"
|
||||||
if template_path.exists():
|
if template_path.exists():
|
||||||
return self.jinja_env.get_template("certificate.html")
|
return self.jinja_env.get_template("certificate.html")
|
||||||
|
|
||||||
return self.jinja_env.from_string(get_certificate_template_html())
|
return self.jinja_env.from_string(self._get_certificate_template_html())
|
||||||
|
|
||||||
def _get_correction_template(self):
|
def _get_correction_template(self):
|
||||||
"""Gibt Correction-Template zurueck."""
|
"""Gibt Correction-Template zurück."""
|
||||||
template_path = self.templates_dir / "correction.html"
|
template_path = self.templates_dir / "correction.html"
|
||||||
if template_path.exists():
|
if template_path.exists():
|
||||||
return self.jinja_env.get_template("correction.html")
|
return self.jinja_env.get_template("correction.html")
|
||||||
|
|
||||||
return self.jinja_env.from_string(get_correction_template_html())
|
return self.jinja_env.from_string(self._get_correction_template_html())
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _get_letter_template_html() -> str:
|
||||||
|
"""Inline HTML-Template für Elternbriefe."""
|
||||||
|
return """
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>{{ data.subject }}</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="header">
|
||||||
|
{% if data.school_info %}
|
||||||
|
<div class="school-name">{{ data.school_info.name }}</div>
|
||||||
|
<div class="school-info">
|
||||||
|
{{ data.school_info.address }}<br>
|
||||||
|
Tel: {{ data.school_info.phone }} | E-Mail: {{ data.school_info.email }}
|
||||||
|
{% if data.school_info.website %} | {{ data.school_info.website }}{% endif %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="school-name">Schule</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="letter-date">
|
||||||
|
{{ data.date }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="recipient">
|
||||||
|
{{ data.recipient_name }}<br>
|
||||||
|
{{ data.recipient_address | replace('\\n', '<br>') | safe }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="subject">
|
||||||
|
Betreff: {{ data.subject }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="meta-info" style="font-size: 10pt; color: #666; margin-bottom: 20px;">
|
||||||
|
Schüler/in: {{ data.student_name }} | Klasse: {{ data.student_class }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="content">
|
||||||
|
{{ data.content | replace('\\n', '<br>') | safe }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if data.gfk_principles_applied %}
|
||||||
|
<div style="margin-bottom: 20px;">
|
||||||
|
{% for principle in data.gfk_principles_applied %}
|
||||||
|
<span class="gfk-badge">✓ {{ principle }}</span>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="signature">
|
||||||
|
<p>Mit freundlichen Grüßen</p>
|
||||||
|
<p style="margin-top: 30px;">
|
||||||
|
{{ data.teacher_name }}
|
||||||
|
{% if data.teacher_title %}<br><span style="font-size: 10pt;">{{ data.teacher_title }}</span>{% endif %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if data.legal_references %}
|
||||||
|
<div class="legal-references">
|
||||||
|
<strong>Rechtliche Grundlagen:</strong><br>
|
||||||
|
{% for ref in data.legal_references %}
|
||||||
|
• {{ ref.law }} {{ ref.paragraph }}: {{ ref.title }}<br>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div style="font-size: 8pt; color: #999; margin-top: 30px; text-align: center;">
|
||||||
|
Erstellt mit BreakPilot | {{ generated_at }}
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _get_certificate_template_html() -> str:
|
||||||
|
"""Inline HTML-Template für Zeugnisse."""
|
||||||
|
return """
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Zeugnis - {{ data.student_name }}</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="certificate-header">
|
||||||
|
{% if data.school_info %}
|
||||||
|
<div class="school-name" style="font-size: 14pt;">{{ data.school_info.name }}</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="certificate-title">
|
||||||
|
{% if data.certificate_type == 'halbjahr' %}
|
||||||
|
Halbjahreszeugnis
|
||||||
|
{% elif data.certificate_type == 'jahres' %}
|
||||||
|
Jahreszeugnis
|
||||||
|
{% else %}
|
||||||
|
Abschlusszeugnis
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div>Schuljahr {{ data.school_year }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="student-info">
|
||||||
|
<table style="width: 100%;">
|
||||||
|
<tr>
|
||||||
|
<td><strong>Name:</strong> {{ data.student_name }}</td>
|
||||||
|
<td><strong>Geburtsdatum:</strong> {{ data.student_birthdate }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><strong>Klasse:</strong> {{ data.student_class }}</td>
|
||||||
|
<td> </td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3>Leistungen</h3>
|
||||||
|
<table class="grades-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width: 70%;">Fach</th>
|
||||||
|
<th style="width: 15%;">Note</th>
|
||||||
|
<th style="width: 15%;">Punkte</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for subject in data.subjects %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ subject.name }}</td>
|
||||||
|
<td class="grade-cell" style="color: {{ subject.grade | grade_color }};">
|
||||||
|
{{ subject.grade }}
|
||||||
|
</td>
|
||||||
|
<td class="grade-cell">{{ subject.points | default('-') }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
{% if data.social_behavior or data.work_behavior %}
|
||||||
|
<h3>Verhalten</h3>
|
||||||
|
<table class="grades-table" style="width: 50%;">
|
||||||
|
{% if data.social_behavior %}
|
||||||
|
<tr>
|
||||||
|
<td>Sozialverhalten</td>
|
||||||
|
<td class="grade-cell">{{ data.social_behavior }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
{% if data.work_behavior %}
|
||||||
|
<tr>
|
||||||
|
<td>Arbeitsverhalten</td>
|
||||||
|
<td class="grade-cell">{{ data.work_behavior }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
</table>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="attendance-box">
|
||||||
|
<strong>Versäumte Tage:</strong> {{ data.attendance.days_absent | default(0) }}
|
||||||
|
(davon entschuldigt: {{ data.attendance.days_excused | default(0) }},
|
||||||
|
unentschuldigt: {{ data.attendance.days_unexcused | default(0) }})
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if data.remarks %}
|
||||||
|
<div style="margin-bottom: 20px;">
|
||||||
|
<strong>Bemerkungen:</strong><br>
|
||||||
|
{{ data.remarks }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div style="margin-top: 30px;">
|
||||||
|
<strong>Ausgestellt am:</strong> {{ data.issue_date }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="signatures-row">
|
||||||
|
<div class="signature-block">
|
||||||
|
<div class="signature-line">{{ data.class_teacher }}</div>
|
||||||
|
<div style="font-size: 9pt;">Klassenlehrer/in</div>
|
||||||
|
</div>
|
||||||
|
<div class="signature-block">
|
||||||
|
<div class="signature-line">{{ data.principal }}</div>
|
||||||
|
<div style="font-size: 9pt;">Schulleiter/in</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="text-align: center; margin-top: 40px;">
|
||||||
|
<div style="font-size: 9pt; color: #666;">Siegel der Schule</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _get_correction_template_html() -> str:
|
||||||
|
"""Inline HTML-Template für Korrektur-Übersichten."""
|
||||||
|
return """
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Korrektur - {{ data.exam_title }}</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="exam-header">
|
||||||
|
<h1 style="margin: 0; color: white;">{{ data.exam_title }}</h1>
|
||||||
|
<div>{{ data.subject }} | {{ data.date }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="student-info">
|
||||||
|
<strong>{{ data.student.name }}</strong> | Klasse {{ data.student.class_name }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="result-box">
|
||||||
|
<div class="result-grade" style="color: {{ data.grade | grade_color }};">
|
||||||
|
Note: {{ data.grade }}
|
||||||
|
</div>
|
||||||
|
<div class="result-points">
|
||||||
|
{{ data.achieved_points }} von {{ data.max_points }} Punkten
|
||||||
|
({{ data.percentage | round(1) }}%)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3>Detaillierte Auswertung</h3>
|
||||||
|
<div class="corrections-list">
|
||||||
|
{% for item in data.corrections %}
|
||||||
|
<div class="correction-item">
|
||||||
|
<div class="correction-question">
|
||||||
|
{{ item.question }}
|
||||||
|
</div>
|
||||||
|
{% if item.answer %}
|
||||||
|
<div style="margin: 5px 0; font-style: italic; color: #555;">
|
||||||
|
<strong>Antwort:</strong> {{ item.answer }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div>
|
||||||
|
<strong>Punkte:</strong> {{ item.points }}
|
||||||
|
</div>
|
||||||
|
{% if item.feedback %}
|
||||||
|
<div class="correction-feedback">
|
||||||
|
{{ item.feedback }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if data.teacher_notes %}
|
||||||
|
<div style="background: #e3f2fd; padding: 15px; border-radius: 5px; margin-bottom: 20px;">
|
||||||
|
<strong>Lehrerkommentar:</strong><br>
|
||||||
|
{{ data.teacher_notes }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if data.ai_feedback %}
|
||||||
|
<div style="background: #f3e5f5; padding: 15px; border-radius: 5px; margin-bottom: 20px;">
|
||||||
|
<strong>KI-Feedback:</strong><br>
|
||||||
|
{{ data.ai_feedback }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if data.class_average or data.grade_distribution %}
|
||||||
|
<h3>Klassenstatistik</h3>
|
||||||
|
<table class="stats-table">
|
||||||
|
{% if data.class_average %}
|
||||||
|
<tr>
|
||||||
|
<td><strong>Klassendurchschnitt:</strong></td>
|
||||||
|
<td>{{ data.class_average }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
{% if data.grade_distribution %}
|
||||||
|
<tr>
|
||||||
|
<td><strong>Notenverteilung:</strong></td>
|
||||||
|
<td>
|
||||||
|
{% for grade, count in data.grade_distribution.items() %}
|
||||||
|
Note {{ grade }}: {{ count }}x{% if not loop.last %}, {% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
</table>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="signature" style="margin-top: 40px;">
|
||||||
|
<p style="font-size: 9pt; color: #666;">Datum: {{ data.date }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="font-size: 8pt; color: #999; margin-top: 30px; text-align: center;">
|
||||||
|
Erstellt mit BreakPilot | {{ generated_at }}
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
# Convenience functions for direct usage
|
# Convenience functions for direct usage
|
||||||
@@ -224,7 +793,7 @@ _pdf_service: Optional[PDFService] = None
|
|||||||
|
|
||||||
|
|
||||||
def get_pdf_service() -> PDFService:
|
def get_pdf_service() -> PDFService:
|
||||||
"""Gibt Singleton-Instanz des PDF-Service zurueck."""
|
"""Gibt Singleton-Instanz des PDF-Service zurück."""
|
||||||
global _pdf_service
|
global _pdf_service
|
||||||
if _pdf_service is None:
|
if _pdf_service is None:
|
||||||
_pdf_service = PDFService()
|
_pdf_service = PDFService()
|
||||||
|
|||||||
@@ -1,519 +0,0 @@
|
|||||||
"""
|
|
||||||
PDF Templates - Inline HTML-Templates und CSS fuer PDF-Generierung.
|
|
||||||
|
|
||||||
Fallback-Templates die verwendet werden wenn keine externen HTML-Dateien
|
|
||||||
im templates/pdf/ Verzeichnis vorhanden sind.
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
def get_base_css() -> str:
|
|
||||||
"""Basis-CSS fuer alle PDFs (A4, Typografie, Komponenten-Styles)."""
|
|
||||||
return """
|
|
||||||
@page {
|
|
||||||
size: A4;
|
|
||||||
margin: 2cm 2.5cm;
|
|
||||||
@top-right {
|
|
||||||
content: counter(page) " / " counter(pages);
|
|
||||||
font-size: 9pt;
|
|
||||||
color: #666;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
font-family: 'DejaVu Sans', 'Liberation Sans', Arial, sans-serif;
|
|
||||||
font-size: 11pt;
|
|
||||||
line-height: 1.5;
|
|
||||||
color: #333;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1, h2, h3 {
|
|
||||||
font-weight: bold;
|
|
||||||
margin-top: 1em;
|
|
||||||
margin-bottom: 0.5em;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 { font-size: 16pt; }
|
|
||||||
h2 { font-size: 14pt; }
|
|
||||||
h3 { font-size: 12pt; }
|
|
||||||
|
|
||||||
.header {
|
|
||||||
border-bottom: 2px solid #2c3e50;
|
|
||||||
padding-bottom: 15px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.school-name {
|
|
||||||
font-size: 18pt;
|
|
||||||
font-weight: bold;
|
|
||||||
color: #2c3e50;
|
|
||||||
}
|
|
||||||
|
|
||||||
.school-info {
|
|
||||||
font-size: 9pt;
|
|
||||||
color: #666;
|
|
||||||
}
|
|
||||||
|
|
||||||
.letter-date {
|
|
||||||
text-align: right;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.recipient {
|
|
||||||
margin-bottom: 30px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.subject {
|
|
||||||
font-weight: bold;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content {
|
|
||||||
text-align: justify;
|
|
||||||
margin-bottom: 30px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.signature {
|
|
||||||
margin-top: 40px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.legal-references {
|
|
||||||
font-size: 9pt;
|
|
||||||
color: #666;
|
|
||||||
border-top: 1px solid #ddd;
|
|
||||||
margin-top: 30px;
|
|
||||||
padding-top: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.gfk-badge {
|
|
||||||
display: inline-block;
|
|
||||||
background: #e8f5e9;
|
|
||||||
color: #27ae60;
|
|
||||||
font-size: 8pt;
|
|
||||||
padding: 2px 8px;
|
|
||||||
border-radius: 10px;
|
|
||||||
margin-right: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Zeugnis-Styles */
|
|
||||||
.certificate-header {
|
|
||||||
text-align: center;
|
|
||||||
margin-bottom: 30px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.certificate-title {
|
|
||||||
font-size: 20pt;
|
|
||||||
font-weight: bold;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.student-info {
|
|
||||||
margin-bottom: 20px;
|
|
||||||
padding: 15px;
|
|
||||||
background: #f9f9f9;
|
|
||||||
border-radius: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.grades-table {
|
|
||||||
width: 100%;
|
|
||||||
border-collapse: collapse;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.grades-table th,
|
|
||||||
.grades-table td {
|
|
||||||
border: 1px solid #ddd;
|
|
||||||
padding: 8px 12px;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
.grades-table th {
|
|
||||||
background: #2c3e50;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.grades-table tr:nth-child(even) {
|
|
||||||
background: #f9f9f9;
|
|
||||||
}
|
|
||||||
|
|
||||||
.grade-cell {
|
|
||||||
text-align: center;
|
|
||||||
font-weight: bold;
|
|
||||||
font-size: 12pt;
|
|
||||||
}
|
|
||||||
|
|
||||||
.attendance-box {
|
|
||||||
background: #fff3cd;
|
|
||||||
padding: 15px;
|
|
||||||
border-radius: 5px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.signatures-row {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
margin-top: 50px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.signature-block {
|
|
||||||
text-align: center;
|
|
||||||
width: 40%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.signature-line {
|
|
||||||
border-top: 1px solid #333;
|
|
||||||
margin-top: 40px;
|
|
||||||
padding-top: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Korrektur-Styles */
|
|
||||||
.exam-header {
|
|
||||||
background: #2c3e50;
|
|
||||||
color: white;
|
|
||||||
padding: 15px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.result-box {
|
|
||||||
background: #e8f5e9;
|
|
||||||
padding: 20px;
|
|
||||||
text-align: center;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
border-radius: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.result-grade {
|
|
||||||
font-size: 36pt;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
.result-points {
|
|
||||||
font-size: 14pt;
|
|
||||||
color: #666;
|
|
||||||
}
|
|
||||||
|
|
||||||
.corrections-list {
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.correction-item {
|
|
||||||
border: 1px solid #ddd;
|
|
||||||
padding: 15px;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
border-radius: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.correction-question {
|
|
||||||
font-weight: bold;
|
|
||||||
margin-bottom: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.correction-feedback {
|
|
||||||
background: #fff8e1;
|
|
||||||
padding: 10px;
|
|
||||||
margin-top: 10px;
|
|
||||||
border-left: 3px solid #ffc107;
|
|
||||||
font-size: 10pt;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-table {
|
|
||||||
width: 100%;
|
|
||||||
margin-top: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-table td {
|
|
||||||
padding: 5px 10px;
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
def get_letter_template_html() -> str:
|
|
||||||
"""Inline HTML-Template fuer Elternbriefe."""
|
|
||||||
return """
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="de">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<title>{{ data.subject }}</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="header">
|
|
||||||
{% if data.school_info %}
|
|
||||||
<div class="school-name">{{ data.school_info.name }}</div>
|
|
||||||
<div class="school-info">
|
|
||||||
{{ data.school_info.address }}<br>
|
|
||||||
Tel: {{ data.school_info.phone }} | E-Mail: {{ data.school_info.email }}
|
|
||||||
{% if data.school_info.website %} | {{ data.school_info.website }}{% endif %}
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<div class="school-name">Schule</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="letter-date">
|
|
||||||
{{ data.date }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="recipient">
|
|
||||||
{{ data.recipient_name }}<br>
|
|
||||||
{{ data.recipient_address | replace('\\n', '<br>') | safe }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="subject">
|
|
||||||
Betreff: {{ data.subject }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="meta-info" style="font-size: 10pt; color: #666; margin-bottom: 20px;">
|
|
||||||
Schüler/in: {{ data.student_name }} | Klasse: {{ data.student_class }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="content">
|
|
||||||
{{ data.content | replace('\\n', '<br>') | safe }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% if data.gfk_principles_applied %}
|
|
||||||
<div style="margin-bottom: 20px;">
|
|
||||||
{% for principle in data.gfk_principles_applied %}
|
|
||||||
<span class="gfk-badge">✓ {{ principle }}</span>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<div class="signature">
|
|
||||||
<p>Mit freundlichen Grüßen</p>
|
|
||||||
<p style="margin-top: 30px;">
|
|
||||||
{{ data.teacher_name }}
|
|
||||||
{% if data.teacher_title %}<br><span style="font-size: 10pt;">{{ data.teacher_title }}</span>{% endif %}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% if data.legal_references %}
|
|
||||||
<div class="legal-references">
|
|
||||||
<strong>Rechtliche Grundlagen:</strong><br>
|
|
||||||
{% for ref in data.legal_references %}
|
|
||||||
• {{ ref.law }} {{ ref.paragraph }}: {{ ref.title }}<br>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<div style="font-size: 8pt; color: #999; margin-top: 30px; text-align: center;">
|
|
||||||
Erstellt mit BreakPilot | {{ generated_at }}
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
def get_certificate_template_html() -> str:
|
|
||||||
"""Inline HTML-Template fuer Zeugnisse."""
|
|
||||||
return """
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="de">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<title>Zeugnis - {{ data.student_name }}</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="certificate-header">
|
|
||||||
{% if data.school_info %}
|
|
||||||
<div class="school-name" style="font-size: 14pt;">{{ data.school_info.name }}</div>
|
|
||||||
{% endif %}
|
|
||||||
<div class="certificate-title">
|
|
||||||
{% if data.certificate_type == 'halbjahr' %}
|
|
||||||
Halbjahreszeugnis
|
|
||||||
{% elif data.certificate_type == 'jahres' %}
|
|
||||||
Jahreszeugnis
|
|
||||||
{% else %}
|
|
||||||
Abschlusszeugnis
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
<div>Schuljahr {{ data.school_year }}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="student-info">
|
|
||||||
<table style="width: 100%;">
|
|
||||||
<tr>
|
|
||||||
<td><strong>Name:</strong> {{ data.student_name }}</td>
|
|
||||||
<td><strong>Geburtsdatum:</strong> {{ data.student_birthdate }}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><strong>Klasse:</strong> {{ data.student_class }}</td>
|
|
||||||
<td> </td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h3>Leistungen</h3>
|
|
||||||
<table class="grades-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th style="width: 70%;">Fach</th>
|
|
||||||
<th style="width: 15%;">Note</th>
|
|
||||||
<th style="width: 15%;">Punkte</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{% for subject in data.subjects %}
|
|
||||||
<tr>
|
|
||||||
<td>{{ subject.name }}</td>
|
|
||||||
<td class="grade-cell" style="color: {{ subject.grade | grade_color }};">
|
|
||||||
{{ subject.grade }}
|
|
||||||
</td>
|
|
||||||
<td class="grade-cell">{{ subject.points | default('-') }}</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
{% if data.social_behavior or data.work_behavior %}
|
|
||||||
<h3>Verhalten</h3>
|
|
||||||
<table class="grades-table" style="width: 50%;">
|
|
||||||
{% if data.social_behavior %}
|
|
||||||
<tr>
|
|
||||||
<td>Sozialverhalten</td>
|
|
||||||
<td class="grade-cell">{{ data.social_behavior }}</td>
|
|
||||||
</tr>
|
|
||||||
{% endif %}
|
|
||||||
{% if data.work_behavior %}
|
|
||||||
<tr>
|
|
||||||
<td>Arbeitsverhalten</td>
|
|
||||||
<td class="grade-cell">{{ data.work_behavior }}</td>
|
|
||||||
</tr>
|
|
||||||
{% endif %}
|
|
||||||
</table>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<div class="attendance-box">
|
|
||||||
<strong>Versäumte Tage:</strong> {{ data.attendance.days_absent | default(0) }}
|
|
||||||
(davon entschuldigt: {{ data.attendance.days_excused | default(0) }},
|
|
||||||
unentschuldigt: {{ data.attendance.days_unexcused | default(0) }})
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% if data.remarks %}
|
|
||||||
<div style="margin-bottom: 20px;">
|
|
||||||
<strong>Bemerkungen:</strong><br>
|
|
||||||
{{ data.remarks }}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<div style="margin-top: 30px;">
|
|
||||||
<strong>Ausgestellt am:</strong> {{ data.issue_date }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="signatures-row">
|
|
||||||
<div class="signature-block">
|
|
||||||
<div class="signature-line">{{ data.class_teacher }}</div>
|
|
||||||
<div style="font-size: 9pt;">Klassenlehrer/in</div>
|
|
||||||
</div>
|
|
||||||
<div class="signature-block">
|
|
||||||
<div class="signature-line">{{ data.principal }}</div>
|
|
||||||
<div style="font-size: 9pt;">Schulleiter/in</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style="text-align: center; margin-top: 40px;">
|
|
||||||
<div style="font-size: 9pt; color: #666;">Siegel der Schule</div>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
def get_correction_template_html() -> str:
|
|
||||||
"""Inline HTML-Template fuer Korrektur-Uebersichten."""
|
|
||||||
return """
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="de">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<title>Korrektur - {{ data.exam_title }}</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="exam-header">
|
|
||||||
<h1 style="margin: 0; color: white;">{{ data.exam_title }}</h1>
|
|
||||||
<div>{{ data.subject }} | {{ data.date }}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="student-info">
|
|
||||||
<strong>{{ data.student.name }}</strong> | Klasse {{ data.student.class_name }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="result-box">
|
|
||||||
<div class="result-grade" style="color: {{ data.grade | grade_color }};">
|
|
||||||
Note: {{ data.grade }}
|
|
||||||
</div>
|
|
||||||
<div class="result-points">
|
|
||||||
{{ data.achieved_points }} von {{ data.max_points }} Punkten
|
|
||||||
({{ data.percentage | round(1) }}%)
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h3>Detaillierte Auswertung</h3>
|
|
||||||
<div class="corrections-list">
|
|
||||||
{% for item in data.corrections %}
|
|
||||||
<div class="correction-item">
|
|
||||||
<div class="correction-question">
|
|
||||||
{{ item.question }}
|
|
||||||
</div>
|
|
||||||
{% if item.answer %}
|
|
||||||
<div style="margin: 5px 0; font-style: italic; color: #555;">
|
|
||||||
<strong>Antwort:</strong> {{ item.answer }}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
<div>
|
|
||||||
<strong>Punkte:</strong> {{ item.points }}
|
|
||||||
</div>
|
|
||||||
{% if item.feedback %}
|
|
||||||
<div class="correction-feedback">
|
|
||||||
{{ item.feedback }}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% if data.teacher_notes %}
|
|
||||||
<div style="background: #e3f2fd; padding: 15px; border-radius: 5px; margin-bottom: 20px;">
|
|
||||||
<strong>Lehrerkommentar:</strong><br>
|
|
||||||
{{ data.teacher_notes }}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if data.ai_feedback %}
|
|
||||||
<div style="background: #f3e5f5; padding: 15px; border-radius: 5px; margin-bottom: 20px;">
|
|
||||||
<strong>KI-Feedback:</strong><br>
|
|
||||||
{{ data.ai_feedback }}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if data.class_average or data.grade_distribution %}
|
|
||||||
<h3>Klassenstatistik</h3>
|
|
||||||
<table class="stats-table">
|
|
||||||
{% if data.class_average %}
|
|
||||||
<tr>
|
|
||||||
<td><strong>Klassendurchschnitt:</strong></td>
|
|
||||||
<td>{{ data.class_average }}</td>
|
|
||||||
</tr>
|
|
||||||
{% endif %}
|
|
||||||
{% if data.grade_distribution %}
|
|
||||||
<tr>
|
|
||||||
<td><strong>Notenverteilung:</strong></td>
|
|
||||||
<td>
|
|
||||||
{% for grade, count in data.grade_distribution.items() %}
|
|
||||||
Note {{ grade }}: {{ count }}x{% if not loop.last %}, {% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{% endif %}
|
|
||||||
</table>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<div class="signature" style="margin-top: 40px;">
|
|
||||||
<p style="font-size: 9pt; color: #666;">Datum: {{ data.date }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style="font-size: 8pt; color: #999; margin-top: 30px; text-align: center;">
|
|
||||||
Erstellt mit BreakPilot | {{ generated_at }}
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
"""
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,307 +0,0 @@
|
|||||||
package database
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
)
|
|
||||||
|
|
||||||
// migrateCore creates the core tables: users, auth tokens, sessions,
|
|
||||||
// documents, versions, consents, cookies, audit, notifications,
|
|
||||||
// deadlines, suspensions, and their indexes (Phases 1-5).
|
|
||||||
func migrateCore(db *DB) error {
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
migrations := []string{
|
|
||||||
// Users table (extended for full auth)
|
|
||||||
`CREATE TABLE IF NOT EXISTS users (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
external_id VARCHAR(255) UNIQUE,
|
|
||||||
email VARCHAR(255) UNIQUE NOT NULL,
|
|
||||||
password_hash VARCHAR(255),
|
|
||||||
name VARCHAR(255),
|
|
||||||
role VARCHAR(50) DEFAULT 'user',
|
|
||||||
email_verified BOOLEAN DEFAULT FALSE,
|
|
||||||
email_verified_at TIMESTAMPTZ,
|
|
||||||
account_status VARCHAR(20) DEFAULT 'active',
|
|
||||||
last_login_at TIMESTAMPTZ,
|
|
||||||
failed_login_attempts INT DEFAULT 0,
|
|
||||||
locked_until TIMESTAMPTZ,
|
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
|
||||||
)`,
|
|
||||||
|
|
||||||
// Legal documents table
|
|
||||||
`CREATE TABLE IF NOT EXISTS legal_documents (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
type VARCHAR(50) NOT NULL,
|
|
||||||
name VARCHAR(255) NOT NULL,
|
|
||||||
description TEXT,
|
|
||||||
is_mandatory BOOLEAN DEFAULT true,
|
|
||||||
is_active BOOLEAN DEFAULT true,
|
|
||||||
sort_order INT DEFAULT 0,
|
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
|
||||||
)`,
|
|
||||||
|
|
||||||
// Document versions table
|
|
||||||
`CREATE TABLE IF NOT EXISTS document_versions (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
document_id UUID REFERENCES legal_documents(id) ON DELETE CASCADE,
|
|
||||||
version VARCHAR(20) NOT NULL,
|
|
||||||
language VARCHAR(5) DEFAULT 'de',
|
|
||||||
title VARCHAR(255) NOT NULL,
|
|
||||||
content TEXT NOT NULL,
|
|
||||||
summary TEXT,
|
|
||||||
status VARCHAR(20) DEFAULT 'draft',
|
|
||||||
published_at TIMESTAMPTZ,
|
|
||||||
scheduled_publish_at TIMESTAMPTZ,
|
|
||||||
created_by UUID REFERENCES users(id),
|
|
||||||
approved_by UUID REFERENCES users(id),
|
|
||||||
approved_at TIMESTAMPTZ,
|
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
|
||||||
UNIQUE(document_id, version, language)
|
|
||||||
)`,
|
|
||||||
|
|
||||||
// Add scheduled_publish_at column if not exists (migration)
|
|
||||||
`ALTER TABLE document_versions ADD COLUMN IF NOT EXISTS scheduled_publish_at TIMESTAMPTZ`,
|
|
||||||
`ALTER TABLE document_versions ADD COLUMN IF NOT EXISTS approved_at TIMESTAMPTZ`,
|
|
||||||
|
|
||||||
// User consents table
|
|
||||||
`CREATE TABLE IF NOT EXISTS user_consents (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
|
|
||||||
document_version_id UUID REFERENCES document_versions(id),
|
|
||||||
consented BOOLEAN NOT NULL,
|
|
||||||
ip_address INET,
|
|
||||||
user_agent TEXT,
|
|
||||||
consented_at TIMESTAMPTZ DEFAULT NOW(),
|
|
||||||
withdrawn_at TIMESTAMPTZ,
|
|
||||||
UNIQUE(user_id, document_version_id)
|
|
||||||
)`,
|
|
||||||
|
|
||||||
// Cookie categories table
|
|
||||||
`CREATE TABLE IF NOT EXISTS cookie_categories (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
name VARCHAR(100) NOT NULL UNIQUE,
|
|
||||||
display_name_de VARCHAR(255) NOT NULL,
|
|
||||||
display_name_en VARCHAR(255),
|
|
||||||
description_de TEXT,
|
|
||||||
description_en TEXT,
|
|
||||||
is_mandatory BOOLEAN DEFAULT false,
|
|
||||||
sort_order INT DEFAULT 0,
|
|
||||||
is_active BOOLEAN DEFAULT true,
|
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
|
||||||
)`,
|
|
||||||
|
|
||||||
// Cookie consents table
|
|
||||||
`CREATE TABLE IF NOT EXISTS cookie_consents (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
|
|
||||||
category_id UUID REFERENCES cookie_categories(id) ON DELETE CASCADE,
|
|
||||||
consented BOOLEAN NOT NULL,
|
|
||||||
consented_at TIMESTAMPTZ DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
|
||||||
UNIQUE(user_id, category_id)
|
|
||||||
)`,
|
|
||||||
|
|
||||||
// Audit log table
|
|
||||||
`CREATE TABLE IF NOT EXISTS consent_audit_log (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
|
||||||
action VARCHAR(50) NOT NULL,
|
|
||||||
entity_type VARCHAR(50),
|
|
||||||
entity_id UUID,
|
|
||||||
details JSONB,
|
|
||||||
ip_address INET,
|
|
||||||
user_agent TEXT,
|
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
|
||||||
)`,
|
|
||||||
|
|
||||||
// Data export requests table
|
|
||||||
`CREATE TABLE IF NOT EXISTS data_export_requests (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
|
|
||||||
status VARCHAR(20) DEFAULT 'pending',
|
|
||||||
download_url TEXT,
|
|
||||||
expires_at TIMESTAMPTZ,
|
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
|
||||||
completed_at TIMESTAMPTZ
|
|
||||||
)`,
|
|
||||||
|
|
||||||
// Data deletion requests table
|
|
||||||
`CREATE TABLE IF NOT EXISTS data_deletion_requests (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
|
|
||||||
status VARCHAR(20) DEFAULT 'pending',
|
|
||||||
reason TEXT,
|
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
|
||||||
processed_at TIMESTAMPTZ,
|
|
||||||
processed_by UUID REFERENCES users(id)
|
|
||||||
)`,
|
|
||||||
|
|
||||||
// =============================================
|
|
||||||
// Phase 1: User Management Tables
|
|
||||||
// =============================================
|
|
||||||
|
|
||||||
// Email verification tokens
|
|
||||||
`CREATE TABLE IF NOT EXISTS email_verification_tokens (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
|
|
||||||
token VARCHAR(255) UNIQUE NOT NULL,
|
|
||||||
expires_at TIMESTAMPTZ NOT NULL,
|
|
||||||
used_at TIMESTAMPTZ,
|
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
|
||||||
)`,
|
|
||||||
|
|
||||||
// Password reset tokens
|
|
||||||
`CREATE TABLE IF NOT EXISTS password_reset_tokens (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
|
|
||||||
token VARCHAR(255) UNIQUE NOT NULL,
|
|
||||||
expires_at TIMESTAMPTZ NOT NULL,
|
|
||||||
used_at TIMESTAMPTZ,
|
|
||||||
ip_address INET,
|
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
|
||||||
)`,
|
|
||||||
|
|
||||||
// User sessions (for JWT revocation and session management)
|
|
||||||
`CREATE TABLE IF NOT EXISTS user_sessions (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
|
|
||||||
token_hash VARCHAR(255) NOT NULL,
|
|
||||||
device_info TEXT,
|
|
||||||
ip_address INET,
|
|
||||||
user_agent TEXT,
|
|
||||||
expires_at TIMESTAMPTZ NOT NULL,
|
|
||||||
revoked_at TIMESTAMPTZ,
|
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
|
||||||
last_activity_at TIMESTAMPTZ DEFAULT NOW()
|
|
||||||
)`,
|
|
||||||
|
|
||||||
// =============================================
|
|
||||||
// Phase 3: Version Approvals (DSB Workflow)
|
|
||||||
// =============================================
|
|
||||||
|
|
||||||
// Version approval tracking
|
|
||||||
`CREATE TABLE IF NOT EXISTS version_approvals (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
version_id UUID REFERENCES document_versions(id) ON DELETE CASCADE,
|
|
||||||
approver_id UUID REFERENCES users(id),
|
|
||||||
action VARCHAR(30) NOT NULL,
|
|
||||||
comment TEXT,
|
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
|
||||||
)`,
|
|
||||||
|
|
||||||
// =============================================
|
|
||||||
// Phase 4: Notification System
|
|
||||||
// =============================================
|
|
||||||
|
|
||||||
// Notifications
|
|
||||||
`CREATE TABLE IF NOT EXISTS notifications (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
|
|
||||||
type VARCHAR(50) NOT NULL,
|
|
||||||
channel VARCHAR(20) NOT NULL,
|
|
||||||
title VARCHAR(255) NOT NULL,
|
|
||||||
body TEXT NOT NULL,
|
|
||||||
data JSONB,
|
|
||||||
read_at TIMESTAMPTZ,
|
|
||||||
sent_at TIMESTAMPTZ,
|
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
|
||||||
)`,
|
|
||||||
|
|
||||||
// Push subscriptions for Web Push
|
|
||||||
`CREATE TABLE IF NOT EXISTS push_subscriptions (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
|
|
||||||
endpoint TEXT NOT NULL,
|
|
||||||
p256dh TEXT NOT NULL,
|
|
||||||
auth TEXT NOT NULL,
|
|
||||||
user_agent TEXT,
|
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
|
||||||
UNIQUE(user_id, endpoint)
|
|
||||||
)`,
|
|
||||||
|
|
||||||
// Notification preferences per user
|
|
||||||
`CREATE TABLE IF NOT EXISTS notification_preferences (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
user_id UUID REFERENCES users(id) ON DELETE CASCADE UNIQUE,
|
|
||||||
email_enabled BOOLEAN DEFAULT TRUE,
|
|
||||||
push_enabled BOOLEAN DEFAULT TRUE,
|
|
||||||
in_app_enabled BOOLEAN DEFAULT TRUE,
|
|
||||||
reminder_frequency VARCHAR(20) DEFAULT 'weekly',
|
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
|
||||||
)`,
|
|
||||||
|
|
||||||
// =============================================
|
|
||||||
// Phase 5: Consent Deadlines & Account Suspension
|
|
||||||
// =============================================
|
|
||||||
|
|
||||||
// Consent deadlines per user per version
|
|
||||||
`CREATE TABLE IF NOT EXISTS consent_deadlines (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
|
|
||||||
document_version_id UUID REFERENCES document_versions(id) ON DELETE CASCADE,
|
|
||||||
deadline_at TIMESTAMPTZ NOT NULL,
|
|
||||||
reminder_count INT DEFAULT 0,
|
|
||||||
last_reminder_at TIMESTAMPTZ,
|
|
||||||
consent_given_at TIMESTAMPTZ,
|
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
|
||||||
UNIQUE(user_id, document_version_id)
|
|
||||||
)`,
|
|
||||||
|
|
||||||
// Account suspensions tracking
|
|
||||||
`CREATE TABLE IF NOT EXISTS account_suspensions (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
|
|
||||||
reason VARCHAR(50) NOT NULL,
|
|
||||||
details JSONB,
|
|
||||||
suspended_at TIMESTAMPTZ DEFAULT NOW(),
|
|
||||||
lifted_at TIMESTAMPTZ,
|
|
||||||
lifted_reason TEXT
|
|
||||||
)`,
|
|
||||||
|
|
||||||
// =============================================
|
|
||||||
// Indexes for performance
|
|
||||||
// =============================================
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_user_consents_user ON user_consents(user_id)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_user_consents_version ON user_consents(document_version_id)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_cookie_consents_user ON cookie_consents(user_id)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_audit_log_user ON consent_audit_log(user_id)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_audit_log_created ON consent_audit_log(created_at)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_document_versions_document ON document_versions(document_id)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_document_versions_status ON document_versions(status)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_legal_documents_type ON legal_documents(type)`,
|
|
||||||
|
|
||||||
// Phase 1: Auth indexes
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_email_verification_tokens_token ON email_verification_tokens(token)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_email_verification_tokens_user ON email_verification_tokens(user_id)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_token ON password_reset_tokens(token)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_user_sessions_user ON user_sessions(user_id)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_user_sessions_token ON user_sessions(token_hash)`,
|
|
||||||
|
|
||||||
// Phase 3: Approval indexes
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_version_approvals_version ON version_approvals(version_id)`,
|
|
||||||
|
|
||||||
// Phase 4: Notification indexes
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_notifications_user ON notifications(user_id)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_notifications_unread ON notifications(user_id, read_at)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_push_subscriptions_user ON push_subscriptions(user_id)`,
|
|
||||||
|
|
||||||
// Phase 5: Deadline indexes
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_consent_deadlines_user ON consent_deadlines(user_id)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_consent_deadlines_deadline ON consent_deadlines(deadline_at)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_account_suspensions_user ON account_suspensions(user_id)`,
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, migration := range migrations {
|
|
||||||
if _, err := db.Pool.Exec(ctx, migration); err != nil {
|
|
||||||
return fmt.Errorf("migrateCore: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,267 +0,0 @@
|
|||||||
package database
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
)
|
|
||||||
|
|
||||||
// migrateDSR creates DSGVO Data Subject Request tables (Phase 10)
|
|
||||||
// and EduSearch seed management tables (Phase 11).
|
|
||||||
func migrateDSR(db *DB) error {
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
migrations := []string{
|
|
||||||
// =============================================
|
|
||||||
// Phase 10: DSGVO Betroffenenanfragen (DSR)
|
|
||||||
// Data Subject Request Management
|
|
||||||
// =============================================
|
|
||||||
|
|
||||||
// Sequence for request numbers
|
|
||||||
`CREATE SEQUENCE IF NOT EXISTS dsr_request_number_seq START 1`,
|
|
||||||
|
|
||||||
// Main table: Data Subject Requests
|
|
||||||
`CREATE TABLE IF NOT EXISTS data_subject_requests (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
|
||||||
request_number VARCHAR(50) UNIQUE NOT NULL,
|
|
||||||
request_type VARCHAR(30) NOT NULL,
|
|
||||||
status VARCHAR(30) NOT NULL DEFAULT 'intake',
|
|
||||||
priority VARCHAR(20) DEFAULT 'normal',
|
|
||||||
source VARCHAR(30) NOT NULL DEFAULT 'api',
|
|
||||||
requester_email VARCHAR(255) NOT NULL,
|
|
||||||
requester_name VARCHAR(255),
|
|
||||||
requester_phone VARCHAR(50),
|
|
||||||
identity_verified BOOLEAN DEFAULT FALSE,
|
|
||||||
identity_verified_at TIMESTAMPTZ,
|
|
||||||
identity_verified_by UUID REFERENCES users(id),
|
|
||||||
identity_verification_method VARCHAR(50),
|
|
||||||
request_details JSONB DEFAULT '{}',
|
|
||||||
deadline_at TIMESTAMPTZ NOT NULL,
|
|
||||||
legal_deadline_days INT NOT NULL,
|
|
||||||
extended_deadline_at TIMESTAMPTZ,
|
|
||||||
extension_reason TEXT,
|
|
||||||
assigned_to UUID REFERENCES users(id),
|
|
||||||
processing_notes TEXT,
|
|
||||||
completed_at TIMESTAMPTZ,
|
|
||||||
completed_by UUID REFERENCES users(id),
|
|
||||||
result_summary TEXT,
|
|
||||||
result_data JSONB,
|
|
||||||
rejected_at TIMESTAMPTZ,
|
|
||||||
rejected_by UUID REFERENCES users(id),
|
|
||||||
rejection_reason TEXT,
|
|
||||||
rejection_legal_basis TEXT,
|
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
|
||||||
created_by UUID REFERENCES users(id)
|
|
||||||
)`,
|
|
||||||
|
|
||||||
// DSR Status History for audit trail
|
|
||||||
`CREATE TABLE IF NOT EXISTS dsr_status_history (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
request_id UUID NOT NULL REFERENCES data_subject_requests(id) ON DELETE CASCADE,
|
|
||||||
from_status VARCHAR(30),
|
|
||||||
to_status VARCHAR(30) NOT NULL,
|
|
||||||
changed_by UUID REFERENCES users(id),
|
|
||||||
comment TEXT,
|
|
||||||
metadata JSONB,
|
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
|
||||||
)`,
|
|
||||||
|
|
||||||
// DSR Communications log
|
|
||||||
`CREATE TABLE IF NOT EXISTS dsr_communications (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
request_id UUID NOT NULL REFERENCES data_subject_requests(id) ON DELETE CASCADE,
|
|
||||||
direction VARCHAR(10) NOT NULL,
|
|
||||||
channel VARCHAR(20) NOT NULL,
|
|
||||||
communication_type VARCHAR(50) NOT NULL,
|
|
||||||
template_version_id UUID,
|
|
||||||
subject VARCHAR(500),
|
|
||||||
body_html TEXT,
|
|
||||||
body_text TEXT,
|
|
||||||
recipient_email VARCHAR(255),
|
|
||||||
sent_at TIMESTAMPTZ,
|
|
||||||
error_message TEXT,
|
|
||||||
attachments JSONB DEFAULT '[]',
|
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
|
||||||
created_by UUID REFERENCES users(id)
|
|
||||||
)`,
|
|
||||||
|
|
||||||
// DSR Templates
|
|
||||||
`CREATE TABLE IF NOT EXISTS dsr_templates (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
template_type VARCHAR(50) UNIQUE NOT NULL,
|
|
||||||
name VARCHAR(255) NOT NULL,
|
|
||||||
description TEXT,
|
|
||||||
request_types JSONB DEFAULT '["access","rectification","erasure","restriction","portability"]',
|
|
||||||
is_active BOOLEAN DEFAULT TRUE,
|
|
||||||
sort_order INT DEFAULT 0,
|
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
|
||||||
)`,
|
|
||||||
|
|
||||||
// DSR Template Versions
|
|
||||||
`CREATE TABLE IF NOT EXISTS dsr_template_versions (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
template_id UUID REFERENCES dsr_templates(id) ON DELETE CASCADE,
|
|
||||||
version VARCHAR(20) NOT NULL,
|
|
||||||
language VARCHAR(5) DEFAULT 'de',
|
|
||||||
subject VARCHAR(500) NOT NULL,
|
|
||||||
body_html TEXT NOT NULL,
|
|
||||||
body_text TEXT NOT NULL,
|
|
||||||
status VARCHAR(20) DEFAULT 'draft',
|
|
||||||
published_at TIMESTAMPTZ,
|
|
||||||
created_by UUID REFERENCES users(id),
|
|
||||||
approved_by UUID REFERENCES users(id),
|
|
||||||
approved_at TIMESTAMPTZ,
|
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
|
||||||
UNIQUE(template_id, version, language)
|
|
||||||
)`,
|
|
||||||
|
|
||||||
// DSR Exception Checks (for Art. 17(3) erasure exceptions)
|
|
||||||
`CREATE TABLE IF NOT EXISTS dsr_exception_checks (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
request_id UUID NOT NULL REFERENCES data_subject_requests(id) ON DELETE CASCADE,
|
|
||||||
exception_type VARCHAR(50) NOT NULL,
|
|
||||||
description TEXT NOT NULL,
|
|
||||||
applies BOOLEAN,
|
|
||||||
checked_by UUID REFERENCES users(id),
|
|
||||||
checked_at TIMESTAMPTZ,
|
|
||||||
notes TEXT,
|
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
|
||||||
)`,
|
|
||||||
|
|
||||||
// Phase 10 Indexes
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_dsr_user ON data_subject_requests(user_id)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_dsr_status ON data_subject_requests(status)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_dsr_type ON data_subject_requests(request_type)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_dsr_deadline ON data_subject_requests(deadline_at)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_dsr_assigned ON data_subject_requests(assigned_to)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_dsr_request_number ON data_subject_requests(request_number)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_dsr_created ON data_subject_requests(created_at)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_dsr_status_history_request ON dsr_status_history(request_id)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_dsr_communications_request ON dsr_communications(request_id)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_dsr_exception_checks_request ON dsr_exception_checks(request_id)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_dsr_templates_type ON dsr_templates(template_type)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_dsr_template_versions_template ON dsr_template_versions(template_id)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_dsr_template_versions_status ON dsr_template_versions(status)`,
|
|
||||||
|
|
||||||
// Insert default DSR templates
|
|
||||||
`INSERT INTO dsr_templates (template_type, name, description, request_types, sort_order)
|
|
||||||
VALUES
|
|
||||||
('dsr_receipt_access', 'Eingangsbestätigung Auskunft', 'Bestätigung des Eingangs einer Auskunftsanfrage nach Art. 15 DSGVO', '["access"]', 1),
|
|
||||||
('dsr_receipt_rectification', 'Eingangsbestätigung Berichtigung', 'Bestätigung des Eingangs einer Berichtigungsanfrage nach Art. 16 DSGVO', '["rectification"]', 2),
|
|
||||||
('dsr_receipt_erasure', 'Eingangsbestätigung Löschung', 'Bestätigung des Eingangs einer Löschanfrage nach Art. 17 DSGVO', '["erasure"]', 3),
|
|
||||||
('dsr_receipt_restriction', 'Eingangsbestätigung Einschränkung', 'Bestätigung des Eingangs einer Einschränkungsanfrage nach Art. 18 DSGVO', '["restriction"]', 4),
|
|
||||||
('dsr_receipt_portability', 'Eingangsbestätigung Datenübertragung', 'Bestätigung des Eingangs einer Datenübertragungsanfrage nach Art. 20 DSGVO', '["portability"]', 5),
|
|
||||||
('dsr_identity_request', 'Anfrage Identitätsnachweis', 'Aufforderung zur Identitätsverifizierung', '["access","rectification","erasure","restriction","portability"]', 6),
|
|
||||||
('dsr_processing_started', 'Bearbeitungsbestätigung', 'Bestätigung, dass die Bearbeitung begonnen hat', '["access","rectification","erasure","restriction","portability"]', 7),
|
|
||||||
('dsr_processing_update', 'Zwischenbericht', 'Zwischenstand zur Bearbeitung', '["access","rectification","erasure","restriction","portability"]', 8),
|
|
||||||
('dsr_clarification_request', 'Rückfragen', 'Anfrage zur Klärung des Begehrens', '["access","rectification","erasure","restriction","portability"]', 9),
|
|
||||||
('dsr_completed_access', 'Auskunft erteilt', 'Abschließende Mitteilung mit Datenauskunft', '["access"]', 10),
|
|
||||||
('dsr_completed_access_negative', 'Negativauskunft', 'Mitteilung dass keine Daten vorhanden sind', '["access"]', 11),
|
|
||||||
('dsr_completed_rectification', 'Berichtigung durchgeführt', 'Bestätigung der Datenberichtigung', '["rectification"]', 12),
|
|
||||||
('dsr_completed_erasure', 'Löschung durchgeführt', 'Bestätigung der Datenlöschung', '["erasure"]', 13),
|
|
||||||
('dsr_completed_restriction', 'Einschränkung aktiviert', 'Bestätigung der Verarbeitungseinschränkung', '["restriction"]', 14),
|
|
||||||
('dsr_completed_portability', 'Daten bereitgestellt', 'Mitteilung zur Datenübermittlung', '["portability"]', 15),
|
|
||||||
('dsr_restriction_lifted', 'Einschränkung aufgehoben', 'Vorabbenachrichtigung vor Aufhebung der Einschränkung', '["restriction"]', 16),
|
|
||||||
('dsr_rejected_identity', 'Ablehnung - Identität nicht verifizierbar', 'Ablehnung mangels Identitätsnachweis', '["access","rectification","erasure","restriction","portability"]', 17),
|
|
||||||
('dsr_rejected_exception', 'Ablehnung - Ausnahme', 'Ablehnung aufgrund gesetzlicher Ausnahmen (z.B. Art. 17 Abs. 3)', '["erasure","restriction"]', 18),
|
|
||||||
('dsr_rejected_unfounded', 'Ablehnung - Offensichtlich unbegründet', 'Ablehnung nach Art. 12 Abs. 5 DSGVO', '["access","rectification","erasure","restriction","portability"]', 19)
|
|
||||||
ON CONFLICT (template_type) DO NOTHING`,
|
|
||||||
|
|
||||||
// =============================================
|
|
||||||
// Phase 11: EduSearch Seeds Management
|
|
||||||
// Seed URLs for the education search crawler
|
|
||||||
// =============================================
|
|
||||||
|
|
||||||
// EduSearch Seed Categories
|
|
||||||
`CREATE TABLE IF NOT EXISTS edu_search_categories (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
name VARCHAR(50) UNIQUE NOT NULL,
|
|
||||||
display_name VARCHAR(100) NOT NULL,
|
|
||||||
description TEXT,
|
|
||||||
icon VARCHAR(10),
|
|
||||||
sort_order INT DEFAULT 0,
|
|
||||||
is_active BOOLEAN DEFAULT TRUE,
|
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
|
||||||
)`,
|
|
||||||
|
|
||||||
// EduSearch Seeds (crawler seed URLs)
|
|
||||||
`CREATE TABLE IF NOT EXISTS edu_search_seeds (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
url VARCHAR(500) UNIQUE NOT NULL,
|
|
||||||
name VARCHAR(255) NOT NULL,
|
|
||||||
description TEXT,
|
|
||||||
category_id UUID REFERENCES edu_search_categories(id) ON DELETE SET NULL,
|
|
||||||
source_type VARCHAR(20) DEFAULT 'GOV',
|
|
||||||
scope VARCHAR(20) DEFAULT 'FEDERAL',
|
|
||||||
state VARCHAR(5),
|
|
||||||
trust_boost DECIMAL(3,2) DEFAULT 0.50,
|
|
||||||
enabled BOOLEAN DEFAULT TRUE,
|
|
||||||
crawl_depth INT DEFAULT 2,
|
|
||||||
crawl_frequency VARCHAR(20) DEFAULT 'weekly',
|
|
||||||
last_crawled_at TIMESTAMPTZ,
|
|
||||||
last_crawl_status VARCHAR(20),
|
|
||||||
last_crawl_docs INT DEFAULT 0,
|
|
||||||
total_documents INT DEFAULT 0,
|
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
|
||||||
created_by UUID REFERENCES users(id)
|
|
||||||
)`,
|
|
||||||
|
|
||||||
// EduSearch Crawl Runs (history of crawl executions)
|
|
||||||
`CREATE TABLE IF NOT EXISTS edu_search_crawl_runs (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
seed_id UUID REFERENCES edu_search_seeds(id) ON DELETE CASCADE,
|
|
||||||
status VARCHAR(20) DEFAULT 'running',
|
|
||||||
started_at TIMESTAMPTZ DEFAULT NOW(),
|
|
||||||
completed_at TIMESTAMPTZ,
|
|
||||||
pages_crawled INT DEFAULT 0,
|
|
||||||
documents_indexed INT DEFAULT 0,
|
|
||||||
errors_count INT DEFAULT 0,
|
|
||||||
error_details JSONB,
|
|
||||||
triggered_by UUID REFERENCES users(id)
|
|
||||||
)`,
|
|
||||||
|
|
||||||
// EduSearch Denylist (URLs/domains to never crawl)
|
|
||||||
`CREATE TABLE IF NOT EXISTS edu_search_denylist (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
pattern VARCHAR(500) UNIQUE NOT NULL,
|
|
||||||
pattern_type VARCHAR(20) DEFAULT 'domain',
|
|
||||||
reason TEXT,
|
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
|
||||||
created_by UUID REFERENCES users(id)
|
|
||||||
)`,
|
|
||||||
|
|
||||||
// Phase 11 Indexes
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_edu_search_seeds_category ON edu_search_seeds(category_id)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_edu_search_seeds_enabled ON edu_search_seeds(enabled)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_edu_search_seeds_state ON edu_search_seeds(state)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_edu_search_seeds_scope ON edu_search_seeds(scope)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_edu_search_crawl_runs_seed ON edu_search_crawl_runs(seed_id)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_edu_search_crawl_runs_status ON edu_search_crawl_runs(status)`,
|
|
||||||
|
|
||||||
// Insert default EduSearch categories
|
|
||||||
`INSERT INTO edu_search_categories (name, display_name, description, icon, sort_order)
|
|
||||||
VALUES
|
|
||||||
('federal', 'Bundesebene', 'KMK, BMBF, Bildungsserver', '🏛️', 1),
|
|
||||||
('states', 'Bundesländer', 'Ministerien, Landesbildungsserver', '🗺️', 2),
|
|
||||||
('science', 'Wissenschaft', 'Bertelsmann, PISA, IGLU, TIMSS', '🔬', 3),
|
|
||||||
('universities', 'Universitäten', 'Deutsche Hochschulen', '🎓', 4),
|
|
||||||
('schools', 'Schulen', 'Schulwebsites', '🏫', 5),
|
|
||||||
('portals', 'Bildungsportale', 'Lehrer-Online, 4teachers, ZUM', '📚', 6),
|
|
||||||
('eu', 'EU/International', 'Europäische Bildungsberichte', '🇪🇺', 7),
|
|
||||||
('authorities', 'Schulbehörden', 'Regierungspräsidien, Schulämter', '📋', 8)
|
|
||||||
ON CONFLICT (name) DO NOTHING`,
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, migration := range migrations {
|
|
||||||
if _, err := db.Pool.Exec(ctx, migration); err != nil {
|
|
||||||
return fmt.Errorf("migrateDSR: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
package database
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
)
|
|
||||||
|
|
||||||
// migrateEmail creates email template tables, settings, and indexes (Phase 8).
|
|
||||||
func migrateEmail(db *DB) error {
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
migrations := []string{
|
|
||||||
// =============================================
|
|
||||||
// Phase 8: E-Mail Templates (Transactional)
|
|
||||||
// =============================================
|
|
||||||
|
|
||||||
// Email templates (like legal_documents)
|
|
||||||
`CREATE TABLE IF NOT EXISTS email_templates (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
type VARCHAR(50) UNIQUE NOT NULL,
|
|
||||||
name VARCHAR(255) NOT NULL,
|
|
||||||
description TEXT,
|
|
||||||
is_active BOOLEAN DEFAULT TRUE,
|
|
||||||
sort_order INT DEFAULT 0,
|
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
|
||||||
)`,
|
|
||||||
|
|
||||||
// Email template versions (like document_versions)
|
|
||||||
`CREATE TABLE IF NOT EXISTS email_template_versions (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
template_id UUID REFERENCES email_templates(id) ON DELETE CASCADE,
|
|
||||||
version VARCHAR(20) NOT NULL,
|
|
||||||
language VARCHAR(5) DEFAULT 'de',
|
|
||||||
subject VARCHAR(500) NOT NULL,
|
|
||||||
body_html TEXT NOT NULL,
|
|
||||||
body_text TEXT NOT NULL,
|
|
||||||
summary TEXT,
|
|
||||||
status VARCHAR(20) DEFAULT 'draft',
|
|
||||||
published_at TIMESTAMPTZ,
|
|
||||||
scheduled_publish_at TIMESTAMPTZ,
|
|
||||||
created_by UUID REFERENCES users(id),
|
|
||||||
approved_by UUID REFERENCES users(id),
|
|
||||||
approved_at TIMESTAMPTZ,
|
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
|
||||||
UNIQUE(template_id, version, language)
|
|
||||||
)`,
|
|
||||||
|
|
||||||
// Email template approvals (like version_approvals)
|
|
||||||
`CREATE TABLE IF NOT EXISTS email_template_approvals (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
version_id UUID REFERENCES email_template_versions(id) ON DELETE CASCADE,
|
|
||||||
approver_id UUID REFERENCES users(id),
|
|
||||||
action VARCHAR(30) NOT NULL,
|
|
||||||
comment TEXT,
|
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
|
||||||
)`,
|
|
||||||
|
|
||||||
// Email send logs for audit
|
|
||||||
`CREATE TABLE IF NOT EXISTS email_send_logs (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
|
||||||
version_id UUID REFERENCES email_template_versions(id) ON DELETE SET NULL,
|
|
||||||
recipient VARCHAR(255) NOT NULL,
|
|
||||||
subject VARCHAR(500) NOT NULL,
|
|
||||||
status VARCHAR(20) DEFAULT 'queued',
|
|
||||||
error_msg TEXT,
|
|
||||||
variables JSONB,
|
|
||||||
sent_at TIMESTAMPTZ,
|
|
||||||
delivered_at TIMESTAMPTZ,
|
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
|
||||||
)`,
|
|
||||||
|
|
||||||
// Global email settings (logo, colors, signature)
|
|
||||||
`CREATE TABLE IF NOT EXISTS email_template_settings (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
logo_url TEXT,
|
|
||||||
logo_base64 TEXT,
|
|
||||||
company_name VARCHAR(255) DEFAULT 'BreakPilot',
|
|
||||||
sender_name VARCHAR(255) DEFAULT 'BreakPilot',
|
|
||||||
sender_email VARCHAR(255) DEFAULT 'noreply@breakpilot.app',
|
|
||||||
reply_to_email VARCHAR(255),
|
|
||||||
footer_html TEXT,
|
|
||||||
footer_text TEXT,
|
|
||||||
primary_color VARCHAR(7) DEFAULT '#2563eb',
|
|
||||||
secondary_color VARCHAR(7) DEFAULT '#64748b',
|
|
||||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
|
||||||
updated_by UUID REFERENCES users(id)
|
|
||||||
)`,
|
|
||||||
|
|
||||||
// Insert default email settings
|
|
||||||
`INSERT INTO email_template_settings (id, company_name, sender_name, sender_email, primary_color, secondary_color)
|
|
||||||
VALUES (gen_random_uuid(), 'BreakPilot', 'BreakPilot', 'noreply@breakpilot.app', '#2563eb', '#64748b')
|
|
||||||
ON CONFLICT DO NOTHING`,
|
|
||||||
|
|
||||||
// Phase 8 Indexes
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_email_templates_type ON email_templates(type)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_email_template_versions_template ON email_template_versions(template_id)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_email_template_versions_status ON email_template_versions(status)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_email_template_approvals_version ON email_template_approvals(version_id)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_email_send_logs_user ON email_send_logs(user_id)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_email_send_logs_created ON email_send_logs(created_at)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_email_send_logs_status ON email_send_logs(status)`,
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, migration := range migrations {
|
|
||||||
if _, err := db.Pool.Exec(ctx, migration); err != nil {
|
|
||||||
return fmt.Errorf("migrateEmail: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,171 +0,0 @@
|
|||||||
package database
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
)
|
|
||||||
|
|
||||||
// migrateOAuth creates OAuth 2.0 and 2FA tables (Phases 6-7),
|
|
||||||
// plus default seed data for OAuth clients, cookie categories,
|
|
||||||
// and legal documents.
|
|
||||||
func migrateOAuth(db *DB) error {
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
migrations := []string{
|
|
||||||
// =============================================
|
|
||||||
// Phase 6: OAuth 2.0 Authorization Code Flow
|
|
||||||
// =============================================
|
|
||||||
|
|
||||||
// OAuth 2.0 Clients
|
|
||||||
`CREATE TABLE IF NOT EXISTS oauth_clients (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
client_id VARCHAR(64) UNIQUE NOT NULL,
|
|
||||||
client_secret VARCHAR(255),
|
|
||||||
name VARCHAR(255) NOT NULL,
|
|
||||||
description TEXT,
|
|
||||||
redirect_uris JSONB NOT NULL DEFAULT '[]',
|
|
||||||
scopes JSONB NOT NULL DEFAULT '["openid", "profile", "email"]',
|
|
||||||
grant_types JSONB NOT NULL DEFAULT '["authorization_code", "refresh_token"]',
|
|
||||||
is_public BOOLEAN DEFAULT FALSE,
|
|
||||||
is_active BOOLEAN DEFAULT TRUE,
|
|
||||||
created_by UUID REFERENCES users(id),
|
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
|
||||||
)`,
|
|
||||||
|
|
||||||
// OAuth 2.0 Authorization Codes
|
|
||||||
`CREATE TABLE IF NOT EXISTS oauth_authorization_codes (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
code VARCHAR(255) UNIQUE NOT NULL,
|
|
||||||
client_id VARCHAR(64) NOT NULL REFERENCES oauth_clients(client_id),
|
|
||||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
||||||
redirect_uri TEXT NOT NULL,
|
|
||||||
scopes JSONB NOT NULL DEFAULT '[]',
|
|
||||||
code_challenge VARCHAR(255),
|
|
||||||
code_challenge_method VARCHAR(10),
|
|
||||||
expires_at TIMESTAMPTZ NOT NULL,
|
|
||||||
used_at TIMESTAMPTZ,
|
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
|
||||||
)`,
|
|
||||||
|
|
||||||
// OAuth 2.0 Access Tokens
|
|
||||||
`CREATE TABLE IF NOT EXISTS oauth_access_tokens (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
token_hash VARCHAR(255) UNIQUE NOT NULL,
|
|
||||||
client_id VARCHAR(64) NOT NULL REFERENCES oauth_clients(client_id),
|
|
||||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
||||||
scopes JSONB NOT NULL DEFAULT '[]',
|
|
||||||
expires_at TIMESTAMPTZ NOT NULL,
|
|
||||||
revoked_at TIMESTAMPTZ,
|
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
|
||||||
)`,
|
|
||||||
|
|
||||||
// OAuth 2.0 Refresh Tokens
|
|
||||||
`CREATE TABLE IF NOT EXISTS oauth_refresh_tokens (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
token_hash VARCHAR(255) UNIQUE NOT NULL,
|
|
||||||
access_token_id UUID REFERENCES oauth_access_tokens(id) ON DELETE CASCADE,
|
|
||||||
client_id VARCHAR(64) NOT NULL REFERENCES oauth_clients(client_id),
|
|
||||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
||||||
scopes JSONB NOT NULL DEFAULT '[]',
|
|
||||||
expires_at TIMESTAMPTZ NOT NULL,
|
|
||||||
revoked_at TIMESTAMPTZ,
|
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
|
||||||
)`,
|
|
||||||
|
|
||||||
// =============================================
|
|
||||||
// Phase 7: Two-Factor Authentication (2FA/TOTP)
|
|
||||||
// =============================================
|
|
||||||
|
|
||||||
// User TOTP secrets and recovery codes
|
|
||||||
`CREATE TABLE IF NOT EXISTS user_totp (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
user_id UUID UNIQUE NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
||||||
secret VARCHAR(255) NOT NULL,
|
|
||||||
verified BOOLEAN DEFAULT FALSE,
|
|
||||||
recovery_codes JSONB DEFAULT '[]',
|
|
||||||
enabled_at TIMESTAMPTZ,
|
|
||||||
last_used_at TIMESTAMPTZ,
|
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
|
||||||
)`,
|
|
||||||
|
|
||||||
// 2FA challenges during login
|
|
||||||
`CREATE TABLE IF NOT EXISTS two_factor_challenges (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
||||||
challenge_id VARCHAR(255) UNIQUE NOT NULL,
|
|
||||||
ip_address INET,
|
|
||||||
user_agent TEXT,
|
|
||||||
expires_at TIMESTAMPTZ NOT NULL,
|
|
||||||
used_at TIMESTAMPTZ,
|
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
|
||||||
)`,
|
|
||||||
|
|
||||||
// Add 2FA required flag to users
|
|
||||||
`ALTER TABLE users ADD COLUMN IF NOT EXISTS two_factor_enabled BOOLEAN DEFAULT FALSE`,
|
|
||||||
`ALTER TABLE users ADD COLUMN IF NOT EXISTS two_factor_verified_at TIMESTAMPTZ`,
|
|
||||||
|
|
||||||
// Phase 6 & 7 Indexes
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_oauth_clients_client_id ON oauth_clients(client_id)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_oauth_auth_codes_code ON oauth_authorization_codes(code)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_oauth_auth_codes_user ON oauth_authorization_codes(user_id)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_oauth_access_tokens_hash ON oauth_access_tokens(token_hash)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_oauth_access_tokens_user ON oauth_access_tokens(user_id)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_oauth_refresh_tokens_hash ON oauth_refresh_tokens(token_hash)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_oauth_refresh_tokens_user ON oauth_refresh_tokens(user_id)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_user_totp_user ON user_totp(user_id)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_two_factor_challenges_id ON two_factor_challenges(challenge_id)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_two_factor_challenges_user ON two_factor_challenges(user_id)`,
|
|
||||||
|
|
||||||
// Insert default OAuth client for BreakPilot PWA (public client with PKCE)
|
|
||||||
`INSERT INTO oauth_clients (client_id, name, description, redirect_uris, scopes, grant_types, is_public)
|
|
||||||
VALUES (
|
|
||||||
'breakpilot-pwa',
|
|
||||||
'BreakPilot PWA',
|
|
||||||
'Official BreakPilot Progressive Web Application',
|
|
||||||
'["http://localhost:8000/oauth/callback", "http://localhost:8000/app/oauth/callback"]',
|
|
||||||
'["openid", "profile", "email", "consent:read", "consent:write"]',
|
|
||||||
'["authorization_code", "refresh_token"]',
|
|
||||||
true
|
|
||||||
) ON CONFLICT (client_id) DO NOTHING`,
|
|
||||||
|
|
||||||
// Insert default cookie categories
|
|
||||||
`INSERT INTO cookie_categories (name, display_name_de, display_name_en, description_de, description_en, is_mandatory, sort_order)
|
|
||||||
VALUES
|
|
||||||
('necessary', 'Notwendige Cookies', 'Necessary Cookies',
|
|
||||||
'Diese Cookies sind für die Grundfunktionen der Website unbedingt erforderlich.',
|
|
||||||
'These cookies are essential for the basic functions of the website.',
|
|
||||||
true, 1),
|
|
||||||
('functional', 'Funktionale Cookies', 'Functional Cookies',
|
|
||||||
'Diese Cookies ermöglichen erweiterte Funktionen und Personalisierung.',
|
|
||||||
'These cookies enable enhanced functionality and personalization.',
|
|
||||||
false, 2),
|
|
||||||
('analytics', 'Analyse Cookies', 'Analytics Cookies',
|
|
||||||
'Diese Cookies helfen uns zu verstehen, wie Besucher mit der Website interagieren.',
|
|
||||||
'These cookies help us understand how visitors interact with the website.',
|
|
||||||
false, 3),
|
|
||||||
('marketing', 'Marketing Cookies', 'Marketing Cookies',
|
|
||||||
'Diese Cookies werden verwendet, um Werbung relevanter für Sie zu gestalten.',
|
|
||||||
'These cookies are used to make advertising more relevant to you.',
|
|
||||||
false, 4)
|
|
||||||
ON CONFLICT (name) DO NOTHING`,
|
|
||||||
|
|
||||||
// Insert default legal documents
|
|
||||||
`INSERT INTO legal_documents (type, name, description, is_mandatory, sort_order)
|
|
||||||
VALUES
|
|
||||||
('terms', 'Allgemeine Geschäftsbedingungen', 'Die allgemeinen Geschäftsbedingungen für die Nutzung von BreakPilot.', true, 1),
|
|
||||||
('privacy', 'Datenschutzerklärung', 'Informationen über die Verarbeitung Ihrer personenbezogenen Daten.', true, 2),
|
|
||||||
('cookies', 'Cookie-Richtlinie', 'Informationen über die Verwendung von Cookies auf unserer Website.', false, 3),
|
|
||||||
('community', 'Community Guidelines', 'Regeln für das Verhalten in der BreakPilot Community.', true, 4)
|
|
||||||
ON CONFLICT DO NOTHING`,
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, migration := range migrations {
|
|
||||||
if _, err := db.Pool.Exec(ctx, migration); err != nil {
|
|
||||||
return fmt.Errorf("migrateOAuth: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,182 +0,0 @@
|
|||||||
package database
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
)
|
|
||||||
|
|
||||||
// migrateSchool creates school management tables: schools, classes,
|
|
||||||
// students, teachers, parents, timetable, attendance, grades,
|
|
||||||
// class diary, parent meetings, Matrix integration (Phase 9).
|
|
||||||
func migrateSchool(db *DB) error {
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
migrations := []string{
|
|
||||||
// =============================================
|
|
||||||
// Phase 9: Schulverwaltung / School Management
|
|
||||||
// Matrix-basierte Kommunikation für Schulen
|
|
||||||
// =============================================
|
|
||||||
|
|
||||||
// Schools table
|
|
||||||
`CREATE TABLE IF NOT EXISTS schools (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
name VARCHAR(255) NOT NULL,
|
|
||||||
short_name VARCHAR(50),
|
|
||||||
type VARCHAR(50) NOT NULL,
|
|
||||||
address TEXT,
|
|
||||||
city VARCHAR(100),
|
|
||||||
postal_code VARCHAR(20),
|
|
||||||
state VARCHAR(50),
|
|
||||||
country VARCHAR(2) DEFAULT 'DE',
|
|
||||||
phone VARCHAR(50),
|
|
||||||
email VARCHAR(255),
|
|
||||||
website VARCHAR(255),
|
|
||||||
matrix_server_name VARCHAR(255),
|
|
||||||
logo_url TEXT,
|
|
||||||
is_active BOOLEAN DEFAULT TRUE,
|
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
|
||||||
)`,
|
|
||||||
|
|
||||||
// School years
|
|
||||||
`CREATE TABLE IF NOT EXISTS school_years (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
school_id UUID NOT NULL REFERENCES schools(id) ON DELETE CASCADE,
|
|
||||||
name VARCHAR(20) NOT NULL,
|
|
||||||
start_date DATE NOT NULL,
|
|
||||||
end_date DATE NOT NULL,
|
|
||||||
is_current BOOLEAN DEFAULT FALSE,
|
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
|
||||||
UNIQUE(school_id, name)
|
|
||||||
)`,
|
|
||||||
|
|
||||||
// Subjects
|
|
||||||
`CREATE TABLE IF NOT EXISTS subjects (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
school_id UUID NOT NULL REFERENCES schools(id) ON DELETE CASCADE,
|
|
||||||
name VARCHAR(100) NOT NULL,
|
|
||||||
short_name VARCHAR(10) NOT NULL,
|
|
||||||
color VARCHAR(7),
|
|
||||||
is_active BOOLEAN DEFAULT TRUE,
|
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
|
||||||
UNIQUE(school_id, short_name)
|
|
||||||
)`,
|
|
||||||
|
|
||||||
// Classes
|
|
||||||
`CREATE TABLE IF NOT EXISTS classes (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
school_id UUID NOT NULL REFERENCES schools(id) ON DELETE CASCADE,
|
|
||||||
school_year_id UUID NOT NULL REFERENCES school_years(id) ON DELETE CASCADE,
|
|
||||||
name VARCHAR(20) NOT NULL,
|
|
||||||
grade INT NOT NULL,
|
|
||||||
section VARCHAR(5),
|
|
||||||
room VARCHAR(50),
|
|
||||||
matrix_info_room VARCHAR(255),
|
|
||||||
matrix_rep_room VARCHAR(255),
|
|
||||||
is_active BOOLEAN DEFAULT TRUE,
|
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
|
||||||
UNIQUE(school_id, school_year_id, name)
|
|
||||||
)`,
|
|
||||||
|
|
||||||
// Students
|
|
||||||
`CREATE TABLE IF NOT EXISTS students (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
school_id UUID NOT NULL REFERENCES schools(id) ON DELETE CASCADE,
|
|
||||||
class_id UUID NOT NULL REFERENCES classes(id) ON DELETE CASCADE,
|
|
||||||
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
|
||||||
student_number VARCHAR(50),
|
|
||||||
first_name VARCHAR(100) NOT NULL,
|
|
||||||
last_name VARCHAR(100) NOT NULL,
|
|
||||||
date_of_birth DATE,
|
|
||||||
gender VARCHAR(1),
|
|
||||||
matrix_user_id VARCHAR(255),
|
|
||||||
matrix_dm_room VARCHAR(255),
|
|
||||||
is_active BOOLEAN DEFAULT TRUE,
|
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
|
||||||
)`,
|
|
||||||
|
|
||||||
// Teachers
|
|
||||||
`CREATE TABLE IF NOT EXISTS teachers (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
school_id UUID NOT NULL REFERENCES schools(id) ON DELETE CASCADE,
|
|
||||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
||||||
teacher_code VARCHAR(10),
|
|
||||||
title VARCHAR(20),
|
|
||||||
first_name VARCHAR(100) NOT NULL,
|
|
||||||
last_name VARCHAR(100) NOT NULL,
|
|
||||||
matrix_user_id VARCHAR(255),
|
|
||||||
is_active BOOLEAN DEFAULT TRUE,
|
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
|
||||||
UNIQUE(school_id, user_id)
|
|
||||||
)`,
|
|
||||||
|
|
||||||
// Class teachers assignment
|
|
||||||
`CREATE TABLE IF NOT EXISTS class_teachers (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
class_id UUID NOT NULL REFERENCES classes(id) ON DELETE CASCADE,
|
|
||||||
teacher_id UUID NOT NULL REFERENCES teachers(id) ON DELETE CASCADE,
|
|
||||||
is_primary BOOLEAN DEFAULT FALSE,
|
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
|
||||||
UNIQUE(class_id, teacher_id)
|
|
||||||
)`,
|
|
||||||
|
|
||||||
// Teacher subjects assignment
|
|
||||||
`CREATE TABLE IF NOT EXISTS teacher_subjects (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
teacher_id UUID NOT NULL REFERENCES teachers(id) ON DELETE CASCADE,
|
|
||||||
subject_id UUID NOT NULL REFERENCES subjects(id) ON DELETE CASCADE,
|
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
|
||||||
UNIQUE(teacher_id, subject_id)
|
|
||||||
)`,
|
|
||||||
|
|
||||||
// Parents
|
|
||||||
`CREATE TABLE IF NOT EXISTS parents (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
||||||
matrix_user_id VARCHAR(255),
|
|
||||||
first_name VARCHAR(100) NOT NULL,
|
|
||||||
last_name VARCHAR(100) NOT NULL,
|
|
||||||
phone VARCHAR(50),
|
|
||||||
emergency_contact BOOLEAN DEFAULT FALSE,
|
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
|
||||||
UNIQUE(user_id)
|
|
||||||
)`,
|
|
||||||
|
|
||||||
// Student-parent relationships
|
|
||||||
`CREATE TABLE IF NOT EXISTS student_parents (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
student_id UUID NOT NULL REFERENCES students(id) ON DELETE CASCADE,
|
|
||||||
parent_id UUID NOT NULL REFERENCES parents(id) ON DELETE CASCADE,
|
|
||||||
relationship VARCHAR(20) NOT NULL,
|
|
||||||
is_primary BOOLEAN DEFAULT FALSE,
|
|
||||||
has_custody BOOLEAN DEFAULT TRUE,
|
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
|
||||||
UNIQUE(student_id, parent_id)
|
|
||||||
)`,
|
|
||||||
|
|
||||||
// Parent representatives
|
|
||||||
`CREATE TABLE IF NOT EXISTS parent_representatives (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
class_id UUID NOT NULL REFERENCES classes(id) ON DELETE CASCADE,
|
|
||||||
parent_id UUID NOT NULL REFERENCES parents(id) ON DELETE CASCADE,
|
|
||||||
role VARCHAR(20) NOT NULL,
|
|
||||||
elected_at TIMESTAMPTZ NOT NULL,
|
|
||||||
expires_at TIMESTAMPTZ,
|
|
||||||
is_active BOOLEAN DEFAULT TRUE,
|
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
|
||||||
)`,
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, migration := range migrations {
|
|
||||||
if _, err := db.Pool.Exec(ctx, migration); err != nil {
|
|
||||||
return fmt.Errorf("migrateSchool: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Run the second batch (timetable, attendance, grades, etc.)
|
|
||||||
return migrateSchoolPart2(db)
|
|
||||||
}
|
|
||||||
@@ -1,346 +0,0 @@
|
|||||||
package database
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
)
|
|
||||||
|
|
||||||
// migrateSchoolPart2 creates timetable, attendance, grades, diary,
|
|
||||||
// meetings, Matrix, and Phase 9 indexes/seed data.
|
|
||||||
func migrateSchoolPart2(db *DB) error {
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
migrations := []string{
|
|
||||||
// =============================================
|
|
||||||
// Stundenplan / Timetable
|
|
||||||
// =============================================
|
|
||||||
|
|
||||||
// Timetable slots (Stundenraster)
|
|
||||||
`CREATE TABLE IF NOT EXISTS timetable_slots (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
school_id UUID NOT NULL REFERENCES schools(id) ON DELETE CASCADE,
|
|
||||||
slot_number INT NOT NULL,
|
|
||||||
start_time TIME NOT NULL,
|
|
||||||
end_time TIME NOT NULL,
|
|
||||||
is_break BOOLEAN DEFAULT FALSE,
|
|
||||||
name VARCHAR(50),
|
|
||||||
UNIQUE(school_id, slot_number)
|
|
||||||
)`,
|
|
||||||
|
|
||||||
// Timetable entries (Stundenplan)
|
|
||||||
`CREATE TABLE IF NOT EXISTS timetable_entries (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
school_year_id UUID NOT NULL REFERENCES school_years(id) ON DELETE CASCADE,
|
|
||||||
class_id UUID NOT NULL REFERENCES classes(id) ON DELETE CASCADE,
|
|
||||||
subject_id UUID NOT NULL REFERENCES subjects(id) ON DELETE CASCADE,
|
|
||||||
teacher_id UUID NOT NULL REFERENCES teachers(id) ON DELETE CASCADE,
|
|
||||||
slot_id UUID NOT NULL REFERENCES timetable_slots(id) ON DELETE CASCADE,
|
|
||||||
day_of_week INT NOT NULL CHECK (day_of_week >= 1 AND day_of_week <= 7),
|
|
||||||
room VARCHAR(50),
|
|
||||||
valid_from DATE NOT NULL,
|
|
||||||
valid_until DATE,
|
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
|
||||||
)`,
|
|
||||||
|
|
||||||
// Timetable substitutions (Vertretungsplan)
|
|
||||||
`CREATE TABLE IF NOT EXISTS timetable_substitutions (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
original_entry_id UUID NOT NULL REFERENCES timetable_entries(id) ON DELETE CASCADE,
|
|
||||||
date DATE NOT NULL,
|
|
||||||
substitute_teacher_id UUID REFERENCES teachers(id) ON DELETE SET NULL,
|
|
||||||
substitute_subject_id UUID REFERENCES subjects(id) ON DELETE SET NULL,
|
|
||||||
room VARCHAR(50),
|
|
||||||
type VARCHAR(20) NOT NULL,
|
|
||||||
note TEXT,
|
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
|
||||||
created_by UUID NOT NULL REFERENCES users(id)
|
|
||||||
)`,
|
|
||||||
|
|
||||||
// =============================================
|
|
||||||
// Abwesenheit / Attendance
|
|
||||||
// =============================================
|
|
||||||
|
|
||||||
// Attendance records per lesson
|
|
||||||
`CREATE TABLE IF NOT EXISTS attendance_records (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
student_id UUID NOT NULL REFERENCES students(id) ON DELETE CASCADE,
|
|
||||||
timetable_entry_id UUID REFERENCES timetable_entries(id) ON DELETE SET NULL,
|
|
||||||
date DATE NOT NULL,
|
|
||||||
slot_id UUID NOT NULL REFERENCES timetable_slots(id) ON DELETE CASCADE,
|
|
||||||
status VARCHAR(30) NOT NULL,
|
|
||||||
recorded_by UUID NOT NULL REFERENCES users(id),
|
|
||||||
note TEXT,
|
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
|
||||||
UNIQUE(student_id, date, slot_id)
|
|
||||||
)`,
|
|
||||||
|
|
||||||
// Absence reports (Krankmeldungen)
|
|
||||||
`CREATE TABLE IF NOT EXISTS absence_reports (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
student_id UUID NOT NULL REFERENCES students(id) ON DELETE CASCADE,
|
|
||||||
start_date DATE NOT NULL,
|
|
||||||
end_date DATE NOT NULL,
|
|
||||||
reason TEXT,
|
|
||||||
reason_category VARCHAR(30) NOT NULL,
|
|
||||||
status VARCHAR(20) NOT NULL DEFAULT 'reported',
|
|
||||||
reported_by UUID NOT NULL REFERENCES users(id),
|
|
||||||
reported_at TIMESTAMPTZ DEFAULT NOW(),
|
|
||||||
confirmed_by UUID REFERENCES users(id),
|
|
||||||
confirmed_at TIMESTAMPTZ,
|
|
||||||
medical_certificate BOOLEAN DEFAULT FALSE,
|
|
||||||
certificate_uploaded BOOLEAN DEFAULT FALSE,
|
|
||||||
matrix_notification_sent BOOLEAN DEFAULT FALSE,
|
|
||||||
email_notification_sent BOOLEAN DEFAULT FALSE,
|
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
|
||||||
)`,
|
|
||||||
|
|
||||||
// Absence notifications to parents
|
|
||||||
`CREATE TABLE IF NOT EXISTS absence_notifications (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
attendance_record_id UUID NOT NULL REFERENCES attendance_records(id) ON DELETE CASCADE,
|
|
||||||
parent_id UUID NOT NULL REFERENCES parents(id) ON DELETE CASCADE,
|
|
||||||
channel VARCHAR(20) NOT NULL,
|
|
||||||
message_content TEXT NOT NULL,
|
|
||||||
sent_at TIMESTAMPTZ,
|
|
||||||
read_at TIMESTAMPTZ,
|
|
||||||
response_received BOOLEAN DEFAULT FALSE,
|
|
||||||
response_content TEXT,
|
|
||||||
response_at TIMESTAMPTZ,
|
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
|
||||||
)`,
|
|
||||||
|
|
||||||
// =============================================
|
|
||||||
// Notenspiegel / Grades
|
|
||||||
// =============================================
|
|
||||||
|
|
||||||
// Grade scales
|
|
||||||
`CREATE TABLE IF NOT EXISTS grade_scales (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
school_id UUID NOT NULL REFERENCES schools(id) ON DELETE CASCADE,
|
|
||||||
name VARCHAR(50) NOT NULL,
|
|
||||||
min_value DECIMAL(5,2) NOT NULL,
|
|
||||||
max_value DECIMAL(5,2) NOT NULL,
|
|
||||||
passing_value DECIMAL(5,2) NOT NULL,
|
|
||||||
is_ascending BOOLEAN DEFAULT FALSE,
|
|
||||||
is_default BOOLEAN DEFAULT FALSE,
|
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
|
||||||
)`,
|
|
||||||
|
|
||||||
// Grades
|
|
||||||
`CREATE TABLE IF NOT EXISTS grades (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
student_id UUID NOT NULL REFERENCES students(id) ON DELETE CASCADE,
|
|
||||||
subject_id UUID NOT NULL REFERENCES subjects(id) ON DELETE CASCADE,
|
|
||||||
teacher_id UUID NOT NULL REFERENCES teachers(id) ON DELETE CASCADE,
|
|
||||||
school_year_id UUID NOT NULL REFERENCES school_years(id) ON DELETE CASCADE,
|
|
||||||
grade_scale_id UUID NOT NULL REFERENCES grade_scales(id) ON DELETE CASCADE,
|
|
||||||
type VARCHAR(30) NOT NULL,
|
|
||||||
value DECIMAL(5,2) NOT NULL,
|
|
||||||
weight DECIMAL(3,2) DEFAULT 1.0,
|
|
||||||
date DATE NOT NULL,
|
|
||||||
title VARCHAR(100),
|
|
||||||
description TEXT,
|
|
||||||
is_visible BOOLEAN DEFAULT TRUE,
|
|
||||||
semester INT NOT NULL CHECK (semester IN (1, 2)),
|
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
|
||||||
)`,
|
|
||||||
|
|
||||||
// Grade comments
|
|
||||||
`CREATE TABLE IF NOT EXISTS grade_comments (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
grade_id UUID NOT NULL REFERENCES grades(id) ON DELETE CASCADE,
|
|
||||||
teacher_id UUID NOT NULL REFERENCES teachers(id) ON DELETE CASCADE,
|
|
||||||
comment TEXT NOT NULL,
|
|
||||||
is_private BOOLEAN DEFAULT FALSE,
|
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
|
||||||
)`,
|
|
||||||
|
|
||||||
// =============================================
|
|
||||||
// Klassenbuch / Class Diary
|
|
||||||
// =============================================
|
|
||||||
|
|
||||||
// Class diary entries
|
|
||||||
`CREATE TABLE IF NOT EXISTS class_diary_entries (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
class_id UUID NOT NULL REFERENCES classes(id) ON DELETE CASCADE,
|
|
||||||
date DATE NOT NULL,
|
|
||||||
slot_id UUID NOT NULL REFERENCES timetable_slots(id) ON DELETE CASCADE,
|
|
||||||
subject_id UUID NOT NULL REFERENCES subjects(id) ON DELETE CASCADE,
|
|
||||||
teacher_id UUID NOT NULL REFERENCES teachers(id) ON DELETE CASCADE,
|
|
||||||
topic TEXT,
|
|
||||||
homework TEXT,
|
|
||||||
homework_due_date DATE,
|
|
||||||
materials TEXT,
|
|
||||||
notes TEXT,
|
|
||||||
is_cancelled BOOLEAN DEFAULT FALSE,
|
|
||||||
cancellation_reason TEXT,
|
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
|
||||||
UNIQUE(class_id, date, slot_id)
|
|
||||||
)`,
|
|
||||||
|
|
||||||
// =============================================
|
|
||||||
// Elterngespräche / Parent Meetings
|
|
||||||
// =============================================
|
|
||||||
|
|
||||||
// Parent meeting slots
|
|
||||||
`CREATE TABLE IF NOT EXISTS parent_meeting_slots (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
teacher_id UUID NOT NULL REFERENCES teachers(id) ON DELETE CASCADE,
|
|
||||||
date DATE NOT NULL,
|
|
||||||
start_time TIME NOT NULL,
|
|
||||||
end_time TIME NOT NULL,
|
|
||||||
location VARCHAR(100),
|
|
||||||
is_online BOOLEAN DEFAULT FALSE,
|
|
||||||
meeting_link TEXT,
|
|
||||||
is_booked BOOLEAN DEFAULT FALSE,
|
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
|
||||||
)`,
|
|
||||||
|
|
||||||
// Parent meetings
|
|
||||||
`CREATE TABLE IF NOT EXISTS parent_meetings (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
slot_id UUID NOT NULL REFERENCES parent_meeting_slots(id) ON DELETE CASCADE,
|
|
||||||
parent_id UUID NOT NULL REFERENCES parents(id) ON DELETE CASCADE,
|
|
||||||
student_id UUID NOT NULL REFERENCES students(id) ON DELETE CASCADE,
|
|
||||||
topic TEXT,
|
|
||||||
notes TEXT,
|
|
||||||
status VARCHAR(20) NOT NULL DEFAULT 'scheduled',
|
|
||||||
cancelled_at TIMESTAMPTZ,
|
|
||||||
cancelled_by UUID REFERENCES users(id),
|
|
||||||
cancel_reason TEXT,
|
|
||||||
completed_at TIMESTAMPTZ,
|
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
|
||||||
)`,
|
|
||||||
|
|
||||||
// =============================================
|
|
||||||
// Matrix / Communication Integration
|
|
||||||
// =============================================
|
|
||||||
|
|
||||||
// Matrix rooms
|
|
||||||
`CREATE TABLE IF NOT EXISTS matrix_rooms (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
school_id UUID NOT NULL REFERENCES schools(id) ON DELETE CASCADE,
|
|
||||||
matrix_room_id VARCHAR(255) NOT NULL UNIQUE,
|
|
||||||
type VARCHAR(30) NOT NULL,
|
|
||||||
class_id UUID REFERENCES classes(id) ON DELETE SET NULL,
|
|
||||||
student_id UUID REFERENCES students(id) ON DELETE SET NULL,
|
|
||||||
name VARCHAR(255) NOT NULL,
|
|
||||||
is_encrypted BOOLEAN DEFAULT TRUE,
|
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
|
||||||
)`,
|
|
||||||
|
|
||||||
// Matrix room members
|
|
||||||
`CREATE TABLE IF NOT EXISTS matrix_room_members (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
matrix_room_id UUID NOT NULL REFERENCES matrix_rooms(id) ON DELETE CASCADE,
|
|
||||||
matrix_user_id VARCHAR(255) NOT NULL,
|
|
||||||
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
|
||||||
power_level INT DEFAULT 0,
|
|
||||||
can_write BOOLEAN DEFAULT TRUE,
|
|
||||||
joined_at TIMESTAMPTZ DEFAULT NOW(),
|
|
||||||
left_at TIMESTAMPTZ,
|
|
||||||
UNIQUE(matrix_room_id, matrix_user_id)
|
|
||||||
)`,
|
|
||||||
|
|
||||||
// Parent onboarding tokens (QR codes)
|
|
||||||
`CREATE TABLE IF NOT EXISTS parent_onboarding_tokens (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
school_id UUID NOT NULL REFERENCES schools(id) ON DELETE CASCADE,
|
|
||||||
class_id UUID NOT NULL REFERENCES classes(id) ON DELETE CASCADE,
|
|
||||||
student_id UUID NOT NULL REFERENCES students(id) ON DELETE CASCADE,
|
|
||||||
token VARCHAR(255) NOT NULL UNIQUE,
|
|
||||||
role VARCHAR(30) NOT NULL DEFAULT 'parent',
|
|
||||||
expires_at TIMESTAMPTZ NOT NULL,
|
|
||||||
used_at TIMESTAMPTZ,
|
|
||||||
used_by_user_id UUID REFERENCES users(id),
|
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
|
||||||
created_by UUID NOT NULL REFERENCES users(id)
|
|
||||||
)`,
|
|
||||||
|
|
||||||
// =============================================
|
|
||||||
// Phase 9 Indexes
|
|
||||||
// =============================================
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_schools_active ON schools(is_active)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_school_years_school ON school_years(school_id)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_school_years_current ON school_years(is_current)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_classes_school ON classes(school_id)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_classes_school_year ON classes(school_year_id)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_students_school ON students(school_id)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_students_class ON students(class_id)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_students_user ON students(user_id)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_teachers_school ON teachers(school_id)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_teachers_user ON teachers(user_id)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_class_teachers_class ON class_teachers(class_id)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_class_teachers_teacher ON class_teachers(teacher_id)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_parents_user ON parents(user_id)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_student_parents_student ON student_parents(student_id)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_student_parents_parent ON student_parents(parent_id)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_timetable_entries_class ON timetable_entries(class_id)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_timetable_entries_teacher ON timetable_entries(teacher_id)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_timetable_entries_day ON timetable_entries(day_of_week)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_timetable_substitutions_date ON timetable_substitutions(date)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_timetable_substitutions_entry ON timetable_substitutions(original_entry_id)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_attendance_records_student ON attendance_records(student_id)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_attendance_records_date ON attendance_records(date)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_absence_reports_student ON absence_reports(student_id)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_absence_reports_dates ON absence_reports(start_date, end_date)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_grades_student ON grades(student_id)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_grades_subject ON grades(subject_id)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_grades_teacher ON grades(teacher_id)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_grades_school_year ON grades(school_year_id)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_class_diary_class_date ON class_diary_entries(class_id, date)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_parent_meeting_slots_teacher ON parent_meeting_slots(teacher_id)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_parent_meeting_slots_date ON parent_meeting_slots(date)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_parent_meetings_slot ON parent_meetings(slot_id)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_parent_meetings_parent ON parent_meetings(parent_id)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_matrix_rooms_school ON matrix_rooms(school_id)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_matrix_rooms_class ON matrix_rooms(class_id)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_matrix_room_members_room ON matrix_room_members(matrix_room_id)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_parent_onboarding_tokens_token ON parent_onboarding_tokens(token)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_parent_onboarding_tokens_student ON parent_onboarding_tokens(student_id)`,
|
|
||||||
|
|
||||||
// Insert default grade scales
|
|
||||||
`INSERT INTO grade_scales (id, school_id, name, min_value, max_value, passing_value, is_ascending, is_default)
|
|
||||||
SELECT gen_random_uuid(), s.id, '1-6 (Noten)', 1, 6, 4, false, true
|
|
||||||
FROM schools s
|
|
||||||
WHERE NOT EXISTS (SELECT 1 FROM grade_scales gs WHERE gs.school_id = s.id AND gs.name = '1-6 (Noten)')
|
|
||||||
ON CONFLICT DO NOTHING`,
|
|
||||||
|
|
||||||
// Insert default timetable slots for schools
|
|
||||||
`DO $$
|
|
||||||
DECLARE
|
|
||||||
school_rec RECORD;
|
|
||||||
BEGIN
|
|
||||||
FOR school_rec IN SELECT id FROM schools LOOP
|
|
||||||
INSERT INTO timetable_slots (school_id, slot_number, start_time, end_time, is_break, name)
|
|
||||||
VALUES
|
|
||||||
(school_rec.id, 1, '08:00', '08:45', false, '1. Stunde'),
|
|
||||||
(school_rec.id, 2, '08:45', '09:30', false, '2. Stunde'),
|
|
||||||
(school_rec.id, 3, '09:30', '09:50', true, 'Erste Pause'),
|
|
||||||
(school_rec.id, 4, '09:50', '10:35', false, '3. Stunde'),
|
|
||||||
(school_rec.id, 5, '10:35', '11:20', false, '4. Stunde'),
|
|
||||||
(school_rec.id, 6, '11:20', '11:40', true, 'Zweite Pause'),
|
|
||||||
(school_rec.id, 7, '11:40', '12:25', false, '5. Stunde'),
|
|
||||||
(school_rec.id, 8, '12:25', '13:10', false, '6. Stunde'),
|
|
||||||
(school_rec.id, 9, '13:10', '14:00', true, 'Mittagspause'),
|
|
||||||
(school_rec.id, 10, '14:00', '14:45', false, '7. Stunde'),
|
|
||||||
(school_rec.id, 11, '14:45', '15:30', false, '8. Stunde')
|
|
||||||
ON CONFLICT (school_id, slot_number) DO NOTHING;
|
|
||||||
END LOOP;
|
|
||||||
END $$`,
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, migration := range migrations {
|
|
||||||
if _, err := db.Pool.Exec(ctx, migration); err != nil {
|
|
||||||
return fmt.Errorf("migrateSchool: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,455 +0,0 @@
|
|||||||
package handlers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/breakpilot/consent-service/internal/middleware"
|
|
||||||
"github.com/breakpilot/consent-service/internal/models"
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
"github.com/google/uuid"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// ADMIN ENDPOINTS - Version Approval Workflow (DSB)
|
|
||||||
// ========================================
|
|
||||||
|
|
||||||
// AdminSubmitForReview submits a version for DSB review
|
|
||||||
func (h *Handler) AdminSubmitForReview(c *gin.Context) {
|
|
||||||
versionID, err := uuid.Parse(c.Param("id"))
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid version ID"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
userID, _ := middleware.GetUserID(c)
|
|
||||||
ctx := context.Background()
|
|
||||||
ipAddress := middleware.GetClientIP(c)
|
|
||||||
userAgent := middleware.GetUserAgent(c)
|
|
||||||
|
|
||||||
// Check current status
|
|
||||||
var status string
|
|
||||||
err = h.db.Pool.QueryRow(ctx, `SELECT status FROM document_versions WHERE id = $1`, versionID).Scan(&status)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "Version not found"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if status != "draft" {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Only draft versions can be submitted for review"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update status to review
|
|
||||||
_, err = h.db.Pool.Exec(ctx, `
|
|
||||||
UPDATE document_versions
|
|
||||||
SET status = 'review', updated_at = NOW()
|
|
||||||
WHERE id = $1
|
|
||||||
`, versionID)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to submit for review"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Log approval action
|
|
||||||
_, err = h.db.Pool.Exec(ctx, `
|
|
||||||
INSERT INTO version_approvals (version_id, approver_id, action, comment)
|
|
||||||
VALUES ($1, $2, 'submitted', 'Submitted for DSB review')
|
|
||||||
`, versionID, userID)
|
|
||||||
|
|
||||||
h.logAudit(ctx, &userID, "version_submitted_review", "document_version", &versionID, nil, ipAddress, userAgent)
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "Version submitted for review"})
|
|
||||||
}
|
|
||||||
|
|
||||||
// AdminApproveVersion approves a version with scheduled publish date (DSB only)
|
|
||||||
func (h *Handler) AdminApproveVersion(c *gin.Context) {
|
|
||||||
versionID, err := uuid.Parse(c.Param("id"))
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid version ID"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if user is DSB or Admin (for dev purposes)
|
|
||||||
if !middleware.IsDSB(c) && !middleware.IsAdmin(c) {
|
|
||||||
c.JSON(http.StatusForbidden, gin.H{"error": "Only Data Protection Officers can approve versions"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var req struct {
|
|
||||||
Comment string `json:"comment"`
|
|
||||||
ScheduledPublishAt *string `json:"scheduled_publish_at"` // ISO 8601: "2026-01-01T00:00:00Z"
|
|
||||||
}
|
|
||||||
c.ShouldBindJSON(&req)
|
|
||||||
|
|
||||||
// Validate scheduled publish date
|
|
||||||
var scheduledAt *time.Time
|
|
||||||
if req.ScheduledPublishAt != nil && *req.ScheduledPublishAt != "" {
|
|
||||||
parsed, err := time.Parse(time.RFC3339, *req.ScheduledPublishAt)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid scheduled_publish_at format. Use ISO 8601 (e.g., 2026-01-01T00:00:00Z)"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if parsed.Before(time.Now()) {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Scheduled publish date must be in the future"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
scheduledAt = &parsed
|
|
||||||
}
|
|
||||||
|
|
||||||
userID, _ := middleware.GetUserID(c)
|
|
||||||
ctx := context.Background()
|
|
||||||
ipAddress := middleware.GetClientIP(c)
|
|
||||||
userAgent := middleware.GetUserAgent(c)
|
|
||||||
|
|
||||||
// Check current status
|
|
||||||
var status string
|
|
||||||
var createdBy *uuid.UUID
|
|
||||||
err = h.db.Pool.QueryRow(ctx, `SELECT status, created_by FROM document_versions WHERE id = $1`, versionID).Scan(&status, &createdBy)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "Version not found"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if status != "review" {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Only versions in review status can be approved"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Four-eyes principle: DSB cannot approve their own version
|
|
||||||
// Exception: Admins can approve their own versions for development/testing purposes
|
|
||||||
role, _ := c.Get("role")
|
|
||||||
roleStr, _ := role.(string)
|
|
||||||
if createdBy != nil && *createdBy == userID && roleStr != "admin" {
|
|
||||||
c.JSON(http.StatusForbidden, gin.H{"error": "You cannot approve your own version (four-eyes principle)"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determine new status: 'scheduled' if date set, otherwise 'approved'
|
|
||||||
newStatus := "approved"
|
|
||||||
if scheduledAt != nil {
|
|
||||||
newStatus = "scheduled"
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update status to approved/scheduled
|
|
||||||
_, err = h.db.Pool.Exec(ctx, `
|
|
||||||
UPDATE document_versions
|
|
||||||
SET status = $2, approved_by = $3, approved_at = NOW(), scheduled_publish_at = $4, updated_at = NOW()
|
|
||||||
WHERE id = $1
|
|
||||||
`, versionID, newStatus, userID, scheduledAt)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to approve version"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Log approval action
|
|
||||||
comment := req.Comment
|
|
||||||
if comment == "" {
|
|
||||||
if scheduledAt != nil {
|
|
||||||
comment = "Approved by DSB, scheduled for " + scheduledAt.Format("02.01.2006 15:04")
|
|
||||||
} else {
|
|
||||||
comment = "Approved by DSB"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_, err = h.db.Pool.Exec(ctx, `
|
|
||||||
INSERT INTO version_approvals (version_id, approver_id, action, comment)
|
|
||||||
VALUES ($1, $2, 'approved', $3)
|
|
||||||
`, versionID, userID, comment)
|
|
||||||
|
|
||||||
h.logAudit(ctx, &userID, "version_approved", "document_version", &versionID, &comment, ipAddress, userAgent)
|
|
||||||
|
|
||||||
response := gin.H{"message": "Version approved", "status": newStatus}
|
|
||||||
if scheduledAt != nil {
|
|
||||||
response["scheduled_publish_at"] = scheduledAt.Format(time.RFC3339)
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusOK, response)
|
|
||||||
}
|
|
||||||
|
|
||||||
// AdminRejectVersion rejects a version (DSB only)
|
|
||||||
func (h *Handler) AdminRejectVersion(c *gin.Context) {
|
|
||||||
versionID, err := uuid.Parse(c.Param("id"))
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid version ID"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if user is DSB
|
|
||||||
if !middleware.IsDSB(c) {
|
|
||||||
c.JSON(http.StatusForbidden, gin.H{"error": "Only Data Protection Officers can reject versions"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var req struct {
|
|
||||||
Comment string `json:"comment" binding:"required"`
|
|
||||||
}
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Comment is required when rejecting"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
userID, _ := middleware.GetUserID(c)
|
|
||||||
ctx := context.Background()
|
|
||||||
ipAddress := middleware.GetClientIP(c)
|
|
||||||
userAgent := middleware.GetUserAgent(c)
|
|
||||||
|
|
||||||
// Check current status
|
|
||||||
var status string
|
|
||||||
err = h.db.Pool.QueryRow(ctx, `SELECT status FROM document_versions WHERE id = $1`, versionID).Scan(&status)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "Version not found"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if status != "review" && status != "approved" {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Only versions in review or approved status can be rejected"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update status back to draft
|
|
||||||
_, err = h.db.Pool.Exec(ctx, `
|
|
||||||
UPDATE document_versions
|
|
||||||
SET status = 'draft', approved_by = NULL, updated_at = NOW()
|
|
||||||
WHERE id = $1
|
|
||||||
`, versionID)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to reject version"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Log rejection
|
|
||||||
_, err = h.db.Pool.Exec(ctx, `
|
|
||||||
INSERT INTO version_approvals (version_id, approver_id, action, comment)
|
|
||||||
VALUES ($1, $2, 'rejected', $3)
|
|
||||||
`, versionID, userID, req.Comment)
|
|
||||||
|
|
||||||
h.logAudit(ctx, &userID, "version_rejected", "document_version", &versionID, &req.Comment, ipAddress, userAgent)
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "Version rejected and returned to draft"})
|
|
||||||
}
|
|
||||||
|
|
||||||
// AdminCompareVersions returns two versions for side-by-side comparison
|
|
||||||
func (h *Handler) AdminCompareVersions(c *gin.Context) {
|
|
||||||
versionID, err := uuid.Parse(c.Param("id"))
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid version ID"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
// Get the current version and its document
|
|
||||||
var currentVersion models.DocumentVersion
|
|
||||||
var documentID uuid.UUID
|
|
||||||
err = h.db.Pool.QueryRow(ctx, `
|
|
||||||
SELECT id, document_id, version, language, title, content, summary, status, created_at, updated_at
|
|
||||||
FROM document_versions
|
|
||||||
WHERE id = $1
|
|
||||||
`, versionID).Scan(¤tVersion.ID, &documentID, ¤tVersion.Version, ¤tVersion.Language,
|
|
||||||
¤tVersion.Title, ¤tVersion.Content, ¤tVersion.Summary, ¤tVersion.Status,
|
|
||||||
¤tVersion.CreatedAt, ¤tVersion.UpdatedAt)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "Version not found"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the currently published version (if any)
|
|
||||||
var publishedVersion *models.DocumentVersion
|
|
||||||
var pv models.DocumentVersion
|
|
||||||
err = h.db.Pool.QueryRow(ctx, `
|
|
||||||
SELECT id, document_id, version, language, title, content, summary, status, published_at, created_at, updated_at
|
|
||||||
FROM document_versions
|
|
||||||
WHERE document_id = $1 AND language = $2 AND status = 'published'
|
|
||||||
ORDER BY published_at DESC
|
|
||||||
LIMIT 1
|
|
||||||
`, documentID, currentVersion.Language).Scan(&pv.ID, &pv.DocumentID, &pv.Version, &pv.Language,
|
|
||||||
&pv.Title, &pv.Content, &pv.Summary, &pv.Status, &pv.PublishedAt, &pv.CreatedAt, &pv.UpdatedAt)
|
|
||||||
|
|
||||||
if err == nil && pv.ID != currentVersion.ID {
|
|
||||||
publishedVersion = &pv
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get approval history
|
|
||||||
rows, err := h.db.Pool.Query(ctx, `
|
|
||||||
SELECT va.action, va.comment, va.created_at, u.email
|
|
||||||
FROM version_approvals va
|
|
||||||
LEFT JOIN users u ON va.approver_id = u.id
|
|
||||||
WHERE va.version_id = $1
|
|
||||||
ORDER BY va.created_at DESC
|
|
||||||
`, versionID)
|
|
||||||
|
|
||||||
var approvalHistory []map[string]interface{}
|
|
||||||
if err == nil {
|
|
||||||
defer rows.Close()
|
|
||||||
for rows.Next() {
|
|
||||||
var action, email string
|
|
||||||
var comment *string
|
|
||||||
var createdAt time.Time
|
|
||||||
if err := rows.Scan(&action, &comment, &createdAt, &email); err == nil {
|
|
||||||
approvalHistory = append(approvalHistory, map[string]interface{}{
|
|
||||||
"action": action,
|
|
||||||
"comment": comment,
|
|
||||||
"created_at": createdAt,
|
|
||||||
"approver": email,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
|
||||||
"current_version": currentVersion,
|
|
||||||
"published_version": publishedVersion,
|
|
||||||
"approval_history": approvalHistory,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// AdminGetApprovalHistory returns the approval history for a version
|
|
||||||
func (h *Handler) AdminGetApprovalHistory(c *gin.Context) {
|
|
||||||
versionID, err := uuid.Parse(c.Param("id"))
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid version ID"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
rows, err := h.db.Pool.Query(ctx, `
|
|
||||||
SELECT va.id, va.action, va.comment, va.created_at, u.email, u.name
|
|
||||||
FROM version_approvals va
|
|
||||||
LEFT JOIN users u ON va.approver_id = u.id
|
|
||||||
WHERE va.version_id = $1
|
|
||||||
ORDER BY va.created_at DESC
|
|
||||||
`, versionID)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch approval history"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var history []map[string]interface{}
|
|
||||||
for rows.Next() {
|
|
||||||
var id uuid.UUID
|
|
||||||
var action string
|
|
||||||
var comment *string
|
|
||||||
var createdAt time.Time
|
|
||||||
var email, name *string
|
|
||||||
|
|
||||||
if err := rows.Scan(&id, &action, &comment, &createdAt, &email, &name); err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
history = append(history, map[string]interface{}{
|
|
||||||
"id": id,
|
|
||||||
"action": action,
|
|
||||||
"comment": comment,
|
|
||||||
"created_at": createdAt,
|
|
||||||
"approver": email,
|
|
||||||
"name": name,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"approval_history": history})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// SCHEDULED PUBLISHING
|
|
||||||
// ========================================
|
|
||||||
|
|
||||||
// ProcessScheduledPublishing publishes all versions that are due
|
|
||||||
// This should be called by a cron job or scheduler
|
|
||||||
func (h *Handler) ProcessScheduledPublishing(c *gin.Context) {
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
// Find all scheduled versions that are due
|
|
||||||
rows, err := h.db.Pool.Query(ctx, `
|
|
||||||
SELECT id, document_id, version
|
|
||||||
FROM document_versions
|
|
||||||
WHERE status = 'scheduled'
|
|
||||||
AND scheduled_publish_at IS NOT NULL
|
|
||||||
AND scheduled_publish_at <= NOW()
|
|
||||||
`)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch scheduled versions"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var published []string
|
|
||||||
for rows.Next() {
|
|
||||||
var versionID, docID uuid.UUID
|
|
||||||
var version string
|
|
||||||
if err := rows.Scan(&versionID, &docID, &version); err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Publish this version
|
|
||||||
_, err := h.db.Pool.Exec(ctx, `
|
|
||||||
UPDATE document_versions
|
|
||||||
SET status = 'published', published_at = NOW(), updated_at = NOW()
|
|
||||||
WHERE id = $1
|
|
||||||
`, versionID)
|
|
||||||
|
|
||||||
if err == nil {
|
|
||||||
// Archive previous published versions for this document
|
|
||||||
h.db.Pool.Exec(ctx, `
|
|
||||||
UPDATE document_versions
|
|
||||||
SET status = 'archived', updated_at = NOW()
|
|
||||||
WHERE document_id = $1 AND id != $2 AND status = 'published'
|
|
||||||
`, docID, versionID)
|
|
||||||
|
|
||||||
// Log the publishing
|
|
||||||
details := fmt.Sprintf("Version %s automatically published by scheduler", version)
|
|
||||||
h.logAudit(ctx, nil, "version_scheduled_published", "document_version", &versionID, &details, "", "scheduler")
|
|
||||||
|
|
||||||
published = append(published, version)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
|
||||||
"message": "Scheduled publishing processed",
|
|
||||||
"published_count": len(published),
|
|
||||||
"published_versions": published,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetScheduledVersions returns all versions scheduled for publishing
|
|
||||||
func (h *Handler) GetScheduledVersions(c *gin.Context) {
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
rows, err := h.db.Pool.Query(ctx, `
|
|
||||||
SELECT dv.id, dv.document_id, dv.version, dv.title, dv.scheduled_publish_at, ld.name as document_name
|
|
||||||
FROM document_versions dv
|
|
||||||
JOIN legal_documents ld ON ld.id = dv.document_id
|
|
||||||
WHERE dv.status = 'scheduled'
|
|
||||||
AND dv.scheduled_publish_at IS NOT NULL
|
|
||||||
ORDER BY dv.scheduled_publish_at ASC
|
|
||||||
`)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch scheduled versions"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
type ScheduledVersion struct {
|
|
||||||
ID uuid.UUID `json:"id"`
|
|
||||||
DocumentID uuid.UUID `json:"document_id"`
|
|
||||||
Version string `json:"version"`
|
|
||||||
Title string `json:"title"`
|
|
||||||
ScheduledPublishAt *time.Time `json:"scheduled_publish_at"`
|
|
||||||
DocumentName string `json:"document_name"`
|
|
||||||
}
|
|
||||||
|
|
||||||
var versions []ScheduledVersion
|
|
||||||
for rows.Next() {
|
|
||||||
var v ScheduledVersion
|
|
||||||
if err := rows.Scan(&v.ID, &v.DocumentID, &v.Version, &v.Title, &v.ScheduledPublishAt, &v.DocumentName); err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
versions = append(versions, v)
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"scheduled_versions": versions})
|
|
||||||
}
|
|
||||||
@@ -1,391 +0,0 @@
|
|||||||
package handlers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/breakpilot/consent-service/internal/middleware"
|
|
||||||
"github.com/breakpilot/consent-service/internal/models"
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
"github.com/google/uuid"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// ADMIN ENDPOINTS - Document Management
|
|
||||||
// ========================================
|
|
||||||
|
|
||||||
// AdminGetDocuments returns all documents (including inactive) for admin
|
|
||||||
func (h *Handler) AdminGetDocuments(c *gin.Context) {
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
rows, err := h.db.Pool.Query(ctx, `
|
|
||||||
SELECT id, type, name, description, is_mandatory, is_active, sort_order, created_at, updated_at
|
|
||||||
FROM legal_documents
|
|
||||||
ORDER BY sort_order ASC, created_at DESC
|
|
||||||
`)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch documents"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var documents []models.LegalDocument
|
|
||||||
for rows.Next() {
|
|
||||||
var doc models.LegalDocument
|
|
||||||
if err := rows.Scan(&doc.ID, &doc.Type, &doc.Name, &doc.Description,
|
|
||||||
&doc.IsMandatory, &doc.IsActive, &doc.SortOrder, &doc.CreatedAt, &doc.UpdatedAt); err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
documents = append(documents, doc)
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"documents": documents})
|
|
||||||
}
|
|
||||||
|
|
||||||
// AdminCreateDocument creates a new legal document
|
|
||||||
func (h *Handler) AdminCreateDocument(c *gin.Context) {
|
|
||||||
var req models.CreateDocumentRequest
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
var docID uuid.UUID
|
|
||||||
err := h.db.Pool.QueryRow(ctx, `
|
|
||||||
INSERT INTO legal_documents (type, name, description, is_mandatory)
|
|
||||||
VALUES ($1, $2, $3, $4)
|
|
||||||
RETURNING id
|
|
||||||
`, req.Type, req.Name, req.Description, req.IsMandatory).Scan(&docID)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create document"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusCreated, gin.H{
|
|
||||||
"message": "Document created successfully",
|
|
||||||
"id": docID,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// AdminUpdateDocument updates a legal document
|
|
||||||
func (h *Handler) AdminUpdateDocument(c *gin.Context) {
|
|
||||||
docID, err := uuid.Parse(c.Param("id"))
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid document ID"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var req struct {
|
|
||||||
Name *string `json:"name"`
|
|
||||||
Description *string `json:"description"`
|
|
||||||
IsMandatory *bool `json:"is_mandatory"`
|
|
||||||
IsActive *bool `json:"is_active"`
|
|
||||||
SortOrder *int `json:"sort_order"`
|
|
||||||
}
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
result, err := h.db.Pool.Exec(ctx, `
|
|
||||||
UPDATE legal_documents
|
|
||||||
SET name = COALESCE($2, name),
|
|
||||||
description = COALESCE($3, description),
|
|
||||||
is_mandatory = COALESCE($4, is_mandatory),
|
|
||||||
is_active = COALESCE($5, is_active),
|
|
||||||
sort_order = COALESCE($6, sort_order),
|
|
||||||
updated_at = NOW()
|
|
||||||
WHERE id = $1
|
|
||||||
`, docID, req.Name, req.Description, req.IsMandatory, req.IsActive, req.SortOrder)
|
|
||||||
|
|
||||||
if err != nil || result.RowsAffected() == 0 {
|
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "Document not found"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "Document updated successfully"})
|
|
||||||
}
|
|
||||||
|
|
||||||
// AdminDeleteDocument soft-deletes a document (sets is_active to false)
|
|
||||||
func (h *Handler) AdminDeleteDocument(c *gin.Context) {
|
|
||||||
docID, err := uuid.Parse(c.Param("id"))
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid document ID"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
result, err := h.db.Pool.Exec(ctx, `
|
|
||||||
UPDATE legal_documents
|
|
||||||
SET is_active = false, updated_at = NOW()
|
|
||||||
WHERE id = $1
|
|
||||||
`, docID)
|
|
||||||
|
|
||||||
if err != nil || result.RowsAffected() == 0 {
|
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "Document not found"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "Document deleted successfully"})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// ADMIN ENDPOINTS - Version Management
|
|
||||||
// ========================================
|
|
||||||
|
|
||||||
// AdminGetVersions returns all versions for a document
|
|
||||||
func (h *Handler) AdminGetVersions(c *gin.Context) {
|
|
||||||
docID, err := uuid.Parse(c.Param("docId"))
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid document ID"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
rows, err := h.db.Pool.Query(ctx, `
|
|
||||||
SELECT id, document_id, version, language, title, content, summary, status,
|
|
||||||
published_at, scheduled_publish_at, created_by, approved_by, approved_at, created_at, updated_at
|
|
||||||
FROM document_versions
|
|
||||||
WHERE document_id = $1
|
|
||||||
ORDER BY created_at DESC
|
|
||||||
`, docID)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch versions"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var versions []models.DocumentVersion
|
|
||||||
for rows.Next() {
|
|
||||||
var v models.DocumentVersion
|
|
||||||
if err := rows.Scan(&v.ID, &v.DocumentID, &v.Version, &v.Language, &v.Title, &v.Content,
|
|
||||||
&v.Summary, &v.Status, &v.PublishedAt, &v.ScheduledPublishAt, &v.CreatedBy, &v.ApprovedBy, &v.ApprovedAt, &v.CreatedAt, &v.UpdatedAt); err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
versions = append(versions, v)
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"versions": versions})
|
|
||||||
}
|
|
||||||
|
|
||||||
// AdminCreateVersion creates a new document version
|
|
||||||
func (h *Handler) AdminCreateVersion(c *gin.Context) {
|
|
||||||
var req models.CreateVersionRequest
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
docID, err := uuid.Parse(req.DocumentID)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid document ID"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
userID, _ := middleware.GetUserID(c)
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
var versionID uuid.UUID
|
|
||||||
err = h.db.Pool.QueryRow(ctx, `
|
|
||||||
INSERT INTO document_versions (document_id, version, language, title, content, summary, status, created_by)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, 'draft', $7)
|
|
||||||
RETURNING id
|
|
||||||
`, docID, req.Version, req.Language, req.Title, req.Content, req.Summary, userID).Scan(&versionID)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
// Check for unique constraint violation
|
|
||||||
errStr := err.Error()
|
|
||||||
if strings.Contains(errStr, "duplicate key") || strings.Contains(errStr, "unique constraint") {
|
|
||||||
c.JSON(http.StatusConflict, gin.H{"error": "Eine Version mit dieser Versionsnummer und Sprache existiert bereits für dieses Dokument"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Log the actual error for debugging
|
|
||||||
fmt.Printf("POST /api/v1/admin/versions ✗ %v\n", err)
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create version: " + errStr})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusCreated, gin.H{
|
|
||||||
"message": "Version created successfully",
|
|
||||||
"id": versionID,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// AdminUpdateVersion updates a document version
|
|
||||||
func (h *Handler) AdminUpdateVersion(c *gin.Context) {
|
|
||||||
versionID, err := uuid.Parse(c.Param("id"))
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid version ID"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var req models.UpdateVersionRequest
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
// Check if version is in draft or review status (only these can be edited)
|
|
||||||
var status string
|
|
||||||
err = h.db.Pool.QueryRow(ctx, `SELECT status FROM document_versions WHERE id = $1`, versionID).Scan(&status)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "Version not found"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if status != "draft" && status != "review" {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Only draft or review versions can be edited"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
result, err := h.db.Pool.Exec(ctx, `
|
|
||||||
UPDATE document_versions
|
|
||||||
SET title = COALESCE($2, title),
|
|
||||||
content = COALESCE($3, content),
|
|
||||||
summary = COALESCE($4, summary),
|
|
||||||
status = COALESCE($5, status),
|
|
||||||
updated_at = NOW()
|
|
||||||
WHERE id = $1
|
|
||||||
`, versionID, req.Title, req.Content, req.Summary, req.Status)
|
|
||||||
|
|
||||||
if err != nil || result.RowsAffected() == 0 {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update version"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "Version updated successfully"})
|
|
||||||
}
|
|
||||||
|
|
||||||
// AdminPublishVersion publishes a document version
|
|
||||||
func (h *Handler) AdminPublishVersion(c *gin.Context) {
|
|
||||||
versionID, err := uuid.Parse(c.Param("id"))
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid version ID"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
userID, _ := middleware.GetUserID(c)
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
// Check current status
|
|
||||||
var status string
|
|
||||||
err = h.db.Pool.QueryRow(ctx, `SELECT status FROM document_versions WHERE id = $1`, versionID).Scan(&status)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "Version not found"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if status != "approved" && status != "review" {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Only approved or review versions can be published"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
result, err := h.db.Pool.Exec(ctx, `
|
|
||||||
UPDATE document_versions
|
|
||||||
SET status = 'published',
|
|
||||||
published_at = NOW(),
|
|
||||||
approved_by = $2,
|
|
||||||
updated_at = NOW()
|
|
||||||
WHERE id = $1
|
|
||||||
`, versionID, userID)
|
|
||||||
|
|
||||||
if err != nil || result.RowsAffected() == 0 {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to publish version"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "Version published successfully"})
|
|
||||||
}
|
|
||||||
|
|
||||||
// AdminArchiveVersion archives a document version
|
|
||||||
func (h *Handler) AdminArchiveVersion(c *gin.Context) {
|
|
||||||
versionID, err := uuid.Parse(c.Param("id"))
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid version ID"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
result, err := h.db.Pool.Exec(ctx, `
|
|
||||||
UPDATE document_versions
|
|
||||||
SET status = 'archived', updated_at = NOW()
|
|
||||||
WHERE id = $1
|
|
||||||
`, versionID)
|
|
||||||
|
|
||||||
if err != nil || result.RowsAffected() == 0 {
|
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "Version not found"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "Version archived successfully"})
|
|
||||||
}
|
|
||||||
|
|
||||||
// AdminDeleteVersion permanently deletes a draft/rejected version
|
|
||||||
// Only draft and rejected versions can be deleted. Published versions must be archived.
|
|
||||||
func (h *Handler) AdminDeleteVersion(c *gin.Context) {
|
|
||||||
versionID, err := uuid.Parse(c.Param("id"))
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid version ID"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
// First check the version status - only draft/rejected can be deleted
|
|
||||||
var status string
|
|
||||||
var version string
|
|
||||||
var docID uuid.UUID
|
|
||||||
err = h.db.Pool.QueryRow(ctx, `
|
|
||||||
SELECT status, version, document_id FROM document_versions WHERE id = $1
|
|
||||||
`, versionID).Scan(&status, &version, &docID)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "Version not found"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only allow deletion of draft and rejected versions
|
|
||||||
if status != "draft" && status != "rejected" {
|
|
||||||
c.JSON(http.StatusForbidden, gin.H{
|
|
||||||
"error": "Cannot delete version",
|
|
||||||
"message": "Only draft or rejected versions can be deleted. Published versions must be archived instead.",
|
|
||||||
"status": status,
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete the version
|
|
||||||
result, err := h.db.Pool.Exec(ctx, `
|
|
||||||
DELETE FROM document_versions WHERE id = $1
|
|
||||||
`, versionID)
|
|
||||||
|
|
||||||
if err != nil || result.RowsAffected() == 0 {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete version"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Log the deletion
|
|
||||||
userID, _ := c.Get("user_id")
|
|
||||||
h.db.Pool.Exec(ctx, `
|
|
||||||
INSERT INTO consent_audit_log (action, entity_type, entity_id, user_id, details, ip_address, user_agent)
|
|
||||||
VALUES ('version_deleted', 'document_version', $1, $2, $3, $4, $5)
|
|
||||||
`, versionID, userID, "Version "+version+" permanently deleted", c.ClientIP(), c.Request.UserAgent())
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
|
||||||
"message": "Version deleted successfully",
|
|
||||||
"deleted_version": version,
|
|
||||||
"version_id": versionID,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,319 +0,0 @@
|
|||||||
package handlers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/breakpilot/consent-service/internal/models"
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
"github.com/google/uuid"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// ADMIN ENDPOINTS - Cookie Categories
|
|
||||||
// ========================================
|
|
||||||
|
|
||||||
// AdminGetCookieCategories returns all cookie categories
|
|
||||||
func (h *Handler) AdminGetCookieCategories(c *gin.Context) {
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
rows, err := h.db.Pool.Query(ctx, `
|
|
||||||
SELECT id, name, display_name_de, display_name_en, description_de, description_en,
|
|
||||||
is_mandatory, sort_order, is_active, created_at, updated_at
|
|
||||||
FROM cookie_categories
|
|
||||||
ORDER BY sort_order ASC
|
|
||||||
`)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch categories"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var categories []models.CookieCategory
|
|
||||||
for rows.Next() {
|
|
||||||
var cat models.CookieCategory
|
|
||||||
if err := rows.Scan(&cat.ID, &cat.Name, &cat.DisplayNameDE, &cat.DisplayNameEN,
|
|
||||||
&cat.DescriptionDE, &cat.DescriptionEN, &cat.IsMandatory, &cat.SortOrder,
|
|
||||||
&cat.IsActive, &cat.CreatedAt, &cat.UpdatedAt); err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
categories = append(categories, cat)
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"categories": categories})
|
|
||||||
}
|
|
||||||
|
|
||||||
// AdminCreateCookieCategory creates a new cookie category
|
|
||||||
func (h *Handler) AdminCreateCookieCategory(c *gin.Context) {
|
|
||||||
var req models.CreateCookieCategoryRequest
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
var catID uuid.UUID
|
|
||||||
err := h.db.Pool.QueryRow(ctx, `
|
|
||||||
INSERT INTO cookie_categories (name, display_name_de, display_name_en, description_de, description_en, is_mandatory, sort_order)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
|
||||||
RETURNING id
|
|
||||||
`, req.Name, req.DisplayNameDE, req.DisplayNameEN, req.DescriptionDE, req.DescriptionEN, req.IsMandatory, req.SortOrder).Scan(&catID)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create category"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusCreated, gin.H{
|
|
||||||
"message": "Cookie category created successfully",
|
|
||||||
"id": catID,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// AdminUpdateCookieCategory updates a cookie category
|
|
||||||
func (h *Handler) AdminUpdateCookieCategory(c *gin.Context) {
|
|
||||||
catID, err := uuid.Parse(c.Param("id"))
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid category ID"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var req struct {
|
|
||||||
DisplayNameDE *string `json:"display_name_de"`
|
|
||||||
DisplayNameEN *string `json:"display_name_en"`
|
|
||||||
DescriptionDE *string `json:"description_de"`
|
|
||||||
DescriptionEN *string `json:"description_en"`
|
|
||||||
IsMandatory *bool `json:"is_mandatory"`
|
|
||||||
SortOrder *int `json:"sort_order"`
|
|
||||||
IsActive *bool `json:"is_active"`
|
|
||||||
}
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
result, err := h.db.Pool.Exec(ctx, `
|
|
||||||
UPDATE cookie_categories
|
|
||||||
SET display_name_de = COALESCE($2, display_name_de),
|
|
||||||
display_name_en = COALESCE($3, display_name_en),
|
|
||||||
description_de = COALESCE($4, description_de),
|
|
||||||
description_en = COALESCE($5, description_en),
|
|
||||||
is_mandatory = COALESCE($6, is_mandatory),
|
|
||||||
sort_order = COALESCE($7, sort_order),
|
|
||||||
is_active = COALESCE($8, is_active),
|
|
||||||
updated_at = NOW()
|
|
||||||
WHERE id = $1
|
|
||||||
`, catID, req.DisplayNameDE, req.DisplayNameEN, req.DescriptionDE, req.DescriptionEN,
|
|
||||||
req.IsMandatory, req.SortOrder, req.IsActive)
|
|
||||||
|
|
||||||
if err != nil || result.RowsAffected() == 0 {
|
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "Category not found"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "Cookie category updated successfully"})
|
|
||||||
}
|
|
||||||
|
|
||||||
// AdminDeleteCookieCategory soft-deletes a cookie category
|
|
||||||
func (h *Handler) AdminDeleteCookieCategory(c *gin.Context) {
|
|
||||||
catID, err := uuid.Parse(c.Param("id"))
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid category ID"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
result, err := h.db.Pool.Exec(ctx, `
|
|
||||||
UPDATE cookie_categories
|
|
||||||
SET is_active = false, updated_at = NOW()
|
|
||||||
WHERE id = $1
|
|
||||||
`, catID)
|
|
||||||
|
|
||||||
if err != nil || result.RowsAffected() == 0 {
|
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "Category not found"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "Cookie category deleted successfully"})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// ADMIN ENDPOINTS - Statistics & Audit
|
|
||||||
// ========================================
|
|
||||||
|
|
||||||
// GetConsentStats returns consent statistics
|
|
||||||
func (h *Handler) GetConsentStats(c *gin.Context) {
|
|
||||||
ctx := context.Background()
|
|
||||||
docType := c.Query("document_type")
|
|
||||||
|
|
||||||
var stats models.ConsentStats
|
|
||||||
|
|
||||||
// Total users
|
|
||||||
h.db.Pool.QueryRow(ctx, `SELECT COUNT(*) FROM users`).Scan(&stats.TotalUsers)
|
|
||||||
|
|
||||||
// Consented users (with active consent)
|
|
||||||
query := `
|
|
||||||
SELECT COUNT(DISTINCT uc.user_id)
|
|
||||||
FROM user_consents uc
|
|
||||||
JOIN document_versions dv ON uc.document_version_id = dv.id
|
|
||||||
JOIN legal_documents ld ON dv.document_id = ld.id
|
|
||||||
WHERE uc.consented = true AND uc.withdrawn_at IS NULL
|
|
||||||
`
|
|
||||||
if docType != "" {
|
|
||||||
query += ` AND ld.type = $1`
|
|
||||||
h.db.Pool.QueryRow(ctx, query, docType).Scan(&stats.ConsentedUsers)
|
|
||||||
} else {
|
|
||||||
h.db.Pool.QueryRow(ctx, query).Scan(&stats.ConsentedUsers)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate consent rate
|
|
||||||
if stats.TotalUsers > 0 {
|
|
||||||
stats.ConsentRate = float64(stats.ConsentedUsers) / float64(stats.TotalUsers) * 100
|
|
||||||
}
|
|
||||||
|
|
||||||
// Recent consents (last 7 days)
|
|
||||||
h.db.Pool.QueryRow(ctx, `
|
|
||||||
SELECT COUNT(*) FROM user_consents
|
|
||||||
WHERE consented = true AND consented_at > NOW() - INTERVAL '7 days'
|
|
||||||
`).Scan(&stats.RecentConsents)
|
|
||||||
|
|
||||||
// Recent withdrawals
|
|
||||||
h.db.Pool.QueryRow(ctx, `
|
|
||||||
SELECT COUNT(*) FROM user_consents
|
|
||||||
WHERE withdrawn_at IS NOT NULL AND withdrawn_at > NOW() - INTERVAL '7 days'
|
|
||||||
`).Scan(&stats.RecentWithdrawals)
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, stats)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetCookieStats returns cookie consent statistics
|
|
||||||
func (h *Handler) GetCookieStats(c *gin.Context) {
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
rows, err := h.db.Pool.Query(ctx, `
|
|
||||||
SELECT cat.name,
|
|
||||||
COUNT(DISTINCT u.id) as total_users,
|
|
||||||
COUNT(DISTINCT CASE WHEN cc.consented = true THEN cc.user_id END) as consented_users
|
|
||||||
FROM cookie_categories cat
|
|
||||||
CROSS JOIN users u
|
|
||||||
LEFT JOIN cookie_consents cc ON cat.id = cc.category_id AND u.id = cc.user_id
|
|
||||||
WHERE cat.is_active = true
|
|
||||||
GROUP BY cat.id, cat.name
|
|
||||||
ORDER BY cat.sort_order
|
|
||||||
`)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch stats"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var stats []models.CookieStats
|
|
||||||
for rows.Next() {
|
|
||||||
var s models.CookieStats
|
|
||||||
if err := rows.Scan(&s.Category, &s.TotalUsers, &s.ConsentedUsers); err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if s.TotalUsers > 0 {
|
|
||||||
s.ConsentRate = float64(s.ConsentedUsers) / float64(s.TotalUsers) * 100
|
|
||||||
}
|
|
||||||
stats = append(stats, s)
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"cookie_stats": stats})
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetAuditLog returns audit log entries
|
|
||||||
func (h *Handler) GetAuditLog(c *gin.Context) {
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
// Pagination
|
|
||||||
limit := 50
|
|
||||||
offset := 0
|
|
||||||
if l := c.Query("limit"); l != "" {
|
|
||||||
if parsed, err := parseIntFromQuery(l); err == nil && parsed > 0 {
|
|
||||||
limit = parsed
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if o := c.Query("offset"); o != "" {
|
|
||||||
if parsed, err := parseIntFromQuery(o); err == nil && parsed >= 0 {
|
|
||||||
offset = parsed
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filters
|
|
||||||
userIDFilter := c.Query("user_id")
|
|
||||||
actionFilter := c.Query("action")
|
|
||||||
|
|
||||||
query := `
|
|
||||||
SELECT al.id, al.user_id, al.action, al.entity_type, al.entity_id, al.details,
|
|
||||||
al.ip_address, al.user_agent, al.created_at, u.email
|
|
||||||
FROM consent_audit_log al
|
|
||||||
LEFT JOIN users u ON al.user_id = u.id
|
|
||||||
WHERE 1=1
|
|
||||||
`
|
|
||||||
args := []interface{}{}
|
|
||||||
argCount := 0
|
|
||||||
|
|
||||||
if userIDFilter != "" {
|
|
||||||
argCount++
|
|
||||||
query += fmt.Sprintf(" AND al.user_id = $%d", argCount)
|
|
||||||
args = append(args, userIDFilter)
|
|
||||||
}
|
|
||||||
if actionFilter != "" {
|
|
||||||
argCount++
|
|
||||||
query += fmt.Sprintf(" AND al.action = $%d", argCount)
|
|
||||||
args = append(args, actionFilter)
|
|
||||||
}
|
|
||||||
|
|
||||||
query += fmt.Sprintf(" ORDER BY al.created_at DESC LIMIT $%d OFFSET $%d", argCount+1, argCount+2)
|
|
||||||
args = append(args, limit, offset)
|
|
||||||
|
|
||||||
rows, err := h.db.Pool.Query(ctx, query, args...)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch audit log"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var logs []map[string]interface{}
|
|
||||||
for rows.Next() {
|
|
||||||
var (
|
|
||||||
id uuid.UUID
|
|
||||||
userIDPtr *uuid.UUID
|
|
||||||
action string
|
|
||||||
entityType *string
|
|
||||||
entityID *uuid.UUID
|
|
||||||
details *string
|
|
||||||
ipAddress *string
|
|
||||||
userAgent *string
|
|
||||||
createdAt time.Time
|
|
||||||
email *string
|
|
||||||
)
|
|
||||||
|
|
||||||
if err := rows.Scan(&id, &userIDPtr, &action, &entityType, &entityID, &details,
|
|
||||||
&ipAddress, &userAgent, &createdAt, &email); err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
logs = append(logs, map[string]interface{}{
|
|
||||||
"id": id,
|
|
||||||
"user_id": userIDPtr,
|
|
||||||
"user_email": email,
|
|
||||||
"action": action,
|
|
||||||
"entity_type": entityType,
|
|
||||||
"entity_id": entityID,
|
|
||||||
"details": details,
|
|
||||||
"ip_address": ipAddress,
|
|
||||||
"user_agent": userAgent,
|
|
||||||
"created_at": createdAt,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"audit_log": logs})
|
|
||||||
}
|
|
||||||
@@ -1,265 +0,0 @@
|
|||||||
package handlers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/hex"
|
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
"github.com/google/uuid"
|
|
||||||
)
|
|
||||||
|
|
||||||
// GetSiteConfig gibt die Konfiguration für eine Site zurück
|
|
||||||
// GET /api/v1/banner/config/:siteId
|
|
||||||
func (h *Handler) GetSiteConfig(c *gin.Context) {
|
|
||||||
siteID := c.Param("siteId")
|
|
||||||
|
|
||||||
// Standard-Kategorien (aus Datenbank oder Default)
|
|
||||||
categories := []CategoryConfig{
|
|
||||||
{
|
|
||||||
ID: "essential",
|
|
||||||
Name: map[string]string{
|
|
||||||
"de": "Essentiell",
|
|
||||||
"en": "Essential",
|
|
||||||
},
|
|
||||||
Description: map[string]string{
|
|
||||||
"de": "Notwendig für die Grundfunktionen der Website.",
|
|
||||||
"en": "Required for basic website functionality.",
|
|
||||||
},
|
|
||||||
Required: true,
|
|
||||||
Vendors: []VendorConfig{},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
ID: "functional",
|
|
||||||
Name: map[string]string{
|
|
||||||
"de": "Funktional",
|
|
||||||
"en": "Functional",
|
|
||||||
},
|
|
||||||
Description: map[string]string{
|
|
||||||
"de": "Ermöglicht Personalisierung und Komfortfunktionen.",
|
|
||||||
"en": "Enables personalization and comfort features.",
|
|
||||||
},
|
|
||||||
Required: false,
|
|
||||||
Vendors: []VendorConfig{},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
ID: "analytics",
|
|
||||||
Name: map[string]string{
|
|
||||||
"de": "Statistik",
|
|
||||||
"en": "Analytics",
|
|
||||||
},
|
|
||||||
Description: map[string]string{
|
|
||||||
"de": "Hilft uns, die Website zu verbessern.",
|
|
||||||
"en": "Helps us improve the website.",
|
|
||||||
},
|
|
||||||
Required: false,
|
|
||||||
Vendors: []VendorConfig{},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
ID: "marketing",
|
|
||||||
Name: map[string]string{
|
|
||||||
"de": "Marketing",
|
|
||||||
"en": "Marketing",
|
|
||||||
},
|
|
||||||
Description: map[string]string{
|
|
||||||
"de": "Ermöglicht personalisierte Werbung.",
|
|
||||||
"en": "Enables personalized advertising.",
|
|
||||||
},
|
|
||||||
Required: false,
|
|
||||||
Vendors: []VendorConfig{},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
ID: "social",
|
|
||||||
Name: map[string]string{
|
|
||||||
"de": "Soziale Medien",
|
|
||||||
"en": "Social Media",
|
|
||||||
},
|
|
||||||
Description: map[string]string{
|
|
||||||
"de": "Ermöglicht Inhalte von sozialen Netzwerken.",
|
|
||||||
"en": "Enables content from social networks.",
|
|
||||||
},
|
|
||||||
Required: false,
|
|
||||||
Vendors: []VendorConfig{},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
config := SiteConfig{
|
|
||||||
SiteID: siteID,
|
|
||||||
SiteName: "BreakPilot",
|
|
||||||
Categories: categories,
|
|
||||||
UI: UIConfig{
|
|
||||||
Theme: "auto",
|
|
||||||
Position: "bottom",
|
|
||||||
},
|
|
||||||
Legal: LegalConfig{
|
|
||||||
PrivacyPolicyURL: "/datenschutz",
|
|
||||||
ImprintURL: "/impressum",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, config)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ExportBannerConsent exportiert alle Consent-Daten eines Nutzers (DSGVO Art. 20)
|
|
||||||
// GET /api/v1/banner/consent/export?userId=xxx
|
|
||||||
func (h *Handler) ExportBannerConsent(c *gin.Context) {
|
|
||||||
userID := c.Query("userId")
|
|
||||||
|
|
||||||
if userID == "" {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{
|
|
||||||
"error": "missing_user_id",
|
|
||||||
"message": "userId parameter is required",
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
rows, err := h.db.Pool.Query(ctx, `
|
|
||||||
SELECT id, site_id, device_fingerprint, categories, vendors,
|
|
||||||
version, created_at, updated_at, revoked_at
|
|
||||||
FROM banner_consents
|
|
||||||
WHERE user_id = $1
|
|
||||||
ORDER BY created_at DESC
|
|
||||||
`, userID)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{
|
|
||||||
"error": "export_failed",
|
|
||||||
"message": "Failed to export consent data",
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var consents []map[string]interface{}
|
|
||||||
for rows.Next() {
|
|
||||||
var id, siteID, deviceFingerprint, version string
|
|
||||||
var categoriesJSON, vendorsJSON []byte
|
|
||||||
var createdAt, updatedAt time.Time
|
|
||||||
var revokedAt *time.Time
|
|
||||||
|
|
||||||
rows.Scan(&id, &siteID, &deviceFingerprint, &categoriesJSON, &vendorsJSON,
|
|
||||||
&version, &createdAt, &updatedAt, &revokedAt)
|
|
||||||
|
|
||||||
var categories, vendors map[string]bool
|
|
||||||
json.Unmarshal(categoriesJSON, &categories)
|
|
||||||
json.Unmarshal(vendorsJSON, &vendors)
|
|
||||||
|
|
||||||
consent := map[string]interface{}{
|
|
||||||
"consentId": id,
|
|
||||||
"siteId": siteID,
|
|
||||||
"consent": map[string]interface{}{
|
|
||||||
"categories": categories,
|
|
||||||
"vendors": vendors,
|
|
||||||
},
|
|
||||||
"createdAt": createdAt.UTC().Format(time.RFC3339),
|
|
||||||
"revokedAt": nil,
|
|
||||||
}
|
|
||||||
|
|
||||||
if revokedAt != nil {
|
|
||||||
consent["revokedAt"] = revokedAt.UTC().Format(time.RFC3339)
|
|
||||||
}
|
|
||||||
|
|
||||||
consents = append(consents, consent)
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
|
||||||
"userId": userID,
|
|
||||||
"exportedAt": time.Now().UTC().Format(time.RFC3339),
|
|
||||||
"consents": consents,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetBannerStats gibt anonymisierte Statistiken zurück (Admin)
|
|
||||||
// GET /api/v1/banner/admin/stats/:siteId
|
|
||||||
func (h *Handler) GetBannerStats(c *gin.Context) {
|
|
||||||
siteID := c.Param("siteId")
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
// Gesamtanzahl Consents
|
|
||||||
var totalConsents int
|
|
||||||
h.db.Pool.QueryRow(ctx, `
|
|
||||||
SELECT COUNT(*) FROM banner_consents
|
|
||||||
WHERE site_id = $1 AND revoked_at IS NULL
|
|
||||||
`, siteID).Scan(&totalConsents)
|
|
||||||
|
|
||||||
// Consent-Rate pro Kategorie
|
|
||||||
categoryStats := make(map[string]map[string]interface{})
|
|
||||||
|
|
||||||
rows, _ := h.db.Pool.Query(ctx, `
|
|
||||||
SELECT
|
|
||||||
key as category,
|
|
||||||
COUNT(*) FILTER (WHERE value::text = 'true') as accepted,
|
|
||||||
COUNT(*) as total
|
|
||||||
FROM banner_consents,
|
|
||||||
jsonb_each(categories::jsonb)
|
|
||||||
WHERE site_id = $1 AND revoked_at IS NULL
|
|
||||||
GROUP BY key
|
|
||||||
`, siteID)
|
|
||||||
|
|
||||||
if rows != nil {
|
|
||||||
defer rows.Close()
|
|
||||||
for rows.Next() {
|
|
||||||
var category string
|
|
||||||
var accepted, total int
|
|
||||||
rows.Scan(&category, &accepted, &total)
|
|
||||||
|
|
||||||
rate := float64(0)
|
|
||||||
if total > 0 {
|
|
||||||
rate = float64(accepted) / float64(total)
|
|
||||||
}
|
|
||||||
|
|
||||||
categoryStats[category] = map[string]interface{}{
|
|
||||||
"accepted": accepted,
|
|
||||||
"rate": rate,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
|
||||||
"siteId": siteID,
|
|
||||||
"period": gin.H{
|
|
||||||
"from": time.Now().AddDate(0, -1, 0).Format("2006-01-02"),
|
|
||||||
"to": time.Now().Format("2006-01-02"),
|
|
||||||
},
|
|
||||||
"totalConsents": totalConsents,
|
|
||||||
"consentByCategory": categoryStats,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// Helper Functions
|
|
||||||
// ========================================
|
|
||||||
|
|
||||||
// anonymizeIP anonymisiert eine IP-Adresse (DSGVO-konform)
|
|
||||||
func anonymizeIP(ip string) string {
|
|
||||||
// IPv4: Letztes Oktett auf 0
|
|
||||||
parts := strings.Split(ip, ".")
|
|
||||||
if len(parts) == 4 {
|
|
||||||
parts[3] = "0"
|
|
||||||
anonymized := strings.Join(parts, ".")
|
|
||||||
hash := sha256.Sum256([]byte(anonymized))
|
|
||||||
return hex.EncodeToString(hash[:])[:16]
|
|
||||||
}
|
|
||||||
|
|
||||||
// IPv6: Hash
|
|
||||||
hash := sha256.Sum256([]byte(ip))
|
|
||||||
return hex.EncodeToString(hash[:])[:16]
|
|
||||||
}
|
|
||||||
|
|
||||||
// logBannerConsentAudit schreibt einen Audit-Log-Eintrag
|
|
||||||
func (h *Handler) logBannerConsentAudit(ctx context.Context, consentID, action string, req interface{}, ipHash string) {
|
|
||||||
details, _ := json.Marshal(req)
|
|
||||||
|
|
||||||
h.db.Pool.Exec(ctx, `
|
|
||||||
INSERT INTO banner_consent_audit_log (
|
|
||||||
id, consent_id, action, details, ip_hash, created_at
|
|
||||||
) VALUES ($1, $2, $3, $4, $5, NOW())
|
|
||||||
`, uuid.New().String(), consentID, action, string(details), ipHash)
|
|
||||||
}
|
|
||||||
@@ -2,8 +2,11 @@ package handlers
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -305,3 +308,254 @@ func (h *Handler) RevokeBannerConsent(c *gin.Context) {
|
|||||||
"revokedAt": time.Now().UTC().Format(time.RFC3339),
|
"revokedAt": time.Now().UTC().Format(time.RFC3339),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetSiteConfig gibt die Konfiguration für eine Site zurück
|
||||||
|
// GET /api/v1/banner/config/:siteId
|
||||||
|
func (h *Handler) GetSiteConfig(c *gin.Context) {
|
||||||
|
siteID := c.Param("siteId")
|
||||||
|
|
||||||
|
// Standard-Kategorien (aus Datenbank oder Default)
|
||||||
|
categories := []CategoryConfig{
|
||||||
|
{
|
||||||
|
ID: "essential",
|
||||||
|
Name: map[string]string{
|
||||||
|
"de": "Essentiell",
|
||||||
|
"en": "Essential",
|
||||||
|
},
|
||||||
|
Description: map[string]string{
|
||||||
|
"de": "Notwendig für die Grundfunktionen der Website.",
|
||||||
|
"en": "Required for basic website functionality.",
|
||||||
|
},
|
||||||
|
Required: true,
|
||||||
|
Vendors: []VendorConfig{},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "functional",
|
||||||
|
Name: map[string]string{
|
||||||
|
"de": "Funktional",
|
||||||
|
"en": "Functional",
|
||||||
|
},
|
||||||
|
Description: map[string]string{
|
||||||
|
"de": "Ermöglicht Personalisierung und Komfortfunktionen.",
|
||||||
|
"en": "Enables personalization and comfort features.",
|
||||||
|
},
|
||||||
|
Required: false,
|
||||||
|
Vendors: []VendorConfig{},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "analytics",
|
||||||
|
Name: map[string]string{
|
||||||
|
"de": "Statistik",
|
||||||
|
"en": "Analytics",
|
||||||
|
},
|
||||||
|
Description: map[string]string{
|
||||||
|
"de": "Hilft uns, die Website zu verbessern.",
|
||||||
|
"en": "Helps us improve the website.",
|
||||||
|
},
|
||||||
|
Required: false,
|
||||||
|
Vendors: []VendorConfig{},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "marketing",
|
||||||
|
Name: map[string]string{
|
||||||
|
"de": "Marketing",
|
||||||
|
"en": "Marketing",
|
||||||
|
},
|
||||||
|
Description: map[string]string{
|
||||||
|
"de": "Ermöglicht personalisierte Werbung.",
|
||||||
|
"en": "Enables personalized advertising.",
|
||||||
|
},
|
||||||
|
Required: false,
|
||||||
|
Vendors: []VendorConfig{},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "social",
|
||||||
|
Name: map[string]string{
|
||||||
|
"de": "Soziale Medien",
|
||||||
|
"en": "Social Media",
|
||||||
|
},
|
||||||
|
Description: map[string]string{
|
||||||
|
"de": "Ermöglicht Inhalte von sozialen Netzwerken.",
|
||||||
|
"en": "Enables content from social networks.",
|
||||||
|
},
|
||||||
|
Required: false,
|
||||||
|
Vendors: []VendorConfig{},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
config := SiteConfig{
|
||||||
|
SiteID: siteID,
|
||||||
|
SiteName: "BreakPilot",
|
||||||
|
Categories: categories,
|
||||||
|
UI: UIConfig{
|
||||||
|
Theme: "auto",
|
||||||
|
Position: "bottom",
|
||||||
|
},
|
||||||
|
Legal: LegalConfig{
|
||||||
|
PrivacyPolicyURL: "/datenschutz",
|
||||||
|
ImprintURL: "/impressum",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, config)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExportBannerConsent exportiert alle Consent-Daten eines Nutzers (DSGVO Art. 20)
|
||||||
|
// GET /api/v1/banner/consent/export?userId=xxx
|
||||||
|
func (h *Handler) ExportBannerConsent(c *gin.Context) {
|
||||||
|
userID := c.Query("userId")
|
||||||
|
|
||||||
|
if userID == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{
|
||||||
|
"error": "missing_user_id",
|
||||||
|
"message": "userId parameter is required",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
rows, err := h.db.Pool.Query(ctx, `
|
||||||
|
SELECT id, site_id, device_fingerprint, categories, vendors,
|
||||||
|
version, created_at, updated_at, revoked_at
|
||||||
|
FROM banner_consents
|
||||||
|
WHERE user_id = $1
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
`, userID)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{
|
||||||
|
"error": "export_failed",
|
||||||
|
"message": "Failed to export consent data",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var consents []map[string]interface{}
|
||||||
|
for rows.Next() {
|
||||||
|
var id, siteID, deviceFingerprint, version string
|
||||||
|
var categoriesJSON, vendorsJSON []byte
|
||||||
|
var createdAt, updatedAt time.Time
|
||||||
|
var revokedAt *time.Time
|
||||||
|
|
||||||
|
rows.Scan(&id, &siteID, &deviceFingerprint, &categoriesJSON, &vendorsJSON,
|
||||||
|
&version, &createdAt, &updatedAt, &revokedAt)
|
||||||
|
|
||||||
|
var categories, vendors map[string]bool
|
||||||
|
json.Unmarshal(categoriesJSON, &categories)
|
||||||
|
json.Unmarshal(vendorsJSON, &vendors)
|
||||||
|
|
||||||
|
consent := map[string]interface{}{
|
||||||
|
"consentId": id,
|
||||||
|
"siteId": siteID,
|
||||||
|
"consent": map[string]interface{}{
|
||||||
|
"categories": categories,
|
||||||
|
"vendors": vendors,
|
||||||
|
},
|
||||||
|
"createdAt": createdAt.UTC().Format(time.RFC3339),
|
||||||
|
"revokedAt": nil,
|
||||||
|
}
|
||||||
|
|
||||||
|
if revokedAt != nil {
|
||||||
|
consent["revokedAt"] = revokedAt.UTC().Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
|
||||||
|
consents = append(consents, consent)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"userId": userID,
|
||||||
|
"exportedAt": time.Now().UTC().Format(time.RFC3339),
|
||||||
|
"consents": consents,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetBannerStats gibt anonymisierte Statistiken zurück (Admin)
|
||||||
|
// GET /api/v1/banner/admin/stats/:siteId
|
||||||
|
func (h *Handler) GetBannerStats(c *gin.Context) {
|
||||||
|
siteID := c.Param("siteId")
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Gesamtanzahl Consents
|
||||||
|
var totalConsents int
|
||||||
|
h.db.Pool.QueryRow(ctx, `
|
||||||
|
SELECT COUNT(*) FROM banner_consents
|
||||||
|
WHERE site_id = $1 AND revoked_at IS NULL
|
||||||
|
`, siteID).Scan(&totalConsents)
|
||||||
|
|
||||||
|
// Consent-Rate pro Kategorie
|
||||||
|
categoryStats := make(map[string]map[string]interface{})
|
||||||
|
|
||||||
|
rows, _ := h.db.Pool.Query(ctx, `
|
||||||
|
SELECT
|
||||||
|
key as category,
|
||||||
|
COUNT(*) FILTER (WHERE value::text = 'true') as accepted,
|
||||||
|
COUNT(*) as total
|
||||||
|
FROM banner_consents,
|
||||||
|
jsonb_each(categories::jsonb)
|
||||||
|
WHERE site_id = $1 AND revoked_at IS NULL
|
||||||
|
GROUP BY key
|
||||||
|
`, siteID)
|
||||||
|
|
||||||
|
if rows != nil {
|
||||||
|
defer rows.Close()
|
||||||
|
for rows.Next() {
|
||||||
|
var category string
|
||||||
|
var accepted, total int
|
||||||
|
rows.Scan(&category, &accepted, &total)
|
||||||
|
|
||||||
|
rate := float64(0)
|
||||||
|
if total > 0 {
|
||||||
|
rate = float64(accepted) / float64(total)
|
||||||
|
}
|
||||||
|
|
||||||
|
categoryStats[category] = map[string]interface{}{
|
||||||
|
"accepted": accepted,
|
||||||
|
"rate": rate,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"siteId": siteID,
|
||||||
|
"period": gin.H{
|
||||||
|
"from": time.Now().AddDate(0, -1, 0).Format("2006-01-02"),
|
||||||
|
"to": time.Now().Format("2006-01-02"),
|
||||||
|
},
|
||||||
|
"totalConsents": totalConsents,
|
||||||
|
"consentByCategory": categoryStats,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// Helper Functions
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
// anonymizeIP anonymisiert eine IP-Adresse (DSGVO-konform)
|
||||||
|
func anonymizeIP(ip string) string {
|
||||||
|
// IPv4: Letztes Oktett auf 0
|
||||||
|
parts := strings.Split(ip, ".")
|
||||||
|
if len(parts) == 4 {
|
||||||
|
parts[3] = "0"
|
||||||
|
anonymized := strings.Join(parts, ".")
|
||||||
|
hash := sha256.Sum256([]byte(anonymized))
|
||||||
|
return hex.EncodeToString(hash[:])[:16]
|
||||||
|
}
|
||||||
|
|
||||||
|
// IPv6: Hash
|
||||||
|
hash := sha256.Sum256([]byte(ip))
|
||||||
|
return hex.EncodeToString(hash[:])[:16]
|
||||||
|
}
|
||||||
|
|
||||||
|
// logBannerConsentAudit schreibt einen Audit-Log-Eintrag
|
||||||
|
func (h *Handler) logBannerConsentAudit(ctx context.Context, consentID, action string, req interface{}, ipHash string) {
|
||||||
|
details, _ := json.Marshal(req)
|
||||||
|
|
||||||
|
h.db.Pool.Exec(ctx, `
|
||||||
|
INSERT INTO banner_consent_audit_log (
|
||||||
|
id, consent_id, action, details, ip_hash, created_at
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, NOW())
|
||||||
|
`, uuid.New().String(), consentID, action, string(details), ipHash)
|
||||||
|
}
|
||||||
|
|||||||
@@ -273,3 +273,239 @@ func (h *CommunicationHandlers) RegisterMatrixUser(c *gin.Context) {
|
|||||||
"user_id": resp.UserID,
|
"user_id": resp.UserID,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// Jitsi Video Conference Endpoints
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
// CreateMeetingRequest for creating Jitsi meetings
|
||||||
|
type CreateMeetingRequest struct {
|
||||||
|
Type string `json:"type" binding:"required"` // "quick", "training", "parent_teacher", "class"
|
||||||
|
Title string `json:"title,omitempty"`
|
||||||
|
DisplayName string `json:"display_name"`
|
||||||
|
Email string `json:"email,omitempty"`
|
||||||
|
Duration int `json:"duration,omitempty"` // minutes
|
||||||
|
ClassName string `json:"class_name,omitempty"`
|
||||||
|
ParentName string `json:"parent_name,omitempty"`
|
||||||
|
StudentName string `json:"student_name,omitempty"`
|
||||||
|
Subject string `json:"subject,omitempty"`
|
||||||
|
StartTime time.Time `json:"start_time,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateMeeting creates a new Jitsi meeting
|
||||||
|
func (h *CommunicationHandlers) CreateMeeting(c *gin.Context) {
|
||||||
|
if h.jitsiService == nil {
|
||||||
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Jitsi service not configured"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req CreateMeetingRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
var link *jitsi.MeetingLink
|
||||||
|
var err error
|
||||||
|
|
||||||
|
switch req.Type {
|
||||||
|
case "quick":
|
||||||
|
link, err = h.jitsiService.CreateQuickMeeting(ctx, req.DisplayName)
|
||||||
|
case "training":
|
||||||
|
link, err = h.jitsiService.CreateTrainingSession(ctx, req.Title, req.DisplayName, req.Email, req.Duration)
|
||||||
|
case "parent_teacher":
|
||||||
|
link, err = h.jitsiService.CreateParentTeacherMeeting(ctx, req.DisplayName, req.ParentName, req.StudentName, req.StartTime)
|
||||||
|
case "class":
|
||||||
|
link, err = h.jitsiService.CreateClassMeeting(ctx, req.ClassName, req.DisplayName, req.Subject)
|
||||||
|
default:
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid meeting type. Use: quick, training, parent_teacher, class"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusCreated, gin.H{
|
||||||
|
"room_name": link.RoomName,
|
||||||
|
"url": link.URL,
|
||||||
|
"join_url": link.JoinURL,
|
||||||
|
"moderator_url": link.ModeratorURL,
|
||||||
|
"password": link.Password,
|
||||||
|
"expires_at": link.ExpiresAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetEmbedURLRequest for embedding Jitsi
|
||||||
|
type GetEmbedURLRequest struct {
|
||||||
|
RoomName string `json:"room_name" binding:"required"`
|
||||||
|
DisplayName string `json:"display_name"`
|
||||||
|
AudioMuted bool `json:"audio_muted"`
|
||||||
|
VideoMuted bool `json:"video_muted"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetEmbedURL returns an embeddable Jitsi URL
|
||||||
|
func (h *CommunicationHandlers) GetEmbedURL(c *gin.Context) {
|
||||||
|
if h.jitsiService == nil {
|
||||||
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Jitsi service not configured"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req GetEmbedURLRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
config := &jitsi.MeetingConfig{
|
||||||
|
StartWithAudioMuted: req.AudioMuted,
|
||||||
|
StartWithVideoMuted: req.VideoMuted,
|
||||||
|
DisableDeepLinking: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
embedURL := h.jitsiService.BuildEmbedURL(req.RoomName, req.DisplayName, config)
|
||||||
|
iframeCode := h.jitsiService.BuildIFrameCode(req.RoomName, 800, 600)
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"embed_url": embedURL,
|
||||||
|
"iframe_code": iframeCode,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetJitsiInfo returns Jitsi server information
|
||||||
|
func (h *CommunicationHandlers) GetJitsiInfo(c *gin.Context) {
|
||||||
|
if h.jitsiService == nil {
|
||||||
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Jitsi service not configured"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
info := h.jitsiService.GetServerInfo()
|
||||||
|
c.JSON(http.StatusOK, info)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// Admin Statistics Endpoints (for Admin Panel)
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
// CommunicationStats holds communication service statistics
|
||||||
|
type CommunicationStats struct {
|
||||||
|
Matrix MatrixStats `json:"matrix"`
|
||||||
|
Jitsi JitsiStats `json:"jitsi"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MatrixStats holds Matrix-specific statistics
|
||||||
|
type MatrixStats struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
Healthy bool `json:"healthy"`
|
||||||
|
ServerName string `json:"server_name"`
|
||||||
|
// TODO: Add real stats from Matrix Synapse Admin API
|
||||||
|
TotalUsers int `json:"total_users"`
|
||||||
|
TotalRooms int `json:"total_rooms"`
|
||||||
|
ActiveToday int `json:"active_today"`
|
||||||
|
MessagesToday int `json:"messages_today"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// JitsiStats holds Jitsi-specific statistics
|
||||||
|
type JitsiStats struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
Healthy bool `json:"healthy"`
|
||||||
|
BaseURL string `json:"base_url"`
|
||||||
|
AuthEnabled bool `json:"auth_enabled"`
|
||||||
|
// TODO: Add real stats from Jitsi SRTP API or Jicofo
|
||||||
|
ActiveMeetings int `json:"active_meetings"`
|
||||||
|
TotalParticipants int `json:"total_participants"`
|
||||||
|
MeetingsToday int `json:"meetings_today"`
|
||||||
|
AvgDurationMin int `json:"avg_duration_min"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAdminStats returns admin statistics for Matrix and Jitsi
|
||||||
|
func (h *CommunicationHandlers) GetAdminStats(c *gin.Context) {
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
|
||||||
|
stats := CommunicationStats{}
|
||||||
|
|
||||||
|
// Matrix Stats
|
||||||
|
if h.matrixService != nil {
|
||||||
|
matrixErr := h.matrixService.HealthCheck(ctx)
|
||||||
|
stats.Matrix = MatrixStats{
|
||||||
|
Enabled: true,
|
||||||
|
Healthy: matrixErr == nil,
|
||||||
|
ServerName: h.matrixService.GetServerName(),
|
||||||
|
// Placeholder stats - in production these would come from Synapse Admin API
|
||||||
|
TotalUsers: 0,
|
||||||
|
TotalRooms: 0,
|
||||||
|
ActiveToday: 0,
|
||||||
|
MessagesToday: 0,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
stats.Matrix = MatrixStats{Enabled: false}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Jitsi Stats
|
||||||
|
if h.jitsiService != nil {
|
||||||
|
jitsiErr := h.jitsiService.HealthCheck(ctx)
|
||||||
|
serverInfo := h.jitsiService.GetServerInfo()
|
||||||
|
stats.Jitsi = JitsiStats{
|
||||||
|
Enabled: true,
|
||||||
|
Healthy: jitsiErr == nil,
|
||||||
|
BaseURL: serverInfo["base_url"],
|
||||||
|
AuthEnabled: serverInfo["auth_enabled"] == "true",
|
||||||
|
// Placeholder stats - in production these would come from Jicofo/JVB stats
|
||||||
|
ActiveMeetings: 0,
|
||||||
|
TotalParticipants: 0,
|
||||||
|
MeetingsToday: 0,
|
||||||
|
AvgDurationMin: 0,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
stats.Jitsi = JitsiStats{Enabled: false}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, stats)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// Helper Functions
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
func errToString(err error) string {
|
||||||
|
if err == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return err.Error()
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterRoutes registers all communication routes
|
||||||
|
func (h *CommunicationHandlers) RegisterRoutes(router *gin.RouterGroup, jwtSecret string, authMiddleware gin.HandlerFunc) {
|
||||||
|
comm := router.Group("/communication")
|
||||||
|
{
|
||||||
|
// Public health check
|
||||||
|
comm.GET("/status", h.GetCommunicationStatus)
|
||||||
|
|
||||||
|
// Protected routes
|
||||||
|
protected := comm.Group("")
|
||||||
|
protected.Use(authMiddleware)
|
||||||
|
{
|
||||||
|
// Matrix
|
||||||
|
protected.POST("/rooms", h.CreateRoom)
|
||||||
|
protected.POST("/rooms/invite", h.InviteUser)
|
||||||
|
protected.POST("/messages", h.SendMessage)
|
||||||
|
protected.POST("/notifications", h.SendNotification)
|
||||||
|
|
||||||
|
// Jitsi
|
||||||
|
protected.POST("/meetings", h.CreateMeeting)
|
||||||
|
protected.POST("/meetings/embed", h.GetEmbedURL)
|
||||||
|
protected.GET("/jitsi/info", h.GetJitsiInfo)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admin routes (for Matrix user registration and stats)
|
||||||
|
admin := comm.Group("/admin")
|
||||||
|
admin.Use(authMiddleware)
|
||||||
|
// TODO: Add AdminOnly middleware
|
||||||
|
{
|
||||||
|
admin.POST("/matrix/users", h.RegisterMatrixUser)
|
||||||
|
admin.GET("/stats", h.GetAdminStats)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,245 +0,0 @@
|
|||||||
package handlers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/breakpilot/consent-service/internal/services/jitsi"
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// Jitsi Video Conference Endpoints
|
|
||||||
// ========================================
|
|
||||||
|
|
||||||
// CreateMeetingRequest for creating Jitsi meetings
|
|
||||||
type CreateMeetingRequest struct {
|
|
||||||
Type string `json:"type" binding:"required"` // "quick", "training", "parent_teacher", "class"
|
|
||||||
Title string `json:"title,omitempty"`
|
|
||||||
DisplayName string `json:"display_name"`
|
|
||||||
Email string `json:"email,omitempty"`
|
|
||||||
Duration int `json:"duration,omitempty"` // minutes
|
|
||||||
ClassName string `json:"class_name,omitempty"`
|
|
||||||
ParentName string `json:"parent_name,omitempty"`
|
|
||||||
StudentName string `json:"student_name,omitempty"`
|
|
||||||
Subject string `json:"subject,omitempty"`
|
|
||||||
StartTime time.Time `json:"start_time,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// CreateMeeting creates a new Jitsi meeting
|
|
||||||
func (h *CommunicationHandlers) CreateMeeting(c *gin.Context) {
|
|
||||||
if h.jitsiService == nil {
|
|
||||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Jitsi service not configured"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var req CreateMeetingRequest
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := c.Request.Context()
|
|
||||||
var link *jitsi.MeetingLink
|
|
||||||
var err error
|
|
||||||
|
|
||||||
switch req.Type {
|
|
||||||
case "quick":
|
|
||||||
link, err = h.jitsiService.CreateQuickMeeting(ctx, req.DisplayName)
|
|
||||||
case "training":
|
|
||||||
link, err = h.jitsiService.CreateTrainingSession(ctx, req.Title, req.DisplayName, req.Email, req.Duration)
|
|
||||||
case "parent_teacher":
|
|
||||||
link, err = h.jitsiService.CreateParentTeacherMeeting(ctx, req.DisplayName, req.ParentName, req.StudentName, req.StartTime)
|
|
||||||
case "class":
|
|
||||||
link, err = h.jitsiService.CreateClassMeeting(ctx, req.ClassName, req.DisplayName, req.Subject)
|
|
||||||
default:
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid meeting type. Use: quick, training, parent_teacher, class"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusCreated, gin.H{
|
|
||||||
"room_name": link.RoomName,
|
|
||||||
"url": link.URL,
|
|
||||||
"join_url": link.JoinURL,
|
|
||||||
"moderator_url": link.ModeratorURL,
|
|
||||||
"password": link.Password,
|
|
||||||
"expires_at": link.ExpiresAt,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetEmbedURLRequest for embedding Jitsi
|
|
||||||
type GetEmbedURLRequest struct {
|
|
||||||
RoomName string `json:"room_name" binding:"required"`
|
|
||||||
DisplayName string `json:"display_name"`
|
|
||||||
AudioMuted bool `json:"audio_muted"`
|
|
||||||
VideoMuted bool `json:"video_muted"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetEmbedURL returns an embeddable Jitsi URL
|
|
||||||
func (h *CommunicationHandlers) GetEmbedURL(c *gin.Context) {
|
|
||||||
if h.jitsiService == nil {
|
|
||||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Jitsi service not configured"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var req GetEmbedURLRequest
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
config := &jitsi.MeetingConfig{
|
|
||||||
StartWithAudioMuted: req.AudioMuted,
|
|
||||||
StartWithVideoMuted: req.VideoMuted,
|
|
||||||
DisableDeepLinking: true,
|
|
||||||
}
|
|
||||||
|
|
||||||
embedURL := h.jitsiService.BuildEmbedURL(req.RoomName, req.DisplayName, config)
|
|
||||||
iframeCode := h.jitsiService.BuildIFrameCode(req.RoomName, 800, 600)
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
|
||||||
"embed_url": embedURL,
|
|
||||||
"iframe_code": iframeCode,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetJitsiInfo returns Jitsi server information
|
|
||||||
func (h *CommunicationHandlers) GetJitsiInfo(c *gin.Context) {
|
|
||||||
if h.jitsiService == nil {
|
|
||||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Jitsi service not configured"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
info := h.jitsiService.GetServerInfo()
|
|
||||||
c.JSON(http.StatusOK, info)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// Admin Statistics Endpoints (for Admin Panel)
|
|
||||||
// ========================================
|
|
||||||
|
|
||||||
// CommunicationStats holds communication service statistics
|
|
||||||
type CommunicationStats struct {
|
|
||||||
Matrix MatrixStats `json:"matrix"`
|
|
||||||
Jitsi JitsiStats `json:"jitsi"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// MatrixStats holds Matrix-specific statistics
|
|
||||||
type MatrixStats struct {
|
|
||||||
Enabled bool `json:"enabled"`
|
|
||||||
Healthy bool `json:"healthy"`
|
|
||||||
ServerName string `json:"server_name"`
|
|
||||||
// TODO: Add real stats from Matrix Synapse Admin API
|
|
||||||
TotalUsers int `json:"total_users"`
|
|
||||||
TotalRooms int `json:"total_rooms"`
|
|
||||||
ActiveToday int `json:"active_today"`
|
|
||||||
MessagesToday int `json:"messages_today"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// JitsiStats holds Jitsi-specific statistics
|
|
||||||
type JitsiStats struct {
|
|
||||||
Enabled bool `json:"enabled"`
|
|
||||||
Healthy bool `json:"healthy"`
|
|
||||||
BaseURL string `json:"base_url"`
|
|
||||||
AuthEnabled bool `json:"auth_enabled"`
|
|
||||||
// TODO: Add real stats from Jitsi SRTP API or Jicofo
|
|
||||||
ActiveMeetings int `json:"active_meetings"`
|
|
||||||
TotalParticipants int `json:"total_participants"`
|
|
||||||
MeetingsToday int `json:"meetings_today"`
|
|
||||||
AvgDurationMin int `json:"avg_duration_min"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetAdminStats returns admin statistics for Matrix and Jitsi
|
|
||||||
func (h *CommunicationHandlers) GetAdminStats(c *gin.Context) {
|
|
||||||
ctx := c.Request.Context()
|
|
||||||
|
|
||||||
stats := CommunicationStats{}
|
|
||||||
|
|
||||||
// Matrix Stats
|
|
||||||
if h.matrixService != nil {
|
|
||||||
matrixErr := h.matrixService.HealthCheck(ctx)
|
|
||||||
stats.Matrix = MatrixStats{
|
|
||||||
Enabled: true,
|
|
||||||
Healthy: matrixErr == nil,
|
|
||||||
ServerName: h.matrixService.GetServerName(),
|
|
||||||
// Placeholder stats - in production these would come from Synapse Admin API
|
|
||||||
TotalUsers: 0,
|
|
||||||
TotalRooms: 0,
|
|
||||||
ActiveToday: 0,
|
|
||||||
MessagesToday: 0,
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
stats.Matrix = MatrixStats{Enabled: false}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Jitsi Stats
|
|
||||||
if h.jitsiService != nil {
|
|
||||||
jitsiErr := h.jitsiService.HealthCheck(ctx)
|
|
||||||
serverInfo := h.jitsiService.GetServerInfo()
|
|
||||||
stats.Jitsi = JitsiStats{
|
|
||||||
Enabled: true,
|
|
||||||
Healthy: jitsiErr == nil,
|
|
||||||
BaseURL: serverInfo["base_url"],
|
|
||||||
AuthEnabled: serverInfo["auth_enabled"] == "true",
|
|
||||||
// Placeholder stats - in production these would come from Jicofo/JVB stats
|
|
||||||
ActiveMeetings: 0,
|
|
||||||
TotalParticipants: 0,
|
|
||||||
MeetingsToday: 0,
|
|
||||||
AvgDurationMin: 0,
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
stats.Jitsi = JitsiStats{Enabled: false}
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, stats)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// Helper Functions
|
|
||||||
// ========================================
|
|
||||||
|
|
||||||
func errToString(err error) string {
|
|
||||||
if err == nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return err.Error()
|
|
||||||
}
|
|
||||||
|
|
||||||
// RegisterRoutes registers all communication routes
|
|
||||||
func (h *CommunicationHandlers) RegisterRoutes(router *gin.RouterGroup, jwtSecret string, authMiddleware gin.HandlerFunc) {
|
|
||||||
comm := router.Group("/communication")
|
|
||||||
{
|
|
||||||
// Public health check
|
|
||||||
comm.GET("/status", h.GetCommunicationStatus)
|
|
||||||
|
|
||||||
// Protected routes
|
|
||||||
protected := comm.Group("")
|
|
||||||
protected.Use(authMiddleware)
|
|
||||||
{
|
|
||||||
// Matrix
|
|
||||||
protected.POST("/rooms", h.CreateRoom)
|
|
||||||
protected.POST("/rooms/invite", h.InviteUser)
|
|
||||||
protected.POST("/messages", h.SendMessage)
|
|
||||||
protected.POST("/notifications", h.SendNotification)
|
|
||||||
|
|
||||||
// Jitsi
|
|
||||||
protected.POST("/meetings", h.CreateMeeting)
|
|
||||||
protected.POST("/meetings/embed", h.GetEmbedURL)
|
|
||||||
protected.GET("/jitsi/info", h.GetJitsiInfo)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Admin routes (for Matrix user registration and stats)
|
|
||||||
admin := comm.Group("/admin")
|
|
||||||
admin.Use(authMiddleware)
|
|
||||||
// TODO: Add AdminOnly middleware
|
|
||||||
{
|
|
||||||
admin.POST("/matrix/users", h.RegisterMatrixUser)
|
|
||||||
admin.GET("/stats", h.GetAdminStats)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,244 +0,0 @@
|
|||||||
package handlers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"net/http"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/breakpilot/consent-service/internal/middleware"
|
|
||||||
"github.com/breakpilot/consent-service/internal/models"
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
"github.com/google/uuid"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// PUBLIC ENDPOINTS - Consent
|
|
||||||
// ========================================
|
|
||||||
|
|
||||||
// CreateConsent creates a new user consent
|
|
||||||
func (h *Handler) CreateConsent(c *gin.Context) {
|
|
||||||
var req models.CreateConsentRequest
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
userID, err := middleware.GetUserID(c)
|
|
||||||
if err != nil || userID == uuid.Nil {
|
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
versionID, err := uuid.Parse(req.VersionID)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid version ID"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
ipAddress := middleware.GetClientIP(c)
|
|
||||||
userAgent := middleware.GetUserAgent(c)
|
|
||||||
|
|
||||||
// Upsert consent
|
|
||||||
var consentID uuid.UUID
|
|
||||||
err = h.db.Pool.QueryRow(ctx, `
|
|
||||||
INSERT INTO user_consents (user_id, document_version_id, consented, ip_address, user_agent)
|
|
||||||
VALUES ($1, $2, $3, $4, $5)
|
|
||||||
ON CONFLICT (user_id, document_version_id)
|
|
||||||
DO UPDATE SET consented = $3, consented_at = NOW(), withdrawn_at = NULL
|
|
||||||
RETURNING id
|
|
||||||
`, userID, versionID, req.Consented, ipAddress, userAgent).Scan(&consentID)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save consent"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Log to audit trail
|
|
||||||
h.logAudit(ctx, &userID, "consent_given", "document_version", &versionID, nil, ipAddress, userAgent)
|
|
||||||
|
|
||||||
c.JSON(http.StatusCreated, gin.H{
|
|
||||||
"message": "Consent saved successfully",
|
|
||||||
"consent_id": consentID,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetMyConsents returns all consents for the current user
|
|
||||||
func (h *Handler) GetMyConsents(c *gin.Context) {
|
|
||||||
userID, err := middleware.GetUserID(c)
|
|
||||||
if err != nil || userID == uuid.Nil {
|
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
rows, err := h.db.Pool.Query(ctx, `
|
|
||||||
SELECT uc.id, uc.consented, uc.consented_at, uc.withdrawn_at,
|
|
||||||
ld.id, ld.type, ld.name, ld.is_mandatory,
|
|
||||||
dv.id, dv.version, dv.language, dv.title
|
|
||||||
FROM user_consents uc
|
|
||||||
JOIN document_versions dv ON uc.document_version_id = dv.id
|
|
||||||
JOIN legal_documents ld ON dv.document_id = ld.id
|
|
||||||
WHERE uc.user_id = $1
|
|
||||||
ORDER BY uc.consented_at DESC
|
|
||||||
`, userID)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch consents"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var consents []map[string]interface{}
|
|
||||||
for rows.Next() {
|
|
||||||
var (
|
|
||||||
consentID uuid.UUID
|
|
||||||
consented bool
|
|
||||||
consentedAt time.Time
|
|
||||||
withdrawnAt *time.Time
|
|
||||||
docID uuid.UUID
|
|
||||||
docType string
|
|
||||||
docName string
|
|
||||||
isMandatory bool
|
|
||||||
versionID uuid.UUID
|
|
||||||
version string
|
|
||||||
language string
|
|
||||||
title string
|
|
||||||
)
|
|
||||||
|
|
||||||
if err := rows.Scan(&consentID, &consented, &consentedAt, &withdrawnAt,
|
|
||||||
&docID, &docType, &docName, &isMandatory,
|
|
||||||
&versionID, &version, &language, &title); err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
consents = append(consents, map[string]interface{}{
|
|
||||||
"consent_id": consentID,
|
|
||||||
"consented": consented,
|
|
||||||
"consented_at": consentedAt,
|
|
||||||
"withdrawn_at": withdrawnAt,
|
|
||||||
"document": map[string]interface{}{
|
|
||||||
"id": docID,
|
|
||||||
"type": docType,
|
|
||||||
"name": docName,
|
|
||||||
"is_mandatory": isMandatory,
|
|
||||||
},
|
|
||||||
"version": map[string]interface{}{
|
|
||||||
"id": versionID,
|
|
||||||
"version": version,
|
|
||||||
"language": language,
|
|
||||||
"title": title,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"consents": consents})
|
|
||||||
}
|
|
||||||
|
|
||||||
// CheckConsent checks if the user has consented to a document
|
|
||||||
func (h *Handler) CheckConsent(c *gin.Context) {
|
|
||||||
docType := c.Param("documentType")
|
|
||||||
language := c.DefaultQuery("language", "de")
|
|
||||||
|
|
||||||
userID, err := middleware.GetUserID(c)
|
|
||||||
if err != nil || userID == uuid.Nil {
|
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
// Get latest published version
|
|
||||||
var latestVersionID uuid.UUID
|
|
||||||
var latestVersion string
|
|
||||||
err = h.db.Pool.QueryRow(ctx, `
|
|
||||||
SELECT dv.id, dv.version
|
|
||||||
FROM document_versions dv
|
|
||||||
JOIN legal_documents ld ON dv.document_id = ld.id
|
|
||||||
WHERE ld.type = $1 AND dv.language = $2 AND dv.status = 'published'
|
|
||||||
ORDER BY dv.published_at DESC
|
|
||||||
LIMIT 1
|
|
||||||
`, docType, language).Scan(&latestVersionID, &latestVersion)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusOK, models.ConsentCheckResponse{
|
|
||||||
HasConsent: false,
|
|
||||||
NeedsUpdate: false,
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if user has consented to this version
|
|
||||||
var consentedVersionID uuid.UUID
|
|
||||||
var consentedVersion string
|
|
||||||
var consentedAt time.Time
|
|
||||||
err = h.db.Pool.QueryRow(ctx, `
|
|
||||||
SELECT dv.id, dv.version, uc.consented_at
|
|
||||||
FROM user_consents uc
|
|
||||||
JOIN document_versions dv ON uc.document_version_id = dv.id
|
|
||||||
JOIN legal_documents ld ON dv.document_id = ld.id
|
|
||||||
WHERE uc.user_id = $1 AND ld.type = $2 AND uc.consented = true AND uc.withdrawn_at IS NULL
|
|
||||||
ORDER BY uc.consented_at DESC
|
|
||||||
LIMIT 1
|
|
||||||
`, userID, docType).Scan(&consentedVersionID, &consentedVersion, &consentedAt)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
// No consent found
|
|
||||||
latestIDStr := latestVersionID.String()
|
|
||||||
c.JSON(http.StatusOK, models.ConsentCheckResponse{
|
|
||||||
HasConsent: false,
|
|
||||||
CurrentVersionID: &latestIDStr,
|
|
||||||
NeedsUpdate: true,
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if consent is for latest version
|
|
||||||
needsUpdate := consentedVersionID != latestVersionID
|
|
||||||
latestIDStr := latestVersionID.String()
|
|
||||||
consentedVerStr := consentedVersion
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, models.ConsentCheckResponse{
|
|
||||||
HasConsent: true,
|
|
||||||
CurrentVersionID: &latestIDStr,
|
|
||||||
ConsentedVersion: &consentedVerStr,
|
|
||||||
NeedsUpdate: needsUpdate,
|
|
||||||
ConsentedAt: &consentedAt,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// WithdrawConsent withdraws a consent
|
|
||||||
func (h *Handler) WithdrawConsent(c *gin.Context) {
|
|
||||||
consentID, err := uuid.Parse(c.Param("id"))
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid consent ID"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
userID, err := middleware.GetUserID(c)
|
|
||||||
if err != nil || userID == uuid.Nil {
|
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
ipAddress := middleware.GetClientIP(c)
|
|
||||||
userAgent := middleware.GetUserAgent(c)
|
|
||||||
|
|
||||||
// Update consent
|
|
||||||
result, err := h.db.Pool.Exec(ctx, `
|
|
||||||
UPDATE user_consents
|
|
||||||
SET withdrawn_at = NOW(), consented = false
|
|
||||||
WHERE id = $1 AND user_id = $2
|
|
||||||
`, consentID, userID)
|
|
||||||
|
|
||||||
if err != nil || result.RowsAffected() == 0 {
|
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "Consent not found"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Log to audit trail
|
|
||||||
h.logAudit(ctx, &userID, "consent_withdrawn", "consent", &consentID, nil, ipAddress, userAgent)
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "Consent withdrawn successfully"})
|
|
||||||
}
|
|
||||||
@@ -1,158 +0,0 @@
|
|||||||
package handlers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"net/http"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/breakpilot/consent-service/internal/middleware"
|
|
||||||
"github.com/breakpilot/consent-service/internal/models"
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
"github.com/google/uuid"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// PUBLIC ENDPOINTS - Cookie Consent
|
|
||||||
// ========================================
|
|
||||||
|
|
||||||
// GetCookieCategories returns all active cookie categories
|
|
||||||
func (h *Handler) GetCookieCategories(c *gin.Context) {
|
|
||||||
language := c.DefaultQuery("language", "de")
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
rows, err := h.db.Pool.Query(ctx, `
|
|
||||||
SELECT id, name, display_name_de, display_name_en, description_de, description_en,
|
|
||||||
is_mandatory, sort_order
|
|
||||||
FROM cookie_categories
|
|
||||||
WHERE is_active = true
|
|
||||||
ORDER BY sort_order ASC
|
|
||||||
`)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch categories"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var categories []map[string]interface{}
|
|
||||||
for rows.Next() {
|
|
||||||
var cat models.CookieCategory
|
|
||||||
if err := rows.Scan(&cat.ID, &cat.Name, &cat.DisplayNameDE, &cat.DisplayNameEN,
|
|
||||||
&cat.DescriptionDE, &cat.DescriptionEN, &cat.IsMandatory, &cat.SortOrder); err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return localized data
|
|
||||||
displayName := cat.DisplayNameDE
|
|
||||||
description := cat.DescriptionDE
|
|
||||||
if language == "en" && cat.DisplayNameEN != nil {
|
|
||||||
displayName = *cat.DisplayNameEN
|
|
||||||
if cat.DescriptionEN != nil {
|
|
||||||
description = cat.DescriptionEN
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
categories = append(categories, map[string]interface{}{
|
|
||||||
"id": cat.ID,
|
|
||||||
"name": cat.Name,
|
|
||||||
"display_name": displayName,
|
|
||||||
"description": description,
|
|
||||||
"is_mandatory": cat.IsMandatory,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"categories": categories})
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetCookieConsent sets cookie preferences for a user
|
|
||||||
func (h *Handler) SetCookieConsent(c *gin.Context) {
|
|
||||||
var req models.CookieConsentRequest
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
userID, err := middleware.GetUserID(c)
|
|
||||||
if err != nil || userID == uuid.Nil {
|
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
ipAddress := middleware.GetClientIP(c)
|
|
||||||
userAgent := middleware.GetUserAgent(c)
|
|
||||||
|
|
||||||
// Process each category
|
|
||||||
for _, cat := range req.Categories {
|
|
||||||
categoryID, err := uuid.Parse(cat.CategoryID)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = h.db.Pool.Exec(ctx, `
|
|
||||||
INSERT INTO cookie_consents (user_id, category_id, consented)
|
|
||||||
VALUES ($1, $2, $3)
|
|
||||||
ON CONFLICT (user_id, category_id)
|
|
||||||
DO UPDATE SET consented = $3, updated_at = NOW()
|
|
||||||
`, userID, categoryID, cat.Consented)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Log to audit trail
|
|
||||||
h.logAudit(ctx, &userID, "cookie_consent_updated", "cookie", nil, nil, ipAddress, userAgent)
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "Cookie preferences saved"})
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetMyCookieConsent returns cookie preferences for the current user
|
|
||||||
func (h *Handler) GetMyCookieConsent(c *gin.Context) {
|
|
||||||
userID, err := middleware.GetUserID(c)
|
|
||||||
if err != nil || userID == uuid.Nil {
|
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
rows, err := h.db.Pool.Query(ctx, `
|
|
||||||
SELECT cc.category_id, cc.consented, cc.updated_at,
|
|
||||||
cat.name, cat.display_name_de, cat.is_mandatory
|
|
||||||
FROM cookie_consents cc
|
|
||||||
JOIN cookie_categories cat ON cc.category_id = cat.id
|
|
||||||
WHERE cc.user_id = $1
|
|
||||||
`, userID)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch preferences"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var consents []map[string]interface{}
|
|
||||||
for rows.Next() {
|
|
||||||
var (
|
|
||||||
categoryID uuid.UUID
|
|
||||||
consented bool
|
|
||||||
updatedAt time.Time
|
|
||||||
name string
|
|
||||||
displayName string
|
|
||||||
isMandatory bool
|
|
||||||
)
|
|
||||||
|
|
||||||
if err := rows.Scan(&categoryID, &consented, &updatedAt, &name, &displayName, &isMandatory); err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
consents = append(consents, map[string]interface{}{
|
|
||||||
"category_id": categoryID,
|
|
||||||
"name": name,
|
|
||||||
"display_name": displayName,
|
|
||||||
"consented": consented,
|
|
||||||
"is_mandatory": isMandatory,
|
|
||||||
"updated_at": updatedAt,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"cookie_consents": consents})
|
|
||||||
}
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
package handlers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"github.com/breakpilot/consent-service/internal/models"
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// PUBLIC ENDPOINTS - Documents
|
|
||||||
// ========================================
|
|
||||||
|
|
||||||
// GetDocuments returns all active legal documents
|
|
||||||
func (h *Handler) GetDocuments(c *gin.Context) {
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
rows, err := h.db.Pool.Query(ctx, `
|
|
||||||
SELECT id, type, name, description, is_mandatory, is_active, sort_order, created_at, updated_at
|
|
||||||
FROM legal_documents
|
|
||||||
WHERE is_active = true
|
|
||||||
ORDER BY sort_order ASC
|
|
||||||
`)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch documents"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var documents []models.LegalDocument
|
|
||||||
for rows.Next() {
|
|
||||||
var doc models.LegalDocument
|
|
||||||
if err := rows.Scan(&doc.ID, &doc.Type, &doc.Name, &doc.Description,
|
|
||||||
&doc.IsMandatory, &doc.IsActive, &doc.SortOrder, &doc.CreatedAt, &doc.UpdatedAt); err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
documents = append(documents, doc)
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"documents": documents})
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetDocumentByType returns a document by its type
|
|
||||||
func (h *Handler) GetDocumentByType(c *gin.Context) {
|
|
||||||
docType := c.Param("type")
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
var doc models.LegalDocument
|
|
||||||
err := h.db.Pool.QueryRow(ctx, `
|
|
||||||
SELECT id, type, name, description, is_mandatory, is_active, sort_order, created_at, updated_at
|
|
||||||
FROM legal_documents
|
|
||||||
WHERE type = $1 AND is_active = true
|
|
||||||
`, docType).Scan(&doc.ID, &doc.Type, &doc.Name, &doc.Description,
|
|
||||||
&doc.IsMandatory, &doc.IsActive, &doc.SortOrder, &doc.CreatedAt, &doc.UpdatedAt)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "Document not found"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, doc)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetLatestDocumentVersion returns the latest published version of a document
|
|
||||||
func (h *Handler) GetLatestDocumentVersion(c *gin.Context) {
|
|
||||||
docType := c.Param("type")
|
|
||||||
language := c.DefaultQuery("language", "de")
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
var version models.DocumentVersion
|
|
||||||
err := h.db.Pool.QueryRow(ctx, `
|
|
||||||
SELECT dv.id, dv.document_id, dv.version, dv.language, dv.title, dv.content,
|
|
||||||
dv.summary, dv.status, dv.published_at, dv.created_at, dv.updated_at
|
|
||||||
FROM document_versions dv
|
|
||||||
JOIN legal_documents ld ON dv.document_id = ld.id
|
|
||||||
WHERE ld.type = $1 AND dv.language = $2 AND dv.status = 'published'
|
|
||||||
ORDER BY dv.published_at DESC
|
|
||||||
LIMIT 1
|
|
||||||
`, docType, language).Scan(&version.ID, &version.DocumentID, &version.Version, &version.Language,
|
|
||||||
&version.Title, &version.Content, &version.Summary, &version.Status,
|
|
||||||
&version.PublishedAt, &version.CreatedAt, &version.UpdatedAt)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "No published version found"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, version)
|
|
||||||
}
|
|
||||||
@@ -3,6 +3,8 @@ package handlers
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/breakpilot/consent-service/internal/middleware"
|
"github.com/breakpilot/consent-service/internal/middleware"
|
||||||
"github.com/breakpilot/consent-service/internal/models"
|
"github.com/breakpilot/consent-service/internal/models"
|
||||||
@@ -133,3 +135,814 @@ func (h *DSRHandler) CancelMyDSR(c *gin.Context) {
|
|||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "Anfrage wurde storniert"})
|
c.JSON(http.StatusOK, gin.H{"message": "Anfrage wurde storniert"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// ADMIN ENDPOINTS
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
// AdminListDSR returns all DSRs with filters (admin only)
|
||||||
|
func (h *DSRHandler) AdminListDSR(c *gin.Context) {
|
||||||
|
if !middleware.IsAdmin(c) && !middleware.IsDSB(c) {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse pagination
|
||||||
|
limit := 20
|
||||||
|
offset := 0
|
||||||
|
if l := c.Query("limit"); l != "" {
|
||||||
|
if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 {
|
||||||
|
limit = parsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if o := c.Query("offset"); o != "" {
|
||||||
|
if parsed, err := strconv.Atoi(o); err == nil && parsed >= 0 {
|
||||||
|
offset = parsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse filters
|
||||||
|
filters := models.DSRListFilters{}
|
||||||
|
if status := c.Query("status"); status != "" {
|
||||||
|
filters.Status = &status
|
||||||
|
}
|
||||||
|
if reqType := c.Query("request_type"); reqType != "" {
|
||||||
|
filters.RequestType = &reqType
|
||||||
|
}
|
||||||
|
if assignedTo := c.Query("assigned_to"); assignedTo != "" {
|
||||||
|
filters.AssignedTo = &assignedTo
|
||||||
|
}
|
||||||
|
if priority := c.Query("priority"); priority != "" {
|
||||||
|
filters.Priority = &priority
|
||||||
|
}
|
||||||
|
if c.Query("overdue_only") == "true" {
|
||||||
|
filters.OverdueOnly = true
|
||||||
|
}
|
||||||
|
if search := c.Query("search"); search != "" {
|
||||||
|
filters.Search = &search
|
||||||
|
}
|
||||||
|
if from := c.Query("from_date"); from != "" {
|
||||||
|
if t, err := time.Parse("2006-01-02", from); err == nil {
|
||||||
|
filters.FromDate = &t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if to := c.Query("to_date"); to != "" {
|
||||||
|
if t, err := time.Parse("2006-01-02", to); err == nil {
|
||||||
|
filters.ToDate = &t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dsrs, total, err := h.dsrService.List(c.Request.Context(), filters, limit, offset)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch requests"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"requests": dsrs,
|
||||||
|
"total": total,
|
||||||
|
"limit": limit,
|
||||||
|
"offset": offset,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminGetDSR returns a specific DSR (admin only)
|
||||||
|
func (h *DSRHandler) AdminGetDSR(c *gin.Context) {
|
||||||
|
if !middleware.IsAdmin(c) && !middleware.IsDSB(c) {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
dsrID, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
dsr, err := h.dsrService.GetByID(c.Request.Context(), dsrID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Request not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, dsr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminCreateDSR creates a DSR manually (admin only)
|
||||||
|
func (h *DSRHandler) AdminCreateDSR(c *gin.Context) {
|
||||||
|
if !middleware.IsAdmin(c) && !middleware.IsDSB(c) {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userID, _ := middleware.GetUserID(c)
|
||||||
|
|
||||||
|
var req models.CreateDSRRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body", "details": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set source as admin_panel
|
||||||
|
if req.Source == "" {
|
||||||
|
req.Source = "admin_panel"
|
||||||
|
}
|
||||||
|
|
||||||
|
dsr, err := h.dsrService.CreateRequest(c.Request.Context(), req, &userID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusCreated, gin.H{
|
||||||
|
"message": "Anfrage wurde erstellt",
|
||||||
|
"request_number": dsr.RequestNumber,
|
||||||
|
"dsr": dsr,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminUpdateDSR updates a DSR (admin only)
|
||||||
|
func (h *DSRHandler) AdminUpdateDSR(c *gin.Context) {
|
||||||
|
if !middleware.IsAdmin(c) && !middleware.IsDSB(c) {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
dsrID, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userID, _ := middleware.GetUserID(c)
|
||||||
|
|
||||||
|
var req models.UpdateDSRRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
|
||||||
|
// Update status if provided
|
||||||
|
if req.Status != nil {
|
||||||
|
err = h.dsrService.UpdateStatus(ctx, dsrID, models.DSRStatus(*req.Status), "", &userID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update processing notes
|
||||||
|
if req.ProcessingNotes != nil {
|
||||||
|
h.dsrService.GetPool().Exec(ctx, `
|
||||||
|
UPDATE data_subject_requests SET processing_notes = $1, updated_at = NOW() WHERE id = $2
|
||||||
|
`, *req.ProcessingNotes, dsrID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update priority
|
||||||
|
if req.Priority != nil {
|
||||||
|
h.dsrService.GetPool().Exec(ctx, `
|
||||||
|
UPDATE data_subject_requests SET priority = $1, updated_at = NOW() WHERE id = $2
|
||||||
|
`, *req.Priority, dsrID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get updated DSR
|
||||||
|
dsr, _ := h.dsrService.GetByID(ctx, dsrID)
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"message": "Anfrage wurde aktualisiert",
|
||||||
|
"dsr": dsr,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminGetDSRStats returns dashboard statistics
|
||||||
|
func (h *DSRHandler) AdminGetDSRStats(c *gin.Context) {
|
||||||
|
if !middleware.IsAdmin(c) && !middleware.IsDSB(c) {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
stats, err := h.dsrService.GetDashboardStats(c.Request.Context())
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch statistics"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, stats)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminVerifyIdentity verifies the identity of a requester
|
||||||
|
func (h *DSRHandler) AdminVerifyIdentity(c *gin.Context) {
|
||||||
|
if !middleware.IsAdmin(c) && !middleware.IsDSB(c) {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
dsrID, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userID, _ := middleware.GetUserID(c)
|
||||||
|
|
||||||
|
var req models.VerifyDSRIdentityRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = h.dsrService.VerifyIdentity(c.Request.Context(), dsrID, req.Method, userID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "Identität wurde verifiziert"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminAssignDSR assigns a DSR to a user
|
||||||
|
func (h *DSRHandler) AdminAssignDSR(c *gin.Context) {
|
||||||
|
if !middleware.IsAdmin(c) && !middleware.IsDSB(c) {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
dsrID, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userID, _ := middleware.GetUserID(c)
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
AssigneeID string `json:"assignee_id" binding:"required"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
assigneeID, err := uuid.Parse(req.AssigneeID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid assignee ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = h.dsrService.AssignRequest(c.Request.Context(), dsrID, assigneeID, userID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "Anfrage wurde zugewiesen"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminExtendDSRDeadline extends the deadline for a DSR
|
||||||
|
func (h *DSRHandler) AdminExtendDSRDeadline(c *gin.Context) {
|
||||||
|
if !middleware.IsAdmin(c) && !middleware.IsDSB(c) {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
dsrID, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userID, _ := middleware.GetUserID(c)
|
||||||
|
|
||||||
|
var req models.ExtendDSRDeadlineRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = h.dsrService.ExtendDeadline(c.Request.Context(), dsrID, req.Reason, req.Days, userID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "Frist wurde verlängert"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminCompleteDSR marks a DSR as completed
|
||||||
|
func (h *DSRHandler) AdminCompleteDSR(c *gin.Context) {
|
||||||
|
if !middleware.IsAdmin(c) && !middleware.IsDSB(c) {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
dsrID, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userID, _ := middleware.GetUserID(c)
|
||||||
|
|
||||||
|
var req models.CompleteDSRRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = h.dsrService.CompleteRequest(c.Request.Context(), dsrID, req.ResultSummary, req.ResultData, userID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "Anfrage wurde abgeschlossen"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminRejectDSR rejects a DSR
|
||||||
|
func (h *DSRHandler) AdminRejectDSR(c *gin.Context) {
|
||||||
|
if !middleware.IsAdmin(c) && !middleware.IsDSB(c) {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
dsrID, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userID, _ := middleware.GetUserID(c)
|
||||||
|
|
||||||
|
var req models.RejectDSRRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = h.dsrService.RejectRequest(c.Request.Context(), dsrID, req.Reason, req.LegalBasis, userID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "Anfrage wurde abgelehnt"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminGetDSRHistory returns the status history for a DSR
|
||||||
|
func (h *DSRHandler) AdminGetDSRHistory(c *gin.Context) {
|
||||||
|
if !middleware.IsAdmin(c) && !middleware.IsDSB(c) {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
dsrID, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
history, err := h.dsrService.GetStatusHistory(c.Request.Context(), dsrID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch history"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"history": history})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminGetDSRCommunications returns communications for a DSR
|
||||||
|
func (h *DSRHandler) AdminGetDSRCommunications(c *gin.Context) {
|
||||||
|
if !middleware.IsAdmin(c) && !middleware.IsDSB(c) {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
dsrID, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
comms, err := h.dsrService.GetCommunications(c.Request.Context(), dsrID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch communications"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"communications": comms})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminSendDSRCommunication sends a communication for a DSR
|
||||||
|
func (h *DSRHandler) AdminSendDSRCommunication(c *gin.Context) {
|
||||||
|
if !middleware.IsAdmin(c) && !middleware.IsDSB(c) {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
dsrID, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userID, _ := middleware.GetUserID(c)
|
||||||
|
|
||||||
|
var req models.SendDSRCommunicationRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = h.dsrService.SendCommunication(c.Request.Context(), dsrID, req, userID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "Kommunikation wurde gesendet"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminUpdateDSRStatus updates the status of a DSR
|
||||||
|
func (h *DSRHandler) AdminUpdateDSRStatus(c *gin.Context) {
|
||||||
|
if !middleware.IsAdmin(c) && !middleware.IsDSB(c) {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
dsrID, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userID, _ := middleware.GetUserID(c)
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Status string `json:"status" binding:"required"`
|
||||||
|
Comment string `json:"comment"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = h.dsrService.UpdateStatus(c.Request.Context(), dsrID, models.DSRStatus(req.Status), req.Comment, &userID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "Status wurde aktualisiert"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// EXCEPTION CHECKS (Art. 17)
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
// AdminGetExceptionChecks returns exception checks for an erasure DSR
|
||||||
|
func (h *DSRHandler) AdminGetExceptionChecks(c *gin.Context) {
|
||||||
|
if !middleware.IsAdmin(c) && !middleware.IsDSB(c) {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
dsrID, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
checks, err := h.dsrService.GetExceptionChecks(c.Request.Context(), dsrID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch exception checks"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"exception_checks": checks})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminInitExceptionChecks initializes exception checks for an erasure DSR
|
||||||
|
func (h *DSRHandler) AdminInitExceptionChecks(c *gin.Context) {
|
||||||
|
if !middleware.IsAdmin(c) && !middleware.IsDSB(c) {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
dsrID, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = h.dsrService.InitErasureExceptionChecks(c.Request.Context(), dsrID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to initialize exception checks"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "Ausnahmeprüfungen wurden initialisiert"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminUpdateExceptionCheck updates a single exception check
|
||||||
|
func (h *DSRHandler) AdminUpdateExceptionCheck(c *gin.Context) {
|
||||||
|
if !middleware.IsAdmin(c) && !middleware.IsDSB(c) {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
checkID, err := uuid.Parse(c.Param("checkId"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid check ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userID, _ := middleware.GetUserID(c)
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Applies bool `json:"applies"`
|
||||||
|
Notes *string `json:"notes"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = h.dsrService.UpdateExceptionCheck(c.Request.Context(), checkID, req.Applies, req.Notes, userID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update exception check"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "Ausnahmeprüfung wurde aktualisiert"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// TEMPLATE ENDPOINTS
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
// AdminGetDSRTemplates returns all DSR templates
|
||||||
|
func (h *DSRHandler) AdminGetDSRTemplates(c *gin.Context) {
|
||||||
|
if !middleware.IsAdmin(c) && !middleware.IsDSB(c) {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
rows, err := h.dsrService.GetPool().Query(ctx, `
|
||||||
|
SELECT id, template_type, name, description, request_types, is_active, sort_order, created_at, updated_at
|
||||||
|
FROM dsr_templates ORDER BY sort_order, name
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch templates"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var templates []map[string]interface{}
|
||||||
|
for rows.Next() {
|
||||||
|
var id uuid.UUID
|
||||||
|
var templateType, name string
|
||||||
|
var description *string
|
||||||
|
var requestTypes []byte
|
||||||
|
var isActive bool
|
||||||
|
var sortOrder int
|
||||||
|
var createdAt, updatedAt time.Time
|
||||||
|
|
||||||
|
err := rows.Scan(&id, &templateType, &name, &description, &requestTypes, &isActive, &sortOrder, &createdAt, &updatedAt)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
templates = append(templates, map[string]interface{}{
|
||||||
|
"id": id,
|
||||||
|
"template_type": templateType,
|
||||||
|
"name": name,
|
||||||
|
"description": description,
|
||||||
|
"request_types": string(requestTypes),
|
||||||
|
"is_active": isActive,
|
||||||
|
"sort_order": sortOrder,
|
||||||
|
"created_at": createdAt,
|
||||||
|
"updated_at": updatedAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"templates": templates})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminGetDSRTemplateVersions returns versions for a template
|
||||||
|
func (h *DSRHandler) AdminGetDSRTemplateVersions(c *gin.Context) {
|
||||||
|
if !middleware.IsAdmin(c) && !middleware.IsDSB(c) {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
templateID, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid template ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
rows, err := h.dsrService.GetPool().Query(ctx, `
|
||||||
|
SELECT id, template_id, version, language, subject, body_html, body_text,
|
||||||
|
status, published_at, created_by, approved_by, approved_at, created_at, updated_at
|
||||||
|
FROM dsr_template_versions WHERE template_id = $1 ORDER BY created_at DESC
|
||||||
|
`, templateID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch versions"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var versions []map[string]interface{}
|
||||||
|
for rows.Next() {
|
||||||
|
var id, tempID uuid.UUID
|
||||||
|
var version, language, subject, bodyHTML, bodyText, status string
|
||||||
|
var publishedAt, approvedAt *time.Time
|
||||||
|
var createdBy, approvedBy *uuid.UUID
|
||||||
|
var createdAt, updatedAt time.Time
|
||||||
|
|
||||||
|
err := rows.Scan(&id, &tempID, &version, &language, &subject, &bodyHTML, &bodyText,
|
||||||
|
&status, &publishedAt, &createdBy, &approvedBy, &approvedAt, &createdAt, &updatedAt)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
versions = append(versions, map[string]interface{}{
|
||||||
|
"id": id,
|
||||||
|
"template_id": tempID,
|
||||||
|
"version": version,
|
||||||
|
"language": language,
|
||||||
|
"subject": subject,
|
||||||
|
"body_html": bodyHTML,
|
||||||
|
"body_text": bodyText,
|
||||||
|
"status": status,
|
||||||
|
"published_at": publishedAt,
|
||||||
|
"created_by": createdBy,
|
||||||
|
"approved_by": approvedBy,
|
||||||
|
"approved_at": approvedAt,
|
||||||
|
"created_at": createdAt,
|
||||||
|
"updated_at": updatedAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"versions": versions})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminCreateDSRTemplateVersion creates a new template version
|
||||||
|
func (h *DSRHandler) AdminCreateDSRTemplateVersion(c *gin.Context) {
|
||||||
|
if !middleware.IsAdmin(c) && !middleware.IsDSB(c) {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
templateID, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid template ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userID, _ := middleware.GetUserID(c)
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Version string `json:"version" binding:"required"`
|
||||||
|
Language string `json:"language"`
|
||||||
|
Subject string `json:"subject" binding:"required"`
|
||||||
|
BodyHTML string `json:"body_html" binding:"required"`
|
||||||
|
BodyText string `json:"body_text"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Language == "" {
|
||||||
|
req.Language = "de"
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
var versionID uuid.UUID
|
||||||
|
err = h.dsrService.GetPool().QueryRow(ctx, `
|
||||||
|
INSERT INTO dsr_template_versions (template_id, version, language, subject, body_html, body_text, created_by)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
|
RETURNING id
|
||||||
|
`, templateID, req.Version, req.Language, req.Subject, req.BodyHTML, req.BodyText, userID).Scan(&versionID)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create version"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusCreated, gin.H{
|
||||||
|
"message": "Version wurde erstellt",
|
||||||
|
"id": versionID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminPublishDSRTemplateVersion publishes a template version
|
||||||
|
func (h *DSRHandler) AdminPublishDSRTemplateVersion(c *gin.Context) {
|
||||||
|
if !middleware.IsAdmin(c) && !middleware.IsDSB(c) {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
versionID, err := uuid.Parse(c.Param("versionId"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid version ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userID, _ := middleware.GetUserID(c)
|
||||||
|
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
_, err = h.dsrService.GetPool().Exec(ctx, `
|
||||||
|
UPDATE dsr_template_versions
|
||||||
|
SET status = 'published', published_at = NOW(), approved_by = $1, approved_at = NOW(), updated_at = NOW()
|
||||||
|
WHERE id = $2
|
||||||
|
`, userID, versionID)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to publish version"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "Version wurde veröffentlicht"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminGetPublishedDSRTemplates returns all published templates for selection
|
||||||
|
func (h *DSRHandler) AdminGetPublishedDSRTemplates(c *gin.Context) {
|
||||||
|
if !middleware.IsAdmin(c) && !middleware.IsDSB(c) {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
requestType := c.Query("request_type")
|
||||||
|
language := c.DefaultQuery("language", "de")
|
||||||
|
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
query := `
|
||||||
|
SELECT t.id, t.template_type, t.name, t.description,
|
||||||
|
v.id as version_id, v.version, v.subject, v.body_html, v.body_text
|
||||||
|
FROM dsr_templates t
|
||||||
|
JOIN dsr_template_versions v ON t.id = v.template_id
|
||||||
|
WHERE t.is_active = TRUE AND v.status = 'published' AND v.language = $1
|
||||||
|
`
|
||||||
|
args := []interface{}{language}
|
||||||
|
|
||||||
|
if requestType != "" {
|
||||||
|
query += ` AND t.request_types @> $2::jsonb`
|
||||||
|
args = append(args, `["`+requestType+`"]`)
|
||||||
|
}
|
||||||
|
|
||||||
|
query += " ORDER BY t.sort_order, t.name"
|
||||||
|
|
||||||
|
rows, err := h.dsrService.GetPool().Query(ctx, query, args...)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch templates"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var templates []map[string]interface{}
|
||||||
|
for rows.Next() {
|
||||||
|
var templateID, versionID uuid.UUID
|
||||||
|
var templateType, name, version, subject, bodyHTML, bodyText string
|
||||||
|
var description *string
|
||||||
|
|
||||||
|
err := rows.Scan(&templateID, &templateType, &name, &description, &versionID, &version, &subject, &bodyHTML, &bodyText)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
templates = append(templates, map[string]interface{}{
|
||||||
|
"template_id": templateID,
|
||||||
|
"template_type": templateType,
|
||||||
|
"name": name,
|
||||||
|
"description": description,
|
||||||
|
"version_id": versionID,
|
||||||
|
"version": version,
|
||||||
|
"subject": subject,
|
||||||
|
"body_html": bodyHTML,
|
||||||
|
"body_text": bodyText,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"templates": templates})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// DEADLINE PROCESSING
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
// ProcessDeadlines triggers deadline checking (called by scheduler)
|
||||||
|
func (h *DSRHandler) ProcessDeadlines(c *gin.Context) {
|
||||||
|
err := h.dsrService.ProcessDeadlines(c.Request.Context())
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to process deadlines"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "Deadline processing completed"})
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,388 +0,0 @@
|
|||||||
package handlers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"strconv"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/breakpilot/consent-service/internal/middleware"
|
|
||||||
"github.com/breakpilot/consent-service/internal/models"
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
"github.com/google/uuid"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// ADMIN ENDPOINTS — CRUD & Workflow
|
|
||||||
// ========================================
|
|
||||||
|
|
||||||
// AdminListDSR returns all DSRs with filters (admin only)
|
|
||||||
func (h *DSRHandler) AdminListDSR(c *gin.Context) {
|
|
||||||
if !middleware.IsAdmin(c) && !middleware.IsDSB(c) {
|
|
||||||
c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse pagination
|
|
||||||
limit := 20
|
|
||||||
offset := 0
|
|
||||||
if l := c.Query("limit"); l != "" {
|
|
||||||
if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 {
|
|
||||||
limit = parsed
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if o := c.Query("offset"); o != "" {
|
|
||||||
if parsed, err := strconv.Atoi(o); err == nil && parsed >= 0 {
|
|
||||||
offset = parsed
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse filters
|
|
||||||
filters := models.DSRListFilters{}
|
|
||||||
if status := c.Query("status"); status != "" {
|
|
||||||
filters.Status = &status
|
|
||||||
}
|
|
||||||
if reqType := c.Query("request_type"); reqType != "" {
|
|
||||||
filters.RequestType = &reqType
|
|
||||||
}
|
|
||||||
if assignedTo := c.Query("assigned_to"); assignedTo != "" {
|
|
||||||
filters.AssignedTo = &assignedTo
|
|
||||||
}
|
|
||||||
if priority := c.Query("priority"); priority != "" {
|
|
||||||
filters.Priority = &priority
|
|
||||||
}
|
|
||||||
if c.Query("overdue_only") == "true" {
|
|
||||||
filters.OverdueOnly = true
|
|
||||||
}
|
|
||||||
if search := c.Query("search"); search != "" {
|
|
||||||
filters.Search = &search
|
|
||||||
}
|
|
||||||
if from := c.Query("from_date"); from != "" {
|
|
||||||
if t, err := time.Parse("2006-01-02", from); err == nil {
|
|
||||||
filters.FromDate = &t
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if to := c.Query("to_date"); to != "" {
|
|
||||||
if t, err := time.Parse("2006-01-02", to); err == nil {
|
|
||||||
filters.ToDate = &t
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
dsrs, total, err := h.dsrService.List(c.Request.Context(), filters, limit, offset)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch requests"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
|
||||||
"requests": dsrs,
|
|
||||||
"total": total,
|
|
||||||
"limit": limit,
|
|
||||||
"offset": offset,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// AdminGetDSR returns a specific DSR (admin only)
|
|
||||||
func (h *DSRHandler) AdminGetDSR(c *gin.Context) {
|
|
||||||
if !middleware.IsAdmin(c) && !middleware.IsDSB(c) {
|
|
||||||
c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
dsrID, err := uuid.Parse(c.Param("id"))
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request ID"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
dsr, err := h.dsrService.GetByID(c.Request.Context(), dsrID)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "Request not found"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, dsr)
|
|
||||||
}
|
|
||||||
|
|
||||||
// AdminCreateDSR creates a DSR manually (admin only)
|
|
||||||
func (h *DSRHandler) AdminCreateDSR(c *gin.Context) {
|
|
||||||
if !middleware.IsAdmin(c) && !middleware.IsDSB(c) {
|
|
||||||
c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
userID, _ := middleware.GetUserID(c)
|
|
||||||
|
|
||||||
var req models.CreateDSRRequest
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body", "details": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set source as admin_panel
|
|
||||||
if req.Source == "" {
|
|
||||||
req.Source = "admin_panel"
|
|
||||||
}
|
|
||||||
|
|
||||||
dsr, err := h.dsrService.CreateRequest(c.Request.Context(), req, &userID)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusCreated, gin.H{
|
|
||||||
"message": "Anfrage wurde erstellt",
|
|
||||||
"request_number": dsr.RequestNumber,
|
|
||||||
"dsr": dsr,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// AdminUpdateDSR updates a DSR (admin only)
|
|
||||||
func (h *DSRHandler) AdminUpdateDSR(c *gin.Context) {
|
|
||||||
if !middleware.IsAdmin(c) && !middleware.IsDSB(c) {
|
|
||||||
c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
dsrID, err := uuid.Parse(c.Param("id"))
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request ID"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
userID, _ := middleware.GetUserID(c)
|
|
||||||
|
|
||||||
var req models.UpdateDSRRequest
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := c.Request.Context()
|
|
||||||
|
|
||||||
// Update status if provided
|
|
||||||
if req.Status != nil {
|
|
||||||
err = h.dsrService.UpdateStatus(ctx, dsrID, models.DSRStatus(*req.Status), "", &userID)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update processing notes
|
|
||||||
if req.ProcessingNotes != nil {
|
|
||||||
h.dsrService.GetPool().Exec(ctx, `
|
|
||||||
UPDATE data_subject_requests SET processing_notes = $1, updated_at = NOW() WHERE id = $2
|
|
||||||
`, *req.ProcessingNotes, dsrID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update priority
|
|
||||||
if req.Priority != nil {
|
|
||||||
h.dsrService.GetPool().Exec(ctx, `
|
|
||||||
UPDATE data_subject_requests SET priority = $1, updated_at = NOW() WHERE id = $2
|
|
||||||
`, *req.Priority, dsrID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get updated DSR
|
|
||||||
dsr, _ := h.dsrService.GetByID(ctx, dsrID)
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
|
||||||
"message": "Anfrage wurde aktualisiert",
|
|
||||||
"dsr": dsr,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// AdminGetDSRStats returns dashboard statistics
|
|
||||||
func (h *DSRHandler) AdminGetDSRStats(c *gin.Context) {
|
|
||||||
if !middleware.IsAdmin(c) && !middleware.IsDSB(c) {
|
|
||||||
c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
stats, err := h.dsrService.GetDashboardStats(c.Request.Context())
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch statistics"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, stats)
|
|
||||||
}
|
|
||||||
|
|
||||||
// AdminVerifyIdentity verifies the identity of a requester
|
|
||||||
func (h *DSRHandler) AdminVerifyIdentity(c *gin.Context) {
|
|
||||||
if !middleware.IsAdmin(c) && !middleware.IsDSB(c) {
|
|
||||||
c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
dsrID, err := uuid.Parse(c.Param("id"))
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request ID"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
userID, _ := middleware.GetUserID(c)
|
|
||||||
|
|
||||||
var req models.VerifyDSRIdentityRequest
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
err = h.dsrService.VerifyIdentity(c.Request.Context(), dsrID, req.Method, userID)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "Identität wurde verifiziert"})
|
|
||||||
}
|
|
||||||
|
|
||||||
// AdminAssignDSR assigns a DSR to a user
|
|
||||||
func (h *DSRHandler) AdminAssignDSR(c *gin.Context) {
|
|
||||||
if !middleware.IsAdmin(c) && !middleware.IsDSB(c) {
|
|
||||||
c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
dsrID, err := uuid.Parse(c.Param("id"))
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request ID"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
userID, _ := middleware.GetUserID(c)
|
|
||||||
|
|
||||||
var req struct {
|
|
||||||
AssigneeID string `json:"assignee_id" binding:"required"`
|
|
||||||
}
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
assigneeID, err := uuid.Parse(req.AssigneeID)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid assignee ID"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
err = h.dsrService.AssignRequest(c.Request.Context(), dsrID, assigneeID, userID)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "Anfrage wurde zugewiesen"})
|
|
||||||
}
|
|
||||||
|
|
||||||
// AdminExtendDSRDeadline extends the deadline for a DSR
|
|
||||||
func (h *DSRHandler) AdminExtendDSRDeadline(c *gin.Context) {
|
|
||||||
if !middleware.IsAdmin(c) && !middleware.IsDSB(c) {
|
|
||||||
c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
dsrID, err := uuid.Parse(c.Param("id"))
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request ID"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
userID, _ := middleware.GetUserID(c)
|
|
||||||
|
|
||||||
var req models.ExtendDSRDeadlineRequest
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
err = h.dsrService.ExtendDeadline(c.Request.Context(), dsrID, req.Reason, req.Days, userID)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "Frist wurde verlängert"})
|
|
||||||
}
|
|
||||||
|
|
||||||
// AdminCompleteDSR marks a DSR as completed
|
|
||||||
func (h *DSRHandler) AdminCompleteDSR(c *gin.Context) {
|
|
||||||
if !middleware.IsAdmin(c) && !middleware.IsDSB(c) {
|
|
||||||
c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
dsrID, err := uuid.Parse(c.Param("id"))
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request ID"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
userID, _ := middleware.GetUserID(c)
|
|
||||||
|
|
||||||
var req models.CompleteDSRRequest
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
err = h.dsrService.CompleteRequest(c.Request.Context(), dsrID, req.ResultSummary, req.ResultData, userID)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "Anfrage wurde abgeschlossen"})
|
|
||||||
}
|
|
||||||
|
|
||||||
// AdminRejectDSR rejects a DSR
|
|
||||||
func (h *DSRHandler) AdminRejectDSR(c *gin.Context) {
|
|
||||||
if !middleware.IsAdmin(c) && !middleware.IsDSB(c) {
|
|
||||||
c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
dsrID, err := uuid.Parse(c.Param("id"))
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request ID"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
userID, _ := middleware.GetUserID(c)
|
|
||||||
|
|
||||||
var req models.RejectDSRRequest
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
err = h.dsrService.RejectRequest(c.Request.Context(), dsrID, req.Reason, req.LegalBasis, userID)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "Anfrage wurde abgelehnt"})
|
|
||||||
}
|
|
||||||
|
|
||||||
// AdminGetDSRHistory returns the status history for a DSR
|
|
||||||
func (h *DSRHandler) AdminGetDSRHistory(c *gin.Context) {
|
|
||||||
if !middleware.IsAdmin(c) && !middleware.IsDSB(c) {
|
|
||||||
c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
dsrID, err := uuid.Parse(c.Param("id"))
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request ID"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
history, err := h.dsrService.GetStatusHistory(c.Request.Context(), dsrID)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch history"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"history": history})
|
|
||||||
}
|
|
||||||
@@ -1,195 +0,0 @@
|
|||||||
package handlers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"github.com/breakpilot/consent-service/internal/middleware"
|
|
||||||
"github.com/breakpilot/consent-service/internal/models"
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
"github.com/google/uuid"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// ADMIN — Communications & Status
|
|
||||||
// ========================================
|
|
||||||
|
|
||||||
// AdminGetDSRCommunications returns communications for a DSR
|
|
||||||
func (h *DSRHandler) AdminGetDSRCommunications(c *gin.Context) {
|
|
||||||
if !middleware.IsAdmin(c) && !middleware.IsDSB(c) {
|
|
||||||
c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
dsrID, err := uuid.Parse(c.Param("id"))
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request ID"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
comms, err := h.dsrService.GetCommunications(c.Request.Context(), dsrID)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch communications"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"communications": comms})
|
|
||||||
}
|
|
||||||
|
|
||||||
// AdminSendDSRCommunication sends a communication for a DSR
|
|
||||||
func (h *DSRHandler) AdminSendDSRCommunication(c *gin.Context) {
|
|
||||||
if !middleware.IsAdmin(c) && !middleware.IsDSB(c) {
|
|
||||||
c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
dsrID, err := uuid.Parse(c.Param("id"))
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request ID"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
userID, _ := middleware.GetUserID(c)
|
|
||||||
|
|
||||||
var req models.SendDSRCommunicationRequest
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
err = h.dsrService.SendCommunication(c.Request.Context(), dsrID, req, userID)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "Kommunikation wurde gesendet"})
|
|
||||||
}
|
|
||||||
|
|
||||||
// AdminUpdateDSRStatus updates the status of a DSR
|
|
||||||
func (h *DSRHandler) AdminUpdateDSRStatus(c *gin.Context) {
|
|
||||||
if !middleware.IsAdmin(c) && !middleware.IsDSB(c) {
|
|
||||||
c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
dsrID, err := uuid.Parse(c.Param("id"))
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request ID"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
userID, _ := middleware.GetUserID(c)
|
|
||||||
|
|
||||||
var req struct {
|
|
||||||
Status string `json:"status" binding:"required"`
|
|
||||||
Comment string `json:"comment"`
|
|
||||||
}
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
err = h.dsrService.UpdateStatus(c.Request.Context(), dsrID, models.DSRStatus(req.Status), req.Comment, &userID)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "Status wurde aktualisiert"})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// EXCEPTION CHECKS (Art. 17)
|
|
||||||
// ========================================
|
|
||||||
|
|
||||||
// AdminGetExceptionChecks returns exception checks for an erasure DSR
|
|
||||||
func (h *DSRHandler) AdminGetExceptionChecks(c *gin.Context) {
|
|
||||||
if !middleware.IsAdmin(c) && !middleware.IsDSB(c) {
|
|
||||||
c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
dsrID, err := uuid.Parse(c.Param("id"))
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request ID"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
checks, err := h.dsrService.GetExceptionChecks(c.Request.Context(), dsrID)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch exception checks"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"exception_checks": checks})
|
|
||||||
}
|
|
||||||
|
|
||||||
// AdminInitExceptionChecks initializes exception checks for an erasure DSR
|
|
||||||
func (h *DSRHandler) AdminInitExceptionChecks(c *gin.Context) {
|
|
||||||
if !middleware.IsAdmin(c) && !middleware.IsDSB(c) {
|
|
||||||
c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
dsrID, err := uuid.Parse(c.Param("id"))
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request ID"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
err = h.dsrService.InitErasureExceptionChecks(c.Request.Context(), dsrID)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to initialize exception checks"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "Ausnahmeprüfungen wurden initialisiert"})
|
|
||||||
}
|
|
||||||
|
|
||||||
// AdminUpdateExceptionCheck updates a single exception check
|
|
||||||
func (h *DSRHandler) AdminUpdateExceptionCheck(c *gin.Context) {
|
|
||||||
if !middleware.IsAdmin(c) && !middleware.IsDSB(c) {
|
|
||||||
c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
checkID, err := uuid.Parse(c.Param("checkId"))
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid check ID"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
userID, _ := middleware.GetUserID(c)
|
|
||||||
|
|
||||||
var req struct {
|
|
||||||
Applies bool `json:"applies"`
|
|
||||||
Notes *string `json:"notes"`
|
|
||||||
}
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
err = h.dsrService.UpdateExceptionCheck(c.Request.Context(), checkID, req.Applies, req.Notes, userID)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update exception check"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "Ausnahmeprüfung wurde aktualisiert"})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// DEADLINE PROCESSING
|
|
||||||
// ========================================
|
|
||||||
|
|
||||||
// ProcessDeadlines triggers deadline checking (called by scheduler)
|
|
||||||
func (h *DSRHandler) ProcessDeadlines(c *gin.Context) {
|
|
||||||
err := h.dsrService.ProcessDeadlines(c.Request.Context())
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to process deadlines"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "Deadline processing completed"})
|
|
||||||
}
|
|
||||||
@@ -1,264 +0,0 @@
|
|||||||
package handlers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/breakpilot/consent-service/internal/middleware"
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
"github.com/google/uuid"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// TEMPLATE ENDPOINTS
|
|
||||||
// ========================================
|
|
||||||
|
|
||||||
// AdminGetDSRTemplates returns all DSR templates
|
|
||||||
func (h *DSRHandler) AdminGetDSRTemplates(c *gin.Context) {
|
|
||||||
if !middleware.IsAdmin(c) && !middleware.IsDSB(c) {
|
|
||||||
c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := c.Request.Context()
|
|
||||||
rows, err := h.dsrService.GetPool().Query(ctx, `
|
|
||||||
SELECT id, template_type, name, description, request_types, is_active, sort_order, created_at, updated_at
|
|
||||||
FROM dsr_templates ORDER BY sort_order, name
|
|
||||||
`)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch templates"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var templates []map[string]interface{}
|
|
||||||
for rows.Next() {
|
|
||||||
var id uuid.UUID
|
|
||||||
var templateType, name string
|
|
||||||
var description *string
|
|
||||||
var requestTypes []byte
|
|
||||||
var isActive bool
|
|
||||||
var sortOrder int
|
|
||||||
var createdAt, updatedAt time.Time
|
|
||||||
|
|
||||||
err := rows.Scan(&id, &templateType, &name, &description, &requestTypes, &isActive, &sortOrder, &createdAt, &updatedAt)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
templates = append(templates, map[string]interface{}{
|
|
||||||
"id": id,
|
|
||||||
"template_type": templateType,
|
|
||||||
"name": name,
|
|
||||||
"description": description,
|
|
||||||
"request_types": string(requestTypes),
|
|
||||||
"is_active": isActive,
|
|
||||||
"sort_order": sortOrder,
|
|
||||||
"created_at": createdAt,
|
|
||||||
"updated_at": updatedAt,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"templates": templates})
|
|
||||||
}
|
|
||||||
|
|
||||||
// AdminGetDSRTemplateVersions returns versions for a template
|
|
||||||
func (h *DSRHandler) AdminGetDSRTemplateVersions(c *gin.Context) {
|
|
||||||
if !middleware.IsAdmin(c) && !middleware.IsDSB(c) {
|
|
||||||
c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
templateID, err := uuid.Parse(c.Param("id"))
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid template ID"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := c.Request.Context()
|
|
||||||
rows, err := h.dsrService.GetPool().Query(ctx, `
|
|
||||||
SELECT id, template_id, version, language, subject, body_html, body_text,
|
|
||||||
status, published_at, created_by, approved_by, approved_at, created_at, updated_at
|
|
||||||
FROM dsr_template_versions WHERE template_id = $1 ORDER BY created_at DESC
|
|
||||||
`, templateID)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch versions"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var versions []map[string]interface{}
|
|
||||||
for rows.Next() {
|
|
||||||
var id, tempID uuid.UUID
|
|
||||||
var version, language, subject, bodyHTML, bodyText, status string
|
|
||||||
var publishedAt, approvedAt *time.Time
|
|
||||||
var createdBy, approvedBy *uuid.UUID
|
|
||||||
var createdAt, updatedAt time.Time
|
|
||||||
|
|
||||||
err := rows.Scan(&id, &tempID, &version, &language, &subject, &bodyHTML, &bodyText,
|
|
||||||
&status, &publishedAt, &createdBy, &approvedBy, &approvedAt, &createdAt, &updatedAt)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
versions = append(versions, map[string]interface{}{
|
|
||||||
"id": id,
|
|
||||||
"template_id": tempID,
|
|
||||||
"version": version,
|
|
||||||
"language": language,
|
|
||||||
"subject": subject,
|
|
||||||
"body_html": bodyHTML,
|
|
||||||
"body_text": bodyText,
|
|
||||||
"status": status,
|
|
||||||
"published_at": publishedAt,
|
|
||||||
"created_by": createdBy,
|
|
||||||
"approved_by": approvedBy,
|
|
||||||
"approved_at": approvedAt,
|
|
||||||
"created_at": createdAt,
|
|
||||||
"updated_at": updatedAt,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"versions": versions})
|
|
||||||
}
|
|
||||||
|
|
||||||
// AdminCreateDSRTemplateVersion creates a new template version
|
|
||||||
func (h *DSRHandler) AdminCreateDSRTemplateVersion(c *gin.Context) {
|
|
||||||
if !middleware.IsAdmin(c) && !middleware.IsDSB(c) {
|
|
||||||
c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
templateID, err := uuid.Parse(c.Param("id"))
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid template ID"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
userID, _ := middleware.GetUserID(c)
|
|
||||||
|
|
||||||
var req struct {
|
|
||||||
Version string `json:"version" binding:"required"`
|
|
||||||
Language string `json:"language"`
|
|
||||||
Subject string `json:"subject" binding:"required"`
|
|
||||||
BodyHTML string `json:"body_html" binding:"required"`
|
|
||||||
BodyText string `json:"body_text"`
|
|
||||||
}
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if req.Language == "" {
|
|
||||||
req.Language = "de"
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := c.Request.Context()
|
|
||||||
var versionID uuid.UUID
|
|
||||||
err = h.dsrService.GetPool().QueryRow(ctx, `
|
|
||||||
INSERT INTO dsr_template_versions (template_id, version, language, subject, body_html, body_text, created_by)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
|
||||||
RETURNING id
|
|
||||||
`, templateID, req.Version, req.Language, req.Subject, req.BodyHTML, req.BodyText, userID).Scan(&versionID)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create version"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusCreated, gin.H{
|
|
||||||
"message": "Version wurde erstellt",
|
|
||||||
"id": versionID,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// AdminPublishDSRTemplateVersion publishes a template version
|
|
||||||
func (h *DSRHandler) AdminPublishDSRTemplateVersion(c *gin.Context) {
|
|
||||||
if !middleware.IsAdmin(c) && !middleware.IsDSB(c) {
|
|
||||||
c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
versionID, err := uuid.Parse(c.Param("versionId"))
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid version ID"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
userID, _ := middleware.GetUserID(c)
|
|
||||||
|
|
||||||
ctx := c.Request.Context()
|
|
||||||
_, err = h.dsrService.GetPool().Exec(ctx, `
|
|
||||||
UPDATE dsr_template_versions
|
|
||||||
SET status = 'published', published_at = NOW(), approved_by = $1, approved_at = NOW(), updated_at = NOW()
|
|
||||||
WHERE id = $2
|
|
||||||
`, userID, versionID)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to publish version"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "Version wurde veröffentlicht"})
|
|
||||||
}
|
|
||||||
|
|
||||||
// AdminGetPublishedDSRTemplates returns all published templates for selection
|
|
||||||
func (h *DSRHandler) AdminGetPublishedDSRTemplates(c *gin.Context) {
|
|
||||||
if !middleware.IsAdmin(c) && !middleware.IsDSB(c) {
|
|
||||||
c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
requestType := c.Query("request_type")
|
|
||||||
language := c.DefaultQuery("language", "de")
|
|
||||||
|
|
||||||
ctx := c.Request.Context()
|
|
||||||
query := `
|
|
||||||
SELECT t.id, t.template_type, t.name, t.description,
|
|
||||||
v.id as version_id, v.version, v.subject, v.body_html, v.body_text
|
|
||||||
FROM dsr_templates t
|
|
||||||
JOIN dsr_template_versions v ON t.id = v.template_id
|
|
||||||
WHERE t.is_active = TRUE AND v.status = 'published' AND v.language = $1
|
|
||||||
`
|
|
||||||
args := []interface{}{language}
|
|
||||||
|
|
||||||
if requestType != "" {
|
|
||||||
query += ` AND t.request_types @> $2::jsonb`
|
|
||||||
args = append(args, `["`+requestType+`"]`)
|
|
||||||
}
|
|
||||||
|
|
||||||
query += " ORDER BY t.sort_order, t.name"
|
|
||||||
|
|
||||||
rows, err := h.dsrService.GetPool().Query(ctx, query, args...)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch templates"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var templates []map[string]interface{}
|
|
||||||
for rows.Next() {
|
|
||||||
var templateID, versionID uuid.UUID
|
|
||||||
var templateType, name, version, subject, bodyHTML, bodyText string
|
|
||||||
var description *string
|
|
||||||
|
|
||||||
err := rows.Scan(&templateID, &templateType, &name, &description, &versionID, &version, &subject, &bodyHTML, &bodyText)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
templates = append(templates, map[string]interface{}{
|
|
||||||
"template_id": templateID,
|
|
||||||
"template_type": templateType,
|
|
||||||
"name": name,
|
|
||||||
"description": description,
|
|
||||||
"version_id": versionID,
|
|
||||||
"version": version,
|
|
||||||
"subject": subject,
|
|
||||||
"body_html": bodyHTML,
|
|
||||||
"body_text": bodyText,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"templates": templates})
|
|
||||||
}
|
|
||||||
@@ -2,6 +2,7 @@ package handlers
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/breakpilot/consent-service/internal/models"
|
"github.com/breakpilot/consent-service/internal/models"
|
||||||
@@ -260,3 +261,268 @@ func (h *EmailTemplateHandler) RejectVersion(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "version rejected"})
|
c.JSON(http.StatusOK, gin.H{"message": "version rejected"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PublishVersion publishes an approved version
|
||||||
|
// POST /api/v1/admin/email-template-versions/:id/publish
|
||||||
|
func (h *EmailTemplateHandler) PublishVersion(c *gin.Context) {
|
||||||
|
idStr := c.Param("id")
|
||||||
|
id, err := uuid.Parse(idStr)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid version ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
role, exists := c.Get("user_role")
|
||||||
|
if !exists || (role != "data_protection_officer" && role != "admin" && role != "super_admin") {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "insufficient permissions"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userID, _ := c.Get("user_id")
|
||||||
|
uid, _ := uuid.Parse(userID.(string))
|
||||||
|
|
||||||
|
if err := h.service.PublishVersion(c.Request.Context(), id, uid); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "version published"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetApprovals returns approval history for a version
|
||||||
|
// GET /api/v1/admin/email-template-versions/:id/approvals
|
||||||
|
func (h *EmailTemplateHandler) GetApprovals(c *gin.Context) {
|
||||||
|
idStr := c.Param("id")
|
||||||
|
id, err := uuid.Parse(idStr)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid version ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
approvals, err := h.service.GetApprovals(c.Request.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"approvals": approvals})
|
||||||
|
}
|
||||||
|
|
||||||
|
// PreviewVersion renders a preview of an email template version
|
||||||
|
// POST /api/v1/admin/email-template-versions/:id/preview
|
||||||
|
func (h *EmailTemplateHandler) PreviewVersion(c *gin.Context) {
|
||||||
|
idStr := c.Param("id")
|
||||||
|
id, err := uuid.Parse(idStr)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid version ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Variables map[string]string `json:"variables"`
|
||||||
|
}
|
||||||
|
c.ShouldBindJSON(&req)
|
||||||
|
|
||||||
|
version, err := h.service.GetVersionByID(c.Request.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "version not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use default test values if not provided
|
||||||
|
if req.Variables == nil {
|
||||||
|
req.Variables = map[string]string{
|
||||||
|
"user_name": "Max Mustermann",
|
||||||
|
"user_email": "max@example.com",
|
||||||
|
"login_url": "https://breakpilot.app/login",
|
||||||
|
"support_email": "support@breakpilot.app",
|
||||||
|
"verification_url": "https://breakpilot.app/verify?token=abc123",
|
||||||
|
"verification_code": "123456",
|
||||||
|
"expires_in": "24 Stunden",
|
||||||
|
"reset_url": "https://breakpilot.app/reset?token=xyz789",
|
||||||
|
"reset_code": "RESET123",
|
||||||
|
"ip_address": "192.168.1.1",
|
||||||
|
"device_info": "Chrome auf Windows 11",
|
||||||
|
"changed_at": time.Now().Format("02.01.2006 15:04"),
|
||||||
|
"enabled_at": time.Now().Format("02.01.2006 15:04"),
|
||||||
|
"disabled_at": time.Now().Format("02.01.2006 15:04"),
|
||||||
|
"support_url": "https://breakpilot.app/support",
|
||||||
|
"security_url": "https://breakpilot.app/account/security",
|
||||||
|
"login_time": time.Now().Format("02.01.2006 15:04"),
|
||||||
|
"location": "Berlin, Deutschland",
|
||||||
|
"activity_type": "Mehrere fehlgeschlagene Login-Versuche",
|
||||||
|
"activity_time": time.Now().Format("02.01.2006 15:04"),
|
||||||
|
"locked_at": time.Now().Format("02.01.2006 15:04"),
|
||||||
|
"reason": "Zu viele fehlgeschlagene Login-Versuche",
|
||||||
|
"unlock_time": time.Now().Add(30 * time.Minute).Format("02.01.2006 15:04"),
|
||||||
|
"unlocked_at": time.Now().Format("02.01.2006 15:04"),
|
||||||
|
"requested_at": time.Now().Format("02.01.2006"),
|
||||||
|
"deletion_date": time.Now().AddDate(0, 0, 30).Format("02.01.2006"),
|
||||||
|
"cancel_url": "https://breakpilot.app/cancel-deletion?token=cancel123",
|
||||||
|
"data_info": "Benutzerdaten, Zustimmungshistorie, Audit-Logs",
|
||||||
|
"deleted_at": time.Now().Format("02.01.2006"),
|
||||||
|
"feedback_url": "https://breakpilot.app/feedback",
|
||||||
|
"download_url": "https://breakpilot.app/export/download?token=export123",
|
||||||
|
"file_size": "2.3 MB",
|
||||||
|
"old_email": "alt@example.com",
|
||||||
|
"new_email": "neu@example.com",
|
||||||
|
"document_name": "Datenschutzerklärung",
|
||||||
|
"document_type": "privacy",
|
||||||
|
"version": "2.0.0",
|
||||||
|
"consent_url": "https://breakpilot.app/consent",
|
||||||
|
"deadline": time.Now().AddDate(0, 0, 14).Format("02.01.2006"),
|
||||||
|
"days_left": "7",
|
||||||
|
"hours_left": "24 Stunden",
|
||||||
|
"consequences": "Ohne Ihre Zustimmung wird Ihr Konto suspendiert.",
|
||||||
|
"suspended_at": time.Now().Format("02.01.2006 15:04"),
|
||||||
|
"documents": "- Datenschutzerklärung v2.0.0\n- AGB v1.5.0",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
preview, err := h.service.RenderTemplate(version, req.Variables)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, preview)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendTestEmail sends a test email
|
||||||
|
// POST /api/v1/admin/email-template-versions/:id/send-test
|
||||||
|
func (h *EmailTemplateHandler) SendTestEmail(c *gin.Context) {
|
||||||
|
idStr := c.Param("id")
|
||||||
|
id, err := uuid.Parse(idStr)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid version ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req models.SendTestEmailRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.VersionID = idStr
|
||||||
|
|
||||||
|
version, err := h.service.GetVersionByID(c.Request.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "version not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get template to find type
|
||||||
|
template, err := h.service.GetTemplateByID(c.Request.Context(), version.TemplateID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "template not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userID, _ := c.Get("user_id")
|
||||||
|
uid, _ := uuid.Parse(userID.(string))
|
||||||
|
|
||||||
|
// Send test email
|
||||||
|
if err := h.service.SendEmail(c.Request.Context(), template.Type, version.Language, req.Recipient, req.Variables, &uid); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "test email sent"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSettings returns global email settings
|
||||||
|
// GET /api/v1/admin/email-templates/settings
|
||||||
|
func (h *EmailTemplateHandler) GetSettings(c *gin.Context) {
|
||||||
|
settings, err := h.service.GetSettings(c.Request.Context())
|
||||||
|
if err != nil {
|
||||||
|
// Return default settings if none exist
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"company_name": "BreakPilot",
|
||||||
|
"sender_name": "BreakPilot",
|
||||||
|
"sender_email": "noreply@breakpilot.app",
|
||||||
|
"primary_color": "#2563eb",
|
||||||
|
"secondary_color": "#64748b",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, settings)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateSettings updates global email settings
|
||||||
|
// PUT /api/v1/admin/email-templates/settings
|
||||||
|
func (h *EmailTemplateHandler) UpdateSettings(c *gin.Context) {
|
||||||
|
var req models.UpdateEmailTemplateSettingsRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userID, _ := c.Get("user_id")
|
||||||
|
uid, _ := uuid.Parse(userID.(string))
|
||||||
|
|
||||||
|
if err := h.service.UpdateSettings(c.Request.Context(), &req, uid); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "settings updated"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetEmailStats returns email statistics
|
||||||
|
// GET /api/v1/admin/email-templates/stats
|
||||||
|
func (h *EmailTemplateHandler) GetEmailStats(c *gin.Context) {
|
||||||
|
stats, err := h.service.GetEmailStats(c.Request.Context())
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, stats)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSendLogs returns email send logs
|
||||||
|
// GET /api/v1/admin/email-templates/logs
|
||||||
|
func (h *EmailTemplateHandler) GetSendLogs(c *gin.Context) {
|
||||||
|
limitStr := c.DefaultQuery("limit", "50")
|
||||||
|
offsetStr := c.DefaultQuery("offset", "0")
|
||||||
|
|
||||||
|
limit, _ := strconv.Atoi(limitStr)
|
||||||
|
offset, _ := strconv.Atoi(offsetStr)
|
||||||
|
|
||||||
|
if limit > 100 {
|
||||||
|
limit = 100
|
||||||
|
}
|
||||||
|
|
||||||
|
logs, total, err := h.service.GetSendLogs(c.Request.Context(), limit, offset)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"logs": logs, "total": total})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDefaultContent returns default template content for a type
|
||||||
|
// GET /api/v1/admin/email-templates/default/:type
|
||||||
|
func (h *EmailTemplateHandler) GetDefaultContent(c *gin.Context) {
|
||||||
|
templateType := c.Param("type")
|
||||||
|
language := c.DefaultQuery("language", "de")
|
||||||
|
|
||||||
|
subject, bodyHTML, bodyText := h.service.GetDefaultTemplateContent(templateType, language)
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"subject": subject,
|
||||||
|
"body_html": bodyHTML,
|
||||||
|
"body_text": bodyText,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// InitializeTemplates initializes default email templates
|
||||||
|
// POST /api/v1/admin/email-templates/initialize
|
||||||
|
func (h *EmailTemplateHandler) InitializeTemplates(c *gin.Context) {
|
||||||
|
role, exists := c.Get("user_role")
|
||||||
|
if !exists || (role != "admin" && role != "super_admin") {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "insufficient permissions"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.service.InitDefaultTemplates(c.Request.Context()); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "default templates initialized"})
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,276 +0,0 @@
|
|||||||
package handlers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"strconv"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/breakpilot/consent-service/internal/models"
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
"github.com/google/uuid"
|
|
||||||
)
|
|
||||||
|
|
||||||
// PublishVersion publishes an approved version
|
|
||||||
// POST /api/v1/admin/email-template-versions/:id/publish
|
|
||||||
func (h *EmailTemplateHandler) PublishVersion(c *gin.Context) {
|
|
||||||
idStr := c.Param("id")
|
|
||||||
id, err := uuid.Parse(idStr)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid version ID"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
role, exists := c.Get("user_role")
|
|
||||||
if !exists || (role != "data_protection_officer" && role != "admin" && role != "super_admin") {
|
|
||||||
c.JSON(http.StatusForbidden, gin.H{"error": "insufficient permissions"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
userID, _ := c.Get("user_id")
|
|
||||||
uid, _ := uuid.Parse(userID.(string))
|
|
||||||
|
|
||||||
if err := h.service.PublishVersion(c.Request.Context(), id, uid); err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "version published"})
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetApprovals returns approval history for a version
|
|
||||||
// GET /api/v1/admin/email-template-versions/:id/approvals
|
|
||||||
func (h *EmailTemplateHandler) GetApprovals(c *gin.Context) {
|
|
||||||
idStr := c.Param("id")
|
|
||||||
id, err := uuid.Parse(idStr)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid version ID"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
approvals, err := h.service.GetApprovals(c.Request.Context(), id)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusOK, gin.H{"approvals": approvals})
|
|
||||||
}
|
|
||||||
|
|
||||||
// PreviewVersion renders a preview of an email template version
|
|
||||||
// POST /api/v1/admin/email-template-versions/:id/preview
|
|
||||||
func (h *EmailTemplateHandler) PreviewVersion(c *gin.Context) {
|
|
||||||
idStr := c.Param("id")
|
|
||||||
id, err := uuid.Parse(idStr)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid version ID"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var req struct {
|
|
||||||
Variables map[string]string `json:"variables"`
|
|
||||||
}
|
|
||||||
c.ShouldBindJSON(&req)
|
|
||||||
|
|
||||||
version, err := h.service.GetVersionByID(c.Request.Context(), id)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "version not found"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use default test values if not provided
|
|
||||||
if req.Variables == nil {
|
|
||||||
req.Variables = map[string]string{
|
|
||||||
"user_name": "Max Mustermann",
|
|
||||||
"user_email": "max@example.com",
|
|
||||||
"login_url": "https://breakpilot.app/login",
|
|
||||||
"support_email": "support@breakpilot.app",
|
|
||||||
"verification_url": "https://breakpilot.app/verify?token=abc123",
|
|
||||||
"verification_code": "123456",
|
|
||||||
"expires_in": "24 Stunden",
|
|
||||||
"reset_url": "https://breakpilot.app/reset?token=xyz789",
|
|
||||||
"reset_code": "RESET123",
|
|
||||||
"ip_address": "192.168.1.1",
|
|
||||||
"device_info": "Chrome auf Windows 11",
|
|
||||||
"changed_at": time.Now().Format("02.01.2006 15:04"),
|
|
||||||
"enabled_at": time.Now().Format("02.01.2006 15:04"),
|
|
||||||
"disabled_at": time.Now().Format("02.01.2006 15:04"),
|
|
||||||
"support_url": "https://breakpilot.app/support",
|
|
||||||
"security_url": "https://breakpilot.app/account/security",
|
|
||||||
"login_time": time.Now().Format("02.01.2006 15:04"),
|
|
||||||
"location": "Berlin, Deutschland",
|
|
||||||
"activity_type": "Mehrere fehlgeschlagene Login-Versuche",
|
|
||||||
"activity_time": time.Now().Format("02.01.2006 15:04"),
|
|
||||||
"locked_at": time.Now().Format("02.01.2006 15:04"),
|
|
||||||
"reason": "Zu viele fehlgeschlagene Login-Versuche",
|
|
||||||
"unlock_time": time.Now().Add(30 * time.Minute).Format("02.01.2006 15:04"),
|
|
||||||
"unlocked_at": time.Now().Format("02.01.2006 15:04"),
|
|
||||||
"requested_at": time.Now().Format("02.01.2006"),
|
|
||||||
"deletion_date": time.Now().AddDate(0, 0, 30).Format("02.01.2006"),
|
|
||||||
"cancel_url": "https://breakpilot.app/cancel-deletion?token=cancel123",
|
|
||||||
"data_info": "Benutzerdaten, Zustimmungshistorie, Audit-Logs",
|
|
||||||
"deleted_at": time.Now().Format("02.01.2006"),
|
|
||||||
"feedback_url": "https://breakpilot.app/feedback",
|
|
||||||
"download_url": "https://breakpilot.app/export/download?token=export123",
|
|
||||||
"file_size": "2.3 MB",
|
|
||||||
"old_email": "alt@example.com",
|
|
||||||
"new_email": "neu@example.com",
|
|
||||||
"document_name": "Datenschutzerklärung",
|
|
||||||
"document_type": "privacy",
|
|
||||||
"version": "2.0.0",
|
|
||||||
"consent_url": "https://breakpilot.app/consent",
|
|
||||||
"deadline": time.Now().AddDate(0, 0, 14).Format("02.01.2006"),
|
|
||||||
"days_left": "7",
|
|
||||||
"hours_left": "24 Stunden",
|
|
||||||
"consequences": "Ohne Ihre Zustimmung wird Ihr Konto suspendiert.",
|
|
||||||
"suspended_at": time.Now().Format("02.01.2006 15:04"),
|
|
||||||
"documents": "- Datenschutzerklärung v2.0.0\n- AGB v1.5.0",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
preview, err := h.service.RenderTemplate(version, req.Variables)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusOK, preview)
|
|
||||||
}
|
|
||||||
|
|
||||||
// SendTestEmail sends a test email
|
|
||||||
// POST /api/v1/admin/email-template-versions/:id/send-test
|
|
||||||
func (h *EmailTemplateHandler) SendTestEmail(c *gin.Context) {
|
|
||||||
idStr := c.Param("id")
|
|
||||||
id, err := uuid.Parse(idStr)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid version ID"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var req models.SendTestEmailRequest
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
req.VersionID = idStr
|
|
||||||
|
|
||||||
version, err := h.service.GetVersionByID(c.Request.Context(), id)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "version not found"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get template to find type
|
|
||||||
template, err := h.service.GetTemplateByID(c.Request.Context(), version.TemplateID)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "template not found"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
userID, _ := c.Get("user_id")
|
|
||||||
uid, _ := uuid.Parse(userID.(string))
|
|
||||||
|
|
||||||
// Send test email
|
|
||||||
if err := h.service.SendEmail(c.Request.Context(), template.Type, version.Language, req.Recipient, req.Variables, &uid); err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "test email sent"})
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetSettings returns global email settings
|
|
||||||
// GET /api/v1/admin/email-templates/settings
|
|
||||||
func (h *EmailTemplateHandler) GetSettings(c *gin.Context) {
|
|
||||||
settings, err := h.service.GetSettings(c.Request.Context())
|
|
||||||
if err != nil {
|
|
||||||
// Return default settings if none exist
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
|
||||||
"company_name": "BreakPilot",
|
|
||||||
"sender_name": "BreakPilot",
|
|
||||||
"sender_email": "noreply@breakpilot.app",
|
|
||||||
"primary_color": "#2563eb",
|
|
||||||
"secondary_color": "#64748b",
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusOK, settings)
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpdateSettings updates global email settings
|
|
||||||
// PUT /api/v1/admin/email-templates/settings
|
|
||||||
func (h *EmailTemplateHandler) UpdateSettings(c *gin.Context) {
|
|
||||||
var req models.UpdateEmailTemplateSettingsRequest
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
userID, _ := c.Get("user_id")
|
|
||||||
uid, _ := uuid.Parse(userID.(string))
|
|
||||||
|
|
||||||
if err := h.service.UpdateSettings(c.Request.Context(), &req, uid); err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "settings updated"})
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetEmailStats returns email statistics
|
|
||||||
// GET /api/v1/admin/email-templates/stats
|
|
||||||
func (h *EmailTemplateHandler) GetEmailStats(c *gin.Context) {
|
|
||||||
stats, err := h.service.GetEmailStats(c.Request.Context())
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusOK, stats)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetSendLogs returns email send logs
|
|
||||||
// GET /api/v1/admin/email-templates/logs
|
|
||||||
func (h *EmailTemplateHandler) GetSendLogs(c *gin.Context) {
|
|
||||||
limitStr := c.DefaultQuery("limit", "50")
|
|
||||||
offsetStr := c.DefaultQuery("offset", "0")
|
|
||||||
|
|
||||||
limit, _ := strconv.Atoi(limitStr)
|
|
||||||
offset, _ := strconv.Atoi(offsetStr)
|
|
||||||
|
|
||||||
if limit > 100 {
|
|
||||||
limit = 100
|
|
||||||
}
|
|
||||||
|
|
||||||
logs, total, err := h.service.GetSendLogs(c.Request.Context(), limit, offset)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusOK, gin.H{"logs": logs, "total": total})
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetDefaultContent returns default template content for a type
|
|
||||||
// GET /api/v1/admin/email-templates/default/:type
|
|
||||||
func (h *EmailTemplateHandler) GetDefaultContent(c *gin.Context) {
|
|
||||||
templateType := c.Param("type")
|
|
||||||
language := c.DefaultQuery("language", "de")
|
|
||||||
|
|
||||||
subject, bodyHTML, bodyText := h.service.GetDefaultTemplateContent(templateType, language)
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
|
||||||
"subject": subject,
|
|
||||||
"body_html": bodyHTML,
|
|
||||||
"body_text": bodyText,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// InitializeTemplates initializes default email templates
|
|
||||||
// POST /api/v1/admin/email-templates/initialize
|
|
||||||
func (h *EmailTemplateHandler) InitializeTemplates(c *gin.Context) {
|
|
||||||
role, exists := c.Get("user_role")
|
|
||||||
if !exists || (role != "admin" && role != "super_admin") {
|
|
||||||
c.JSON(http.StatusForbidden, gin.H{"error": "insufficient permissions"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := h.service.InitDefaultTemplates(c.Request.Context()); err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "default templates initialized"})
|
|
||||||
}
|
|
||||||
@@ -1,168 +0,0 @@
|
|||||||
package handlers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"net/http"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/breakpilot/consent-service/internal/middleware"
|
|
||||||
"github.com/breakpilot/consent-service/internal/models"
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
"github.com/google/uuid"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// GDPR / DATA SUBJECT RIGHTS
|
|
||||||
// ========================================
|
|
||||||
|
|
||||||
// GetMyData returns all data we have about the user
|
|
||||||
func (h *Handler) GetMyData(c *gin.Context) {
|
|
||||||
userID, err := middleware.GetUserID(c)
|
|
||||||
if err != nil || userID == uuid.Nil {
|
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
ipAddress := middleware.GetClientIP(c)
|
|
||||||
userAgent := middleware.GetUserAgent(c)
|
|
||||||
|
|
||||||
// Get user info
|
|
||||||
var user models.User
|
|
||||||
err = h.db.Pool.QueryRow(ctx, `
|
|
||||||
SELECT id, external_id, email, role, created_at, updated_at
|
|
||||||
FROM users WHERE id = $1
|
|
||||||
`, userID).Scan(&user.ID, &user.ExternalID, &user.Email, &user.Role, &user.CreatedAt, &user.UpdatedAt)
|
|
||||||
|
|
||||||
// Get consents
|
|
||||||
consentRows, _ := h.db.Pool.Query(ctx, `
|
|
||||||
SELECT uc.consented, uc.consented_at, ld.type, ld.name, dv.version
|
|
||||||
FROM user_consents uc
|
|
||||||
JOIN document_versions dv ON uc.document_version_id = dv.id
|
|
||||||
JOIN legal_documents ld ON dv.document_id = ld.id
|
|
||||||
WHERE uc.user_id = $1
|
|
||||||
`, userID)
|
|
||||||
defer consentRows.Close()
|
|
||||||
|
|
||||||
var consents []map[string]interface{}
|
|
||||||
for consentRows.Next() {
|
|
||||||
var consented bool
|
|
||||||
var consentedAt time.Time
|
|
||||||
var docType, docName, version string
|
|
||||||
consentRows.Scan(&consented, &consentedAt, &docType, &docName, &version)
|
|
||||||
consents = append(consents, map[string]interface{}{
|
|
||||||
"document_type": docType,
|
|
||||||
"document_name": docName,
|
|
||||||
"version": version,
|
|
||||||
"consented": consented,
|
|
||||||
"consented_at": consentedAt,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get cookie consents
|
|
||||||
cookieRows, _ := h.db.Pool.Query(ctx, `
|
|
||||||
SELECT cat.name, cc.consented, cc.updated_at
|
|
||||||
FROM cookie_consents cc
|
|
||||||
JOIN cookie_categories cat ON cc.category_id = cat.id
|
|
||||||
WHERE cc.user_id = $1
|
|
||||||
`, userID)
|
|
||||||
defer cookieRows.Close()
|
|
||||||
|
|
||||||
var cookieConsents []map[string]interface{}
|
|
||||||
for cookieRows.Next() {
|
|
||||||
var name string
|
|
||||||
var consented bool
|
|
||||||
var updatedAt time.Time
|
|
||||||
cookieRows.Scan(&name, &consented, &updatedAt)
|
|
||||||
cookieConsents = append(cookieConsents, map[string]interface{}{
|
|
||||||
"category": name,
|
|
||||||
"consented": consented,
|
|
||||||
"updated_at": updatedAt,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Log data access
|
|
||||||
h.logAudit(ctx, &userID, "data_access", "user", &userID, nil, ipAddress, userAgent)
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
|
||||||
"user": map[string]interface{}{
|
|
||||||
"id": user.ID,
|
|
||||||
"email": user.Email,
|
|
||||||
"created_at": user.CreatedAt,
|
|
||||||
},
|
|
||||||
"consents": consents,
|
|
||||||
"cookie_consents": cookieConsents,
|
|
||||||
"exported_at": time.Now(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// RequestDataExport creates a data export request
|
|
||||||
func (h *Handler) RequestDataExport(c *gin.Context) {
|
|
||||||
userID, err := middleware.GetUserID(c)
|
|
||||||
if err != nil || userID == uuid.Nil {
|
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
ipAddress := middleware.GetClientIP(c)
|
|
||||||
userAgent := middleware.GetUserAgent(c)
|
|
||||||
|
|
||||||
var requestID uuid.UUID
|
|
||||||
err = h.db.Pool.QueryRow(ctx, `
|
|
||||||
INSERT INTO data_export_requests (user_id, status)
|
|
||||||
VALUES ($1, 'pending')
|
|
||||||
RETURNING id
|
|
||||||
`, userID).Scan(&requestID)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create export request"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Log to audit trail
|
|
||||||
h.logAudit(ctx, &userID, "data_export_requested", "export_request", &requestID, nil, ipAddress, userAgent)
|
|
||||||
|
|
||||||
c.JSON(http.StatusAccepted, gin.H{
|
|
||||||
"message": "Export request created. You will be notified when ready.",
|
|
||||||
"request_id": requestID,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// RequestDataDeletion creates a data deletion request
|
|
||||||
func (h *Handler) RequestDataDeletion(c *gin.Context) {
|
|
||||||
userID, err := middleware.GetUserID(c)
|
|
||||||
if err != nil || userID == uuid.Nil {
|
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var req struct {
|
|
||||||
Reason string `json:"reason"`
|
|
||||||
}
|
|
||||||
c.ShouldBindJSON(&req)
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
ipAddress := middleware.GetClientIP(c)
|
|
||||||
userAgent := middleware.GetUserAgent(c)
|
|
||||||
|
|
||||||
var requestID uuid.UUID
|
|
||||||
err = h.db.Pool.QueryRow(ctx, `
|
|
||||||
INSERT INTO data_deletion_requests (user_id, status, reason)
|
|
||||||
VALUES ($1, 'pending', $2)
|
|
||||||
RETURNING id
|
|
||||||
`, userID, req.Reason).Scan(&requestID)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create deletion request"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Log to audit trail
|
|
||||||
h.logAudit(ctx, &userID, "data_deletion_requested", "deletion_request", &requestID, nil, ipAddress, userAgent)
|
|
||||||
|
|
||||||
c.JSON(http.StatusAccepted, gin.H{
|
|
||||||
"message": "Deletion request created. We will process your request within 30 days.",
|
|
||||||
"request_id": requestID,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,372 +0,0 @@
|
|||||||
package handlers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"github.com/breakpilot/consent-service/internal/middleware"
|
|
||||||
"github.com/breakpilot/consent-service/internal/models"
|
|
||||||
"github.com/breakpilot/consent-service/internal/services"
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
"github.com/google/uuid"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// 2FA (TOTP) Endpoints
|
|
||||||
// ========================================
|
|
||||||
|
|
||||||
// Setup2FA initiates 2FA setup
|
|
||||||
// POST /auth/2fa/setup
|
|
||||||
func (h *OAuthHandler) Setup2FA(c *gin.Context) {
|
|
||||||
userID, err := middleware.GetUserID(c)
|
|
||||||
if err != nil || userID == uuid.Nil {
|
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get user email
|
|
||||||
ctx := context.Background()
|
|
||||||
user, err := h.authService.GetUserByID(ctx, userID)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get user"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Setup 2FA
|
|
||||||
response, err := h.totpService.Setup2FA(ctx, userID, user.Email)
|
|
||||||
if err != nil {
|
|
||||||
switch err {
|
|
||||||
case services.ErrTOTPAlreadyEnabled:
|
|
||||||
c.JSON(http.StatusConflict, gin.H{"error": "2FA is already enabled for this account"})
|
|
||||||
default:
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to setup 2FA"})
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, response)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify2FASetup verifies the 2FA setup with a code
|
|
||||||
// POST /auth/2fa/verify-setup
|
|
||||||
func (h *OAuthHandler) Verify2FASetup(c *gin.Context) {
|
|
||||||
userID, err := middleware.GetUserID(c)
|
|
||||||
if err != nil || userID == uuid.Nil {
|
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var req models.Verify2FARequest
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
err = h.totpService.Verify2FASetup(ctx, userID, req.Code)
|
|
||||||
if err != nil {
|
|
||||||
switch err {
|
|
||||||
case services.ErrTOTPAlreadyEnabled:
|
|
||||||
c.JSON(http.StatusConflict, gin.H{"error": "2FA is already enabled"})
|
|
||||||
case services.ErrTOTPInvalidCode:
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid 2FA code"})
|
|
||||||
default:
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to verify 2FA setup"})
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "2FA enabled successfully"})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify2FAChallenge verifies a 2FA challenge during login
|
|
||||||
// POST /auth/2fa/verify
|
|
||||||
func (h *OAuthHandler) Verify2FAChallenge(c *gin.Context) {
|
|
||||||
var req models.Verify2FAChallengeRequest
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
var userID *uuid.UUID
|
|
||||||
var err error
|
|
||||||
|
|
||||||
if req.RecoveryCode != "" {
|
|
||||||
// Verify with recovery code
|
|
||||||
userID, err = h.totpService.VerifyChallengeWithRecoveryCode(ctx, req.ChallengeID, req.RecoveryCode)
|
|
||||||
} else {
|
|
||||||
// Verify with TOTP code
|
|
||||||
userID, err = h.totpService.VerifyChallenge(ctx, req.ChallengeID, req.Code)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
switch err {
|
|
||||||
case services.ErrTOTPChallengeExpired:
|
|
||||||
c.JSON(http.StatusGone, gin.H{"error": "2FA challenge has expired"})
|
|
||||||
case services.ErrTOTPInvalidCode:
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid 2FA code"})
|
|
||||||
case services.ErrRecoveryCodeInvalid:
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid recovery code"})
|
|
||||||
default:
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "2FA verification failed"})
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get user and generate tokens
|
|
||||||
user, err := h.authService.GetUserByID(ctx, *userID)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get user"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate access token
|
|
||||||
accessToken, err := h.authService.GenerateAccessToken(user)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate token"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate refresh token
|
|
||||||
refreshToken, refreshTokenHash, err := h.authService.GenerateRefreshToken()
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate refresh token"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store session
|
|
||||||
ipAddress := middleware.GetClientIP(c)
|
|
||||||
userAgent := middleware.GetUserAgent(c)
|
|
||||||
|
|
||||||
// We need direct DB access for this, or we need to add a method to AuthService
|
|
||||||
// For now, we'll return the tokens and let the caller handle session storage
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
|
||||||
"access_token": accessToken,
|
|
||||||
"refresh_token": refreshToken,
|
|
||||||
"token_type": "Bearer",
|
|
||||||
"expires_in": 3600,
|
|
||||||
"user": map[string]interface{}{
|
|
||||||
"id": user.ID,
|
|
||||||
"email": user.Email,
|
|
||||||
"name": user.Name,
|
|
||||||
"role": user.Role,
|
|
||||||
},
|
|
||||||
"_session_hash": refreshTokenHash,
|
|
||||||
"_ip": ipAddress,
|
|
||||||
"_user_agent": userAgent,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Disable2FA disables 2FA for the current user
|
|
||||||
// POST /auth/2fa/disable
|
|
||||||
func (h *OAuthHandler) Disable2FA(c *gin.Context) {
|
|
||||||
userID, err := middleware.GetUserID(c)
|
|
||||||
if err != nil || userID == uuid.Nil {
|
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var req models.Verify2FARequest
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
err = h.totpService.Disable2FA(ctx, userID, req.Code)
|
|
||||||
if err != nil {
|
|
||||||
switch err {
|
|
||||||
case services.ErrTOTPNotEnabled:
|
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "2FA is not enabled"})
|
|
||||||
case services.ErrTOTPInvalidCode:
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid 2FA code"})
|
|
||||||
default:
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to disable 2FA"})
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "2FA disabled successfully"})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get2FAStatus returns the 2FA status for the current user
|
|
||||||
// GET /auth/2fa/status
|
|
||||||
func (h *OAuthHandler) Get2FAStatus(c *gin.Context) {
|
|
||||||
userID, err := middleware.GetUserID(c)
|
|
||||||
if err != nil || userID == uuid.Nil {
|
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
status, err := h.totpService.GetStatus(ctx, userID)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get 2FA status"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, status)
|
|
||||||
}
|
|
||||||
|
|
||||||
// RegenerateRecoveryCodes generates new recovery codes
|
|
||||||
// POST /auth/2fa/recovery-codes
|
|
||||||
func (h *OAuthHandler) RegenerateRecoveryCodes(c *gin.Context) {
|
|
||||||
userID, err := middleware.GetUserID(c)
|
|
||||||
if err != nil || userID == uuid.Nil {
|
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var req models.Verify2FARequest
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
codes, err := h.totpService.RegenerateRecoveryCodes(ctx, userID, req.Code)
|
|
||||||
if err != nil {
|
|
||||||
switch err {
|
|
||||||
case services.ErrTOTPNotEnabled:
|
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "2FA is not enabled"})
|
|
||||||
case services.ErrTOTPInvalidCode:
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid 2FA code"})
|
|
||||||
default:
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to regenerate recovery codes"})
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"recovery_codes": codes})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// Enhanced Login with 2FA
|
|
||||||
// ========================================
|
|
||||||
|
|
||||||
// LoginWith2FA handles login with optional 2FA
|
|
||||||
// POST /auth/login
|
|
||||||
func (h *OAuthHandler) LoginWith2FA(c *gin.Context) {
|
|
||||||
var req models.LoginRequest
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
ipAddress := middleware.GetClientIP(c)
|
|
||||||
userAgent := middleware.GetUserAgent(c)
|
|
||||||
|
|
||||||
// Attempt login
|
|
||||||
response, err := h.authService.Login(ctx, &req, ipAddress, userAgent)
|
|
||||||
if err != nil {
|
|
||||||
switch err {
|
|
||||||
case services.ErrInvalidCredentials:
|
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid email or password"})
|
|
||||||
case services.ErrAccountLocked:
|
|
||||||
c.JSON(http.StatusForbidden, gin.H{"error": "Account is temporarily locked"})
|
|
||||||
case services.ErrAccountSuspended:
|
|
||||||
c.JSON(http.StatusForbidden, gin.H{"error": "Account is suspended"})
|
|
||||||
default:
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Login failed"})
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if 2FA is enabled
|
|
||||||
twoFactorEnabled, _ := h.totpService.IsTwoFactorEnabled(ctx, response.User.ID)
|
|
||||||
|
|
||||||
if twoFactorEnabled {
|
|
||||||
// Create 2FA challenge
|
|
||||||
challengeID, err := h.totpService.CreateChallenge(ctx, response.User.ID, ipAddress, userAgent)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create 2FA challenge"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return 2FA required response
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
|
||||||
"requires_2fa": true,
|
|
||||||
"challenge_id": challengeID,
|
|
||||||
"message": "2FA verification required",
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// No 2FA required, return tokens
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
|
||||||
"requires_2fa": false,
|
|
||||||
"access_token": response.AccessToken,
|
|
||||||
"refresh_token": response.RefreshToken,
|
|
||||||
"token_type": "Bearer",
|
|
||||||
"expires_in": response.ExpiresIn,
|
|
||||||
"user": map[string]interface{}{
|
|
||||||
"id": response.User.ID,
|
|
||||||
"email": response.User.Email,
|
|
||||||
"name": response.User.Name,
|
|
||||||
"role": response.User.Role,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// Registration with mandatory 2FA setup
|
|
||||||
// ========================================
|
|
||||||
|
|
||||||
// RegisterWith2FA handles registration with mandatory 2FA setup
|
|
||||||
// POST /auth/register
|
|
||||||
func (h *OAuthHandler) RegisterWith2FA(c *gin.Context) {
|
|
||||||
var req models.RegisterRequest
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
// Validate password strength
|
|
||||||
if len(req.Password) < 8 {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Password must be at least 8 characters"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Register user
|
|
||||||
user, verificationToken, err := h.authService.Register(ctx, &req)
|
|
||||||
if err != nil {
|
|
||||||
switch err {
|
|
||||||
case services.ErrUserExists:
|
|
||||||
c.JSON(http.StatusConflict, gin.H{"error": "A user with this email already exists"})
|
|
||||||
default:
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Registration failed"})
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Setup 2FA immediately
|
|
||||||
twoFAResponse, err := h.totpService.Setup2FA(ctx, user.ID, user.Email)
|
|
||||||
if err != nil {
|
|
||||||
// Non-fatal - user can set up 2FA later, but log it
|
|
||||||
c.JSON(http.StatusCreated, gin.H{
|
|
||||||
"message": "Registration successful. Please verify your email.",
|
|
||||||
"user_id": user.ID,
|
|
||||||
"verification_token": verificationToken, // In production, this would be sent via email
|
|
||||||
"two_factor_setup": nil,
|
|
||||||
"two_factor_error": "Failed to initialize 2FA. Please set it up in your account settings.",
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusCreated, gin.H{
|
|
||||||
"message": "Registration successful. Please verify your email and complete 2FA setup.",
|
|
||||||
"user_id": user.ID,
|
|
||||||
"verification_token": verificationToken, // In production, this would be sent via email
|
|
||||||
"two_factor_setup": map[string]interface{}{
|
|
||||||
"secret": twoFAResponse.Secret,
|
|
||||||
"qr_code": twoFAResponse.QRCodeDataURL,
|
|
||||||
"recovery_codes": twoFAResponse.RecoveryCodes,
|
|
||||||
"setup_required": true,
|
|
||||||
"setup_endpoint": "/auth/2fa/verify-setup",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/breakpilot/consent-service/internal/middleware"
|
"github.com/breakpilot/consent-service/internal/middleware"
|
||||||
|
"github.com/breakpilot/consent-service/internal/models"
|
||||||
"github.com/breakpilot/consent-service/internal/services"
|
"github.com/breakpilot/consent-service/internal/services"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
@@ -291,6 +292,366 @@ func (h *OAuthHandler) Introspect(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 2FA (TOTP) Endpoints
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
// Setup2FA initiates 2FA setup
|
||||||
|
// POST /auth/2fa/setup
|
||||||
|
func (h *OAuthHandler) Setup2FA(c *gin.Context) {
|
||||||
|
userID, err := middleware.GetUserID(c)
|
||||||
|
if err != nil || userID == uuid.Nil {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get user email
|
||||||
|
ctx := context.Background()
|
||||||
|
user, err := h.authService.GetUserByID(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get user"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup 2FA
|
||||||
|
response, err := h.totpService.Setup2FA(ctx, userID, user.Email)
|
||||||
|
if err != nil {
|
||||||
|
switch err {
|
||||||
|
case services.ErrTOTPAlreadyEnabled:
|
||||||
|
c.JSON(http.StatusConflict, gin.H{"error": "2FA is already enabled for this account"})
|
||||||
|
default:
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to setup 2FA"})
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, response)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify2FASetup verifies the 2FA setup with a code
|
||||||
|
// POST /auth/2fa/verify-setup
|
||||||
|
func (h *OAuthHandler) Verify2FASetup(c *gin.Context) {
|
||||||
|
userID, err := middleware.GetUserID(c)
|
||||||
|
if err != nil || userID == uuid.Nil {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req models.Verify2FARequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
err = h.totpService.Verify2FASetup(ctx, userID, req.Code)
|
||||||
|
if err != nil {
|
||||||
|
switch err {
|
||||||
|
case services.ErrTOTPAlreadyEnabled:
|
||||||
|
c.JSON(http.StatusConflict, gin.H{"error": "2FA is already enabled"})
|
||||||
|
case services.ErrTOTPInvalidCode:
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid 2FA code"})
|
||||||
|
default:
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to verify 2FA setup"})
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "2FA enabled successfully"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify2FAChallenge verifies a 2FA challenge during login
|
||||||
|
// POST /auth/2fa/verify
|
||||||
|
func (h *OAuthHandler) Verify2FAChallenge(c *gin.Context) {
|
||||||
|
var req models.Verify2FAChallengeRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
var userID *uuid.UUID
|
||||||
|
var err error
|
||||||
|
|
||||||
|
if req.RecoveryCode != "" {
|
||||||
|
// Verify with recovery code
|
||||||
|
userID, err = h.totpService.VerifyChallengeWithRecoveryCode(ctx, req.ChallengeID, req.RecoveryCode)
|
||||||
|
} else {
|
||||||
|
// Verify with TOTP code
|
||||||
|
userID, err = h.totpService.VerifyChallenge(ctx, req.ChallengeID, req.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
switch err {
|
||||||
|
case services.ErrTOTPChallengeExpired:
|
||||||
|
c.JSON(http.StatusGone, gin.H{"error": "2FA challenge has expired"})
|
||||||
|
case services.ErrTOTPInvalidCode:
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid 2FA code"})
|
||||||
|
case services.ErrRecoveryCodeInvalid:
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid recovery code"})
|
||||||
|
default:
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "2FA verification failed"})
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get user and generate tokens
|
||||||
|
user, err := h.authService.GetUserByID(ctx, *userID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get user"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate access token
|
||||||
|
accessToken, err := h.authService.GenerateAccessToken(user)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate token"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate refresh token
|
||||||
|
refreshToken, refreshTokenHash, err := h.authService.GenerateRefreshToken()
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate refresh token"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store session
|
||||||
|
ipAddress := middleware.GetClientIP(c)
|
||||||
|
userAgent := middleware.GetUserAgent(c)
|
||||||
|
|
||||||
|
// We need direct DB access for this, or we need to add a method to AuthService
|
||||||
|
// For now, we'll return the tokens and let the caller handle session storage
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"access_token": accessToken,
|
||||||
|
"refresh_token": refreshToken,
|
||||||
|
"token_type": "Bearer",
|
||||||
|
"expires_in": 3600,
|
||||||
|
"user": map[string]interface{}{
|
||||||
|
"id": user.ID,
|
||||||
|
"email": user.Email,
|
||||||
|
"name": user.Name,
|
||||||
|
"role": user.Role,
|
||||||
|
},
|
||||||
|
"_session_hash": refreshTokenHash,
|
||||||
|
"_ip": ipAddress,
|
||||||
|
"_user_agent": userAgent,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disable2FA disables 2FA for the current user
|
||||||
|
// POST /auth/2fa/disable
|
||||||
|
func (h *OAuthHandler) Disable2FA(c *gin.Context) {
|
||||||
|
userID, err := middleware.GetUserID(c)
|
||||||
|
if err != nil || userID == uuid.Nil {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req models.Verify2FARequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
err = h.totpService.Disable2FA(ctx, userID, req.Code)
|
||||||
|
if err != nil {
|
||||||
|
switch err {
|
||||||
|
case services.ErrTOTPNotEnabled:
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "2FA is not enabled"})
|
||||||
|
case services.ErrTOTPInvalidCode:
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid 2FA code"})
|
||||||
|
default:
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to disable 2FA"})
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "2FA disabled successfully"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get2FAStatus returns the 2FA status for the current user
|
||||||
|
// GET /auth/2fa/status
|
||||||
|
func (h *OAuthHandler) Get2FAStatus(c *gin.Context) {
|
||||||
|
userID, err := middleware.GetUserID(c)
|
||||||
|
if err != nil || userID == uuid.Nil {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
status, err := h.totpService.GetStatus(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get 2FA status"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegenerateRecoveryCodes generates new recovery codes
|
||||||
|
// POST /auth/2fa/recovery-codes
|
||||||
|
func (h *OAuthHandler) RegenerateRecoveryCodes(c *gin.Context) {
|
||||||
|
userID, err := middleware.GetUserID(c)
|
||||||
|
if err != nil || userID == uuid.Nil {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req models.Verify2FARequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
codes, err := h.totpService.RegenerateRecoveryCodes(ctx, userID, req.Code)
|
||||||
|
if err != nil {
|
||||||
|
switch err {
|
||||||
|
case services.ErrTOTPNotEnabled:
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "2FA is not enabled"})
|
||||||
|
case services.ErrTOTPInvalidCode:
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid 2FA code"})
|
||||||
|
default:
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to regenerate recovery codes"})
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"recovery_codes": codes})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// Enhanced Login with 2FA
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
// LoginWith2FA handles login with optional 2FA
|
||||||
|
// POST /auth/login
|
||||||
|
func (h *OAuthHandler) LoginWith2FA(c *gin.Context) {
|
||||||
|
var req models.LoginRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
ipAddress := middleware.GetClientIP(c)
|
||||||
|
userAgent := middleware.GetUserAgent(c)
|
||||||
|
|
||||||
|
// Attempt login
|
||||||
|
response, err := h.authService.Login(ctx, &req, ipAddress, userAgent)
|
||||||
|
if err != nil {
|
||||||
|
switch err {
|
||||||
|
case services.ErrInvalidCredentials:
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid email or password"})
|
||||||
|
case services.ErrAccountLocked:
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "Account is temporarily locked"})
|
||||||
|
case services.ErrAccountSuspended:
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "Account is suspended"})
|
||||||
|
default:
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Login failed"})
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if 2FA is enabled
|
||||||
|
twoFactorEnabled, _ := h.totpService.IsTwoFactorEnabled(ctx, response.User.ID)
|
||||||
|
|
||||||
|
if twoFactorEnabled {
|
||||||
|
// Create 2FA challenge
|
||||||
|
challengeID, err := h.totpService.CreateChallenge(ctx, response.User.ID, ipAddress, userAgent)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create 2FA challenge"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return 2FA required response
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"requires_2fa": true,
|
||||||
|
"challenge_id": challengeID,
|
||||||
|
"message": "2FA verification required",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// No 2FA required, return tokens
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"requires_2fa": false,
|
||||||
|
"access_token": response.AccessToken,
|
||||||
|
"refresh_token": response.RefreshToken,
|
||||||
|
"token_type": "Bearer",
|
||||||
|
"expires_in": response.ExpiresIn,
|
||||||
|
"user": map[string]interface{}{
|
||||||
|
"id": response.User.ID,
|
||||||
|
"email": response.User.Email,
|
||||||
|
"name": response.User.Name,
|
||||||
|
"role": response.User.Role,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// Registration with mandatory 2FA setup
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
// RegisterWith2FA handles registration with mandatory 2FA setup
|
||||||
|
// POST /auth/register
|
||||||
|
func (h *OAuthHandler) RegisterWith2FA(c *gin.Context) {
|
||||||
|
var req models.RegisterRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Validate password strength
|
||||||
|
if len(req.Password) < 8 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Password must be at least 8 characters"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register user
|
||||||
|
user, verificationToken, err := h.authService.Register(ctx, &req)
|
||||||
|
if err != nil {
|
||||||
|
switch err {
|
||||||
|
case services.ErrUserExists:
|
||||||
|
c.JSON(http.StatusConflict, gin.H{"error": "A user with this email already exists"})
|
||||||
|
default:
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Registration failed"})
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup 2FA immediately
|
||||||
|
twoFAResponse, err := h.totpService.Setup2FA(ctx, user.ID, user.Email)
|
||||||
|
if err != nil {
|
||||||
|
// Non-fatal - user can set up 2FA later, but log it
|
||||||
|
c.JSON(http.StatusCreated, gin.H{
|
||||||
|
"message": "Registration successful. Please verify your email.",
|
||||||
|
"user_id": user.ID,
|
||||||
|
"verification_token": verificationToken, // In production, this would be sent via email
|
||||||
|
"two_factor_setup": nil,
|
||||||
|
"two_factor_error": "Failed to initialize 2FA. Please set it up in your account settings.",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusCreated, gin.H{
|
||||||
|
"message": "Registration successful. Please verify your email and complete 2FA setup.",
|
||||||
|
"user_id": user.ID,
|
||||||
|
"verification_token": verificationToken, // In production, this would be sent via email
|
||||||
|
"two_factor_setup": map[string]interface{}{
|
||||||
|
"secret": twoFAResponse.Secret,
|
||||||
|
"qr_code": twoFAResponse.QRCodeDataURL,
|
||||||
|
"recovery_codes": twoFAResponse.RecoveryCodes,
|
||||||
|
"setup_required": true,
|
||||||
|
"setup_endpoint": "/auth/2fa/verify-setup",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// OAuth Client Management (Admin)
|
// OAuth Client Management (Admin)
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|||||||
@@ -1,243 +0,0 @@
|
|||||||
package handlers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/breakpilot/consent-service/internal/models"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
"github.com/google/uuid"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// Attendance Handlers
|
|
||||||
// ========================================
|
|
||||||
|
|
||||||
// RecordAttendance records attendance for a student
|
|
||||||
// POST /api/v1/attendance
|
|
||||||
func (h *SchoolHandlers) RecordAttendance(c *gin.Context) {
|
|
||||||
userID, exists := c.Get("user_id")
|
|
||||||
if !exists {
|
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "user not authenticated"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var req models.RecordAttendanceRequest
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
record, err := h.attendanceService.RecordAttendance(c.Request.Context(), req, userID.(uuid.UUID))
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusCreated, record)
|
|
||||||
}
|
|
||||||
|
|
||||||
// RecordBulkAttendance records attendance for multiple students
|
|
||||||
// POST /api/v1/classes/:id/attendance
|
|
||||||
func (h *SchoolHandlers) RecordBulkAttendance(c *gin.Context) {
|
|
||||||
userID, exists := c.Get("user_id")
|
|
||||||
if !exists {
|
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "user not authenticated"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
classIDStr := c.Param("id")
|
|
||||||
classID, err := uuid.Parse(classIDStr)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid class ID"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var req struct {
|
|
||||||
Date string `json:"date" binding:"required"`
|
|
||||||
SlotID string `json:"slot_id" binding:"required"`
|
|
||||||
Records []struct {
|
|
||||||
StudentID string `json:"student_id"`
|
|
||||||
Status string `json:"status"`
|
|
||||||
Note *string `json:"note"`
|
|
||||||
} `json:"records" binding:"required"`
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
slotID, err := uuid.Parse(req.SlotID)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid slot ID"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert to the expected type (without JSON tags)
|
|
||||||
records := make([]struct {
|
|
||||||
StudentID string
|
|
||||||
Status string
|
|
||||||
Note *string
|
|
||||||
}, len(req.Records))
|
|
||||||
for i, r := range req.Records {
|
|
||||||
records[i] = struct {
|
|
||||||
StudentID string
|
|
||||||
Status string
|
|
||||||
Note *string
|
|
||||||
}{
|
|
||||||
StudentID: r.StudentID,
|
|
||||||
Status: r.Status,
|
|
||||||
Note: r.Note,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
err = h.attendanceService.RecordBulkAttendance(c.Request.Context(), classID, req.Date, slotID, records, userID.(uuid.UUID))
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "attendance recorded"})
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetClassAttendance gets attendance for a class on a specific date
|
|
||||||
// GET /api/v1/classes/:id/attendance?date=...
|
|
||||||
func (h *SchoolHandlers) GetClassAttendance(c *gin.Context) {
|
|
||||||
classIDStr := c.Param("id")
|
|
||||||
classID, err := uuid.Parse(classIDStr)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid class ID"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
date := c.Query("date")
|
|
||||||
if date == "" {
|
|
||||||
date = time.Now().Format("2006-01-02")
|
|
||||||
}
|
|
||||||
|
|
||||||
overview, err := h.attendanceService.GetAttendanceByClass(c.Request.Context(), classID, date)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, overview)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetStudentAttendance gets attendance history for a student
|
|
||||||
// GET /api/v1/students/:id/attendance?start_date=...&end_date=...
|
|
||||||
func (h *SchoolHandlers) GetStudentAttendance(c *gin.Context) {
|
|
||||||
studentIDStr := c.Param("id")
|
|
||||||
studentID, err := uuid.Parse(studentIDStr)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid student ID"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
startDateStr := c.Query("start_date")
|
|
||||||
endDateStr := c.Query("end_date")
|
|
||||||
|
|
||||||
var startDate, endDate time.Time
|
|
||||||
if startDateStr == "" {
|
|
||||||
startDate = time.Now().AddDate(0, -1, 0) // Last month
|
|
||||||
} else {
|
|
||||||
startDate, _ = time.Parse("2006-01-02", startDateStr)
|
|
||||||
}
|
|
||||||
|
|
||||||
if endDateStr == "" {
|
|
||||||
endDate = time.Now()
|
|
||||||
} else {
|
|
||||||
endDate, _ = time.Parse("2006-01-02", endDateStr)
|
|
||||||
}
|
|
||||||
|
|
||||||
records, err := h.attendanceService.GetStudentAttendance(c.Request.Context(), studentID, startDate, endDate)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, records)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// Absence Report Handlers
|
|
||||||
// ========================================
|
|
||||||
|
|
||||||
// ReportAbsence allows parents to report absence
|
|
||||||
// POST /api/v1/absence/report
|
|
||||||
func (h *SchoolHandlers) ReportAbsence(c *gin.Context) {
|
|
||||||
userID, exists := c.Get("user_id")
|
|
||||||
if !exists {
|
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "user not authenticated"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var req models.ReportAbsenceRequest
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
report, err := h.attendanceService.ReportAbsence(c.Request.Context(), req, userID.(uuid.UUID))
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusCreated, report)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ConfirmAbsence allows teachers to confirm absence
|
|
||||||
// PUT /api/v1/absence/:id/confirm
|
|
||||||
func (h *SchoolHandlers) ConfirmAbsence(c *gin.Context) {
|
|
||||||
userID, exists := c.Get("user_id")
|
|
||||||
if !exists {
|
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "user not authenticated"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
reportIDStr := c.Param("id")
|
|
||||||
reportID, err := uuid.Parse(reportIDStr)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid report ID"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var req struct {
|
|
||||||
Status string `json:"status" binding:"required"` // "excused" or "unexcused"
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
err = h.attendanceService.ConfirmAbsence(c.Request.Context(), reportID, userID.(uuid.UUID), req.Status)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "absence confirmed"})
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetPendingAbsenceReports gets pending absence reports for a class
|
|
||||||
// GET /api/v1/classes/:id/absence/pending
|
|
||||||
func (h *SchoolHandlers) GetPendingAbsenceReports(c *gin.Context) {
|
|
||||||
classIDStr := c.Param("id")
|
|
||||||
classID, err := uuid.Parse(classIDStr)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid class ID"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
reports, err := h.attendanceService.GetPendingAbsenceReports(c.Request.Context(), classID)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, reports)
|
|
||||||
}
|
|
||||||
@@ -1,303 +0,0 @@
|
|||||||
package handlers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"github.com/breakpilot/consent-service/internal/models"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
"github.com/google/uuid"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// Grade Handlers
|
|
||||||
// ========================================
|
|
||||||
|
|
||||||
// CreateGrade creates a new grade
|
|
||||||
// POST /api/v1/grades
|
|
||||||
func (h *SchoolHandlers) CreateGrade(c *gin.Context) {
|
|
||||||
userID, exists := c.Get("user_id")
|
|
||||||
if !exists {
|
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "user not authenticated"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var req models.CreateGradeRequest
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get teacher ID from user ID
|
|
||||||
teacher, err := h.schoolService.GetTeacherByUserID(c.Request.Context(), userID.(uuid.UUID))
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusForbidden, gin.H{"error": "user is not a teacher"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
grade, err := h.gradeService.CreateGrade(c.Request.Context(), req, teacher.ID)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusCreated, grade)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetStudentGrades gets all grades for a student
|
|
||||||
// GET /api/v1/students/:id/grades?school_year_id=...
|
|
||||||
func (h *SchoolHandlers) GetStudentGrades(c *gin.Context) {
|
|
||||||
studentIDStr := c.Param("id")
|
|
||||||
studentID, err := uuid.Parse(studentIDStr)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid student ID"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
schoolYearIDStr := c.Query("school_year_id")
|
|
||||||
if schoolYearIDStr == "" {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "school_year_id is required"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
schoolYearID, err := uuid.Parse(schoolYearIDStr)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid school year ID"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
grades, err := h.gradeService.GetStudentGrades(c.Request.Context(), studentID, schoolYearID)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, grades)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetClassGrades gets grades for all students in a class for a subject (Notenspiegel)
|
|
||||||
// GET /api/v1/classes/:id/grades/:subjectId?school_year_id=...&semester=...
|
|
||||||
func (h *SchoolHandlers) GetClassGrades(c *gin.Context) {
|
|
||||||
classIDStr := c.Param("id")
|
|
||||||
classID, err := uuid.Parse(classIDStr)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid class ID"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
subjectIDStr := c.Param("subjectId")
|
|
||||||
subjectID, err := uuid.Parse(subjectIDStr)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid subject ID"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
schoolYearIDStr := c.Query("school_year_id")
|
|
||||||
if schoolYearIDStr == "" {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "school_year_id is required"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
schoolYearID, err := uuid.Parse(schoolYearIDStr)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid school year ID"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
semesterStr := c.DefaultQuery("semester", "1")
|
|
||||||
var semester int
|
|
||||||
if semesterStr == "1" {
|
|
||||||
semester = 1
|
|
||||||
} else {
|
|
||||||
semester = 2
|
|
||||||
}
|
|
||||||
|
|
||||||
overviews, err := h.gradeService.GetClassGradesBySubject(c.Request.Context(), classID, subjectID, schoolYearID, semester)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, overviews)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetGradeStatistics gets grade statistics for a class/subject
|
|
||||||
// GET /api/v1/classes/:id/grades/:subjectId/stats?school_year_id=...&semester=...
|
|
||||||
func (h *SchoolHandlers) GetGradeStatistics(c *gin.Context) {
|
|
||||||
classIDStr := c.Param("id")
|
|
||||||
classID, err := uuid.Parse(classIDStr)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid class ID"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
subjectIDStr := c.Param("subjectId")
|
|
||||||
subjectID, err := uuid.Parse(subjectIDStr)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid subject ID"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
schoolYearIDStr := c.Query("school_year_id")
|
|
||||||
if schoolYearIDStr == "" {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "school_year_id is required"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
schoolYearID, err := uuid.Parse(schoolYearIDStr)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid school year ID"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
semesterStr := c.DefaultQuery("semester", "1")
|
|
||||||
var semester int
|
|
||||||
if semesterStr == "1" {
|
|
||||||
semester = 1
|
|
||||||
} else {
|
|
||||||
semester = 2
|
|
||||||
}
|
|
||||||
|
|
||||||
stats, err := h.gradeService.GetSubjectGradeStatistics(c.Request.Context(), classID, subjectID, schoolYearID, semester)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, stats)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// Parent Onboarding Handlers
|
|
||||||
// ========================================
|
|
||||||
|
|
||||||
// GenerateOnboardingToken generates a QR code token for parent onboarding
|
|
||||||
// POST /api/v1/onboarding/tokens
|
|
||||||
func (h *SchoolHandlers) GenerateOnboardingToken(c *gin.Context) {
|
|
||||||
userID, exists := c.Get("user_id")
|
|
||||||
if !exists {
|
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "user not authenticated"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var req struct {
|
|
||||||
SchoolID string `json:"school_id" binding:"required"`
|
|
||||||
ClassID string `json:"class_id" binding:"required"`
|
|
||||||
StudentID string `json:"student_id" binding:"required"`
|
|
||||||
Role string `json:"role"` // "parent" or "parent_representative"
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
schoolID, err := uuid.Parse(req.SchoolID)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid school ID"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
classID, err := uuid.Parse(req.ClassID)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid class ID"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
studentID, err := uuid.Parse(req.StudentID)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid student ID"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
role := req.Role
|
|
||||||
if role == "" {
|
|
||||||
role = "parent"
|
|
||||||
}
|
|
||||||
|
|
||||||
token, err := h.schoolService.GenerateParentOnboardingToken(c.Request.Context(), schoolID, classID, studentID, userID.(uuid.UUID), role)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate QR code URL
|
|
||||||
qrURL := "/onboard-parent?token=" + token.Token
|
|
||||||
|
|
||||||
c.JSON(http.StatusCreated, gin.H{
|
|
||||||
"token": token.Token,
|
|
||||||
"qr_url": qrURL,
|
|
||||||
"expires_at": token.ExpiresAt,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ValidateOnboardingToken validates an onboarding token
|
|
||||||
// GET /api/v1/onboarding/validate?token=...
|
|
||||||
func (h *SchoolHandlers) ValidateOnboardingToken(c *gin.Context) {
|
|
||||||
token := c.Query("token")
|
|
||||||
if token == "" {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "token is required"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
onboardingToken, err := h.schoolService.ValidateOnboardingToken(c.Request.Context(), token)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "invalid or expired token"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get student and school info
|
|
||||||
student, err := h.schoolService.GetStudent(c.Request.Context(), onboardingToken.StudentID)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
class, err := h.schoolService.GetClass(c.Request.Context(), onboardingToken.ClassID)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
school, err := h.schoolService.GetSchool(c.Request.Context(), onboardingToken.SchoolID)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
|
||||||
"valid": true,
|
|
||||||
"role": onboardingToken.Role,
|
|
||||||
"student_name": student.FirstName + " " + student.LastName,
|
|
||||||
"class_name": class.Name,
|
|
||||||
"school_name": school.Name,
|
|
||||||
"expires_at": onboardingToken.ExpiresAt,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// RedeemOnboardingToken redeems a token and creates parent account
|
|
||||||
// POST /api/v1/onboarding/redeem
|
|
||||||
func (h *SchoolHandlers) RedeemOnboardingToken(c *gin.Context) {
|
|
||||||
userID, exists := c.Get("user_id")
|
|
||||||
if !exists {
|
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "user not authenticated"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var req struct {
|
|
||||||
Token string `json:"token" binding:"required"`
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
err := h.schoolService.RedeemOnboardingToken(c.Request.Context(), req.Token, userID.(uuid.UUID))
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "token redeemed successfully"})
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user