Compare commits

...

5 Commits

Author SHA1 Message Date
m
b36247dfb9 feat: append-only audit trail for all mutations (P0)
- Database: kanzlai.audit_log table with RLS, append-only policies
  (no UPDATE/DELETE), indexes for entity, user, and time queries
- Backend: AuditService.Log() with context-based tenant/user/IP/UA
  extraction, wired into all 7 services (case, deadline, appointment,
  document, note, party, tenant)
- API: GET /api/audit-log with entity_type, entity_id, user_id,
  from/to date, and pagination filters
- Frontend: Protokoll tab on case detail page with chronological
  audit entries, diff preview, and pagination

Required by § 50 BRAO and DSGVO Art. 5(2).
2026-03-30 11:02:28 +02:00
m
82878dffd5 docs: full system roadmap — from MVP to complete Kanzleimanagement 2026-03-28 02:35:20 +01:00
m
909f14062c docs: comprehensive MVP audit — security, architecture, UX, competitive analysis 2026-03-28 02:26:39 +01:00
m
4b86dfa4ad feat: update AUDIT.md with sub-agent findings
Added 7 additional issues from deep-dive agents:
- Race condition in HolidayService cache (critical)
- Rate limiter X-Forwarded-For bypass (critical)
- German umlaut typos throughout frontend
- Silent error swallowing in createEvent
- Missing React error boundaries
- No RLS policies at database level
- Updated priority roadmap with new items
2026-03-28 02:23:50 +01:00
m
60f1f4ef4a feat: comprehensive MVP audit — security, architecture, UX, competitive analysis
Structured assessment covering code quality, security (critical tenant isolation
bypass found), architecture, UX gaps, testing coverage, deployment, and
competitive positioning vs RA-MICRO/ADVOWARE/AnNoText/Actaport.

Includes prioritized roadmap (P0-P3) with actionable items.
2026-03-28 02:22:07 +01:00
18 changed files with 1015 additions and 37 deletions

482
AUDIT.md Normal file
View File

@@ -0,0 +1,482 @@
# KanzlAI-mGMT MVP Audit
**Date:** 2026-03-28
**Auditor:** athena (consultant)
**Scope:** Full-stack audit of KanzlAI-mGMT — Go backend, Next.js frontend, Supabase database, deployment, security, UX, competitive positioning.
**Codebase:** ~16,500 lines across ~60 source files, built 2026-03-25 in a single session with parallel workers.
---
## Executive Summary
KanzlAI-mGMT is an impressive MVP built in ~2 hours. It covers the core Kanzleimanagement primitives: cases, deadlines, appointments, parties, documents, notes, dashboard, CalDAV sync, and AI-powered deadline extraction. The architecture is sound — clean separation between Go API and Next.js frontend, proper multi-tenant design with Supabase Auth, parameterized SQL throughout.
However, the speed of construction shows. There are **critical security gaps** that must be fixed before any external user touches this. The frontend has good bones but lacks the polish and completeness a lawyer would expect. And the feature gap vs. established competitors (RA-MICRO, ADVOWARE, AnNoText, Actaport) is enormous — particularly around beA integration, billing/RVG, and document generation, which are table-stakes for German law firms.
**Bottom line:** Fix the security issues, add error recovery and multi-tenant auth verification, then decide whether to pursue the Kanzleimanagement market (massive feature gap) or pivot back to the UPC niche (where you had a genuine competitive advantage).
---
## 1. Critical Issues (Fix Immediately)
### 1.1 Tenant Isolation Bypass in TenantResolver
**File:** `backend/internal/auth/tenant_resolver.go:37-42`
When the `X-Tenant-ID` header is provided, the TenantResolver parses it and sets it in context **without verifying the user has access to that tenant**. Any authenticated user can access any tenant's data by setting this header.
```go
if header := r.Header.Get("X-Tenant-ID"); header != "" {
parsed, err := uuid.Parse(header)
// ... sets tenantID = parsed — NO ACCESS CHECK
}
```
Compare with `helpers.go:32-44` where `resolveTenant()` correctly verifies access via `user_tenants` — but this function is unused in the middleware path. The TenantResolver middleware is what actually runs for all scoped routes.
**Impact:** Complete tenant data isolation breach. User A can read/modify/delete User B's cases, deadlines, appointments, documents.
**Fix:** Add `user_tenants` lookup in TenantResolver when X-Tenant-ID is provided, same as `resolveTenant()` does.
### 1.2 Duplicate Tenant Resolution Logic
**Files:** `backend/internal/auth/tenant_resolver.go` and `backend/internal/handlers/helpers.go:25-57`
Two independent implementations of tenant resolution exist. The middleware (`TenantResolver`) is used for the scoped routes. The handler-level `resolveTenant()` function exists in helpers.go. The auth middleware in `middleware.go:39-47` also resolves a tenant into context. This triple-resolution creates confusion and the security bug above.
**Fix:** Consolidate to a single path. Remove the handler-level `resolveTenant()` and the auth middleware's tenant resolution. Let TenantResolver be the single source of truth, but make it verify access.
### 1.3 CalDAV Credentials Stored in Plaintext
**File:** `backend/internal/services/caldav_service.go:29-35`
CalDAV username and password are stored as plain JSON in the `tenants.settings` column:
```go
type CalDAVConfig struct {
URL string `json:"url"`
Username string `json:"username"`
Password string `json:"password"`
...
}
```
Combined with the tenant isolation bypass above, any authenticated user can read any tenant's CalDAV credentials.
**Fix:** Encrypt CalDAV credentials at rest (e.g., using `pgcrypto` or application-level encryption). At minimum, never return the password in API responses.
### 1.4 No CORS Configuration
**File:** `backend/internal/router/router.go`, `backend/cmd/server/main.go`
There is zero CORS handling anywhere in the backend. The frontend uses Next.js rewrites to proxy `/api/` to the backend, which works in production. But:
- If anyone accesses the backend directly (different origin), there's no CORS protection.
- No `X-Frame-Options`, `X-Content-Type-Options`, or other security headers are set.
**Fix:** Add CORS middleware restricting to the frontend origin. Add standard security headers.
### 1.5 Internal Error Messages Leaked to Clients
**Files:** Multiple handlers (e.g., `cases.go:44`, `cases.go:73`, `appointments.go`)
```go
writeError(w, http.StatusInternalServerError, err.Error())
```
Internal error messages (including SQL errors, connection errors, etc.) are sent directly to the client. This leaks implementation details.
**Fix:** Log the full error server-side, return a generic message to the client.
### 1.6 Race Condition in HolidayService Cache
**File:** `backend/internal/services/holidays.go`
The `HolidayService` uses a `map[int][]Holiday` cache without any mutex protection. Concurrent requests (e.g., multiple deadline calculations) will cause a data race. The Go race detector would flag this.
**Fix:** Add `sync.RWMutex` to HolidayService.
### 1.7 Rate Limiter Trivially Bypassable
**File:** `backend/internal/middleware/ratelimit.go:78-79`
```go
ip := r.Header.Get("X-Forwarded-For")
if ip == "" { ip = r.RemoteAddr }
```
Rate limiting keys off `X-Forwarded-For`, which any client can spoof. An attacker can bypass AI endpoint rate limits by rotating this header.
**Fix:** Only trust `X-Forwarded-For` from configured reverse proxy IPs, or use `r.RemoteAddr` exclusively behind a trusted proxy.
---
## 2. Important Gaps (Fix Before Showing to Anyone)
### 2.1 No Input Validation Beyond "Required Fields"
**Files:** All handlers
Input validation is minimal — typically just checking if required fields are empty:
```go
if input.CaseNumber == "" || input.Title == "" {
writeError(w, http.StatusBadRequest, "case_number and title are required")
}
```
Missing:
- Length limits on text fields (could store megabytes in a title field)
- Status value validation (accepts any string for status fields)
- Date format validation
- Case type validation against allowed values
- SQL-safe string validation (although parameterized queries protect against injection)
### 2.2 No Pagination Defaults on Most List Endpoints
**File:** `backend/internal/services/case_service.go:57-63`
`CaseService.List` has sane defaults (limit=20, max=100). But other list endpoints (`appointments`, `deadlines`, `notes`, `parties`, `case_events`) have no pagination at all — they return all records for a tenant/case. As data grows, these become performance problems.
### 2.3 Dashboard Page is Entirely Client-Side
**File:** `frontend/src/app/(app)/dashboard/page.tsx`
The entire dashboard is a `"use client"` component that fetches data via API. This means:
- No SSR benefit — the page is blank until JS loads and API responds
- SEO doesn't matter for a SaaS app, but initial load time does
- The skeleton is nice but adds 200-400ms of perceived latency
For an internal tool this is acceptable, but for a commercial product it should use server components for the initial render.
### 2.4 Frontend Auth Uses `getSession()` Instead of `getUser()`
**File:** `frontend/src/lib/api.ts:10-12`
```typescript
const { data: { session } } = await supabase.auth.getSession();
```
`getSession()` reads from local storage without server verification. If a session is expired or revoked server-side, the frontend will still try to use it until the backend rejects it. The middleware correctly uses `getUser()` (which validates server-side), but the API client does not.
### 2.5 Missing Error Recovery in Frontend
Throughout the frontend, API errors are handled with basic error states, but there's no:
- Retry logic for transient failures
- Token refresh on 401 responses
- Optimistic UI rollback on mutation failures
- Offline detection
### 2.6 Missing `Content-Disposition` Header Sanitization
**File:** `backend/internal/handlers/documents.go:133`
```go
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, title))
```
The `title` (which comes from user input) is inserted directly into the header. A filename containing `"` or newlines could be used for response header injection.
**Fix:** Sanitize the filename — strip or encode special characters.
### 2.7 No Graceful Shutdown
**File:** `backend/cmd/server/main.go:42`
```go
http.ListenAndServe(":"+cfg.Port, handler)
```
No signal handling or graceful shutdown. When the process receives SIGTERM (e.g., during deployment), in-flight requests are dropped, CalDAV sync operations may be interrupted mid-write, and database connections are not cleanly closed.
### 2.8 Database Connection Pool — search_path is Session-Level
**File:** `backend/internal/db/connection.go:17`
```go
db.Exec("SET search_path TO kanzlai, public")
```
`SET search_path` is session-level in PostgreSQL. With connection pooling (`MaxOpenConns: 25`), this SET runs once on the initial connection. If a connection is recycled or a new one opened from the pool, it may not have the kanzlai search_path. This could cause queries to silently hit the wrong schema.
**Fix:** Use `SET LOCAL search_path` in a transaction, or set it at the database/role level, or qualify all table references with the schema name.
### 2.9 go.sum Missing from Dockerfile
**File:** `backend/Dockerfile:4`
```dockerfile
COPY go.mod ./
RUN go mod download
```
Only `go.mod` is copied, not `go.sum`. This means the build isn't reproducible and doesn't verify checksums. Should be `COPY go.mod go.sum ./`.
### 2.10 German Umlaut Typos Throughout Frontend
**Files:** Multiple frontend components
German strings use ASCII approximations instead of proper characters:
- `login/page.tsx`: "Zurueck" instead of "Zurück"
- `cases/[id]/layout.tsx`: "Anhaengig" instead of "Anhängig"
- `cases/[id]/fristen/page.tsx`: "Ueberfaellig" instead of "Überfällig"
- `termine/page.tsx`: "Uberblick" instead of "Überblick"
A German lawyer would notice this immediately. It signals "this was built by a machine, not tested by a human."
### 2.11 Silent Error Swallowing in Event Creation
**File:** `backend/internal/services/case_service.go:260-266`
```go
func createEvent(ctx context.Context, db *sqlx.DB, ...) {
db.ExecContext(ctx, /* ... */) // Error completely ignored
}
```
Case events (audit trail) silently fail to create. The calling functions don't check the return. This means you could have cases with no events and no way to know why.
### 2.12 Missing Error Boundaries in Frontend
No React error boundaries are implemented. If any component throws, the entire page crashes with a white screen. For a law firm tool where data integrity matters, this is unacceptable.
### 2.13 No RLS Policies Defined at Database Level
Multi-tenant isolation relies entirely on `WHERE tenant_id = $X` clauses in Go code. If any query forgets this clause, data leaks across tenants. There are no PostgreSQL RLS policies as a safety net.
**Fix:** Enable RLS on all tenant-scoped tables and create policies tied to `auth.uid()` via `user_tenants`.
---
## 3. Architecture Assessment
### 3.1 What's Good
- **Clean monorepo structure** — `backend/` and `frontend/` are clearly separated. Each has its own Dockerfile. The Makefile provides unified commands.
- **Go backend is well-organized** — `cmd/server/`, `internal/{auth,config,db,handlers,middleware,models,router,services}` follows Go best practices.
- **Handler/Service separation** — handlers do HTTP concerns (parse request, write response), services do business logic. This is correct.
- **Parameterized SQL everywhere** — no string concatenation in queries. All user input goes through `$N` placeholders.
- **Multi-tenant design** — `tenant_id` on every row, context-based tenant resolution, RLS at the database level.
- **Smart use of Go 1.22+ routing** — method+path patterns like `GET /api/cases/{id}` eliminate the need for a third-party router.
- **CalDAV sync is genuinely impressive** — bidirectional sync with conflict resolution, etag tracking, background polling per-tenant. This is a differentiator.
- **Deadline calculator** — ported from youpc.org with holiday awareness. Legally important and hard to build.
- **Frontend routing structure** — German URL paths (`/fristen`, `/termine`, `/einstellungen`), nested case detail routes with layout.tsx for shared chrome. Proper use of App Router patterns.
### 3.2 Structural Concerns
- **No database migrations** — the schema was apparently created via SQL scripts run manually. There's a `seed/demo_data.sql` but no migration system. For a production system, this is unsustainable.
- **No CI/CD pipeline** — no `.github/workflows/`, `.gitea/`, or any CI configuration. Tests run locally but not automatically.
- **No API versioning** — all routes are at `/api/`. Adding breaking changes will break clients.
- **Services take raw `*sqlx.DB`** — no transaction support across service boundaries. Creating a case + event is not atomic (if the event insert fails, the case still exists).
- **Models are just struct definitions** — no validation methods, no constructor functions. Validation is scattered across handlers.
### 3.3 Data Model
Based on the seed data and model files, the schema is reasonable:
- `tenants`, `user_tenants` (multi-tenancy)
- `cases`, `parties` (case management)
- `deadlines`, `appointments` (time management)
- `documents`, `case_events`, `notes` (supporting data)
- `proceeding_types`, `deadline_rules`, `holidays` (reference data)
**Missing indexes likely needed:**
- `deadlines(tenant_id, status, due_date)` — for dashboard queries
- `appointments(tenant_id, start_at)` — for calendar queries
- `case_events(case_id, created_at)` — for event feeds
- `cases(tenant_id, status)` — for filtered lists
**Missing constraints:**
- No CHECK constraint on status values (cases, deadlines, appointments)
- No UNIQUE constraint on `case_number` per tenant
- No foreign key from `notes` to the parent entity (if polymorphic)
---
## 4. Security Assessment
### 4.1 Authentication
- **JWT validation is correct** — algorithm check (HMAC only), expiry check, sub claim extraction. Using `golang-jwt/v5`.
- **Supabase Auth on frontend** — proper cookie-based session with server-side verification in middleware.
- **No refresh token rotation** — the API client uses `getSession()` which may serve stale tokens.
### 4.2 Authorization
- **Critical: Tenant isolation bypass** (see 1.1)
- **No role-based access control** — `user_tenants` has a `role` column but it's never checked. Any member can do anything.
- **No resource-level permissions** — any user in a tenant can delete any case, document, etc.
### 4.3 Input Validation
- **SQL injection: Protected** — all queries use parameterized placeholders.
- **XSS: Partially protected** — React auto-escapes, but the API returns raw strings that could contain HTML. The `Content-Disposition` header is vulnerable (see 2.6).
- **File upload: Partially protected** — `MaxBytesReader` limits to 50MB, but no file type validation (could upload .exe, .html with scripts, etc.).
- **Rate limiting: AI endpoints only** — the rest of the API has no rate limiting. Login/register go through Supabase (which has its own limits), but all CRUD endpoints are unlimited.
### 4.4 Secrets
- **No hardcoded secrets** — all via environment variables. Good.
- **CalDAV credentials in plaintext** — see 1.3.
- **Supabase service key in backend** — necessary for storage, but this key has full DB access. Should be scoped.
---
## 5. Testing Assessment
### 5.1 Backend Tests (15 files)
- **Integration test** — sets up real DB connection, creates JWT, tests full HTTP flow. Excellent pattern but requires DATABASE_URL (skips otherwise).
- **Handler tests** — mock-based unit tests for most handlers. Test JSON parsing, error responses, basic happy paths.
- **Service tests** — deadline calculator has solid date arithmetic tests. Holiday service tested. CalDAV service tested with mocks. AI service tested with mocked HTTP.
- **Middleware tests** — rate limiter tested.
- **Auth tests** — tenant resolver tested.
### 5.2 Frontend Tests (4 files)
- `api.test.ts` — tests the API client
- `DeadlineTrafficLights.test.tsx` — component test
- `CaseOverviewGrid.test.tsx` — component test
- `LoginPage.test.tsx` — auth page test
### 5.3 What's Missing
- **No E2E tests** — no Playwright/Cypress. Critical for a law firm app where correctness matters.
- **No contract tests** — frontend and backend are tested independently. A schema change could break the frontend without any test catching it.
- **Deadline calculation edge cases** — needs tests for year boundaries, leap years, holidays falling on weekends, multiple consecutive holidays.
- **Multi-tenant security tests** — no test verifying that User A can't access Tenant B's data. This is the most important test to add.
- **Frontend test coverage is thin** — 4 tests for ~30 components. The dashboard, all forms, navigation, error states are untested.
- **No load testing** — unknown how the system behaves under concurrent users.
---
## 6. UX Assessment
### 6.1 What Works
- **Dashboard is strong** — traffic light deadline indicators, upcoming timeline, case overview, quick actions. A lawyer can see what matters at a glance.
- **German localization** — UI is in German with proper legal terminology (Akten, Fristen, Termine, Parteien).
- **Mobile responsive** — sidebar collapses to hamburger menu, layout uses responsive grids.
- **Loading states** — skeleton screens on dashboard, not just spinners.
- **Breadcrumbs** — navigation trail on all pages.
- **Deadline calculator** — unique feature that provides real value for UPC litigation.
### 6.2 What a Lawyer Would Stumble On
1. **No onboarding flow** — after registration, user has no tenant, no cases. The app shows empty states but doesn't guide the user to create a tenant or import data.
2. **No search** — there's no global search. A lawyer with 100+ cases needs to find things fast.
3. **No keyboard shortcuts** — power users (lawyers are keyboard-heavy) have no shortcuts.
4. **Sidebar mixes languages** — "Akten" (German) vs "AI Analyse" (English). Should be consistent.
5. **No notifications** — overdue deadlines don't trigger any alert beyond the dashboard color. No email alerts, no push notifications.
6. **No print view** — lawyers need to print deadline lists, case summaries. No print stylesheet.
7. **No bulk operations** — can't mark multiple deadlines as complete, can't bulk-assign parties.
8. **Document upload has no preview** — uploaded PDFs can't be viewed inline.
9. **AI features require manual trigger** — AI summary and deadline extraction are manual. Should auto-trigger on document upload.
10. **No activity log per user** — no audit trail of who changed what. Critical for law firm compliance.
---
## 7. Deployment Assessment
### 7.1 Docker Setup
- **Multi-stage builds** — both Dockerfiles use builder pattern. Good.
- **Backend is minimal** — Alpine + static binary + ca-certificates. ~15MB image.
- **Frontend** — Bun for deps/build, Node for runtime (standalone output). Reasonable.
- **Missing:** go.sum not copied in backend Dockerfile (see 2.9).
- **Missing:** No docker-compose.yml for local development.
- **Missing:** No health check in Dockerfile (`HEALTHCHECK` instruction).
### 7.2 Environment Handling
- **Config validates required vars** — `DATABASE_URL` and `SUPABASE_JWT_SECRET` are checked at startup.
- **Supabase URL/keys not validated** — if missing, features silently fail or crash at runtime.
- **No .env.example** — new developers don't know what env vars are needed.
### 7.3 Reliability
- **No graceful shutdown** (see 2.7)
- **No readiness/liveness probes** — `/health` exists but only checks DB connectivity. No readiness distinction.
- **CalDAV sync runs in-process** — if the sync goroutine panics, it takes down the API server.
- **No structured error recovery** — panics in handlers will crash the process (no recovery middleware).
---
## 8. Competitive Analysis
### 8.1 The Market
German Kanzleisoftware is a mature, crowded market:
| Tool | Type | Price | Key Strength |
|------|------|-------|-------------|
| **RA-MICRO** | Desktop + Cloud | ~100-200 EUR/user/mo | Market leader, 30+ years, full beA integration |
| **ADVOWARE** | Desktop + Cloud | from 20 EUR/mo | Budget-friendly, strong for small firms |
| **AnNoText** (Wolters Kluwer) | Desktop + Cloud | Custom pricing | Enterprise, AI document analysis, DictNow |
| **Actaport** | Cloud-native | from 79.80 EUR/mo | Modern UI, Mandantenportal, integrated Office |
| **Haufe Advolux** | Cloud | Custom | User-friendly, full-featured |
| **Renostar Legal Cloud** | Cloud | Custom | Browser-based, no installation |
### 8.2 Table-Stakes Features KanzlAI is Missing
These are **mandatory** for any German Kanzleisoftware to be taken seriously:
1. **beA Integration** — since 2022, German lawyers must use the electronic court mailbox (besonderes elektronisches Anwaltspostfach). No Kanzleisoftware sells without it. This is a **massive** implementation effort (KSW-Schnittstelle from BRAK).
2. **RVG Billing (Gebührenrechner)** — automated fee calculation per RVG (Rechtsanwaltsvergütungsgesetz). Every competitor has this built-in. Without it, lawyers can't bill clients.
3. **Document Generation** — templates for Schriftsätze, Klageschriften, Mahnbescheide with auto-populated case data. Usually integrated with Word.
4. **Accounting (FiBu)** — client trust accounts (Fremdgeld), DATEV export, tax-relevant bookkeeping. Legal requirement.
5. **Conflict Check (Kollisionsprüfung)** — check if the firm has a conflict of interest before taking a case. Legally required (§ 43a BRAO).
6. **Dictation System** — voice-to-text for lawyers. RA-MICRO has DictaNet, AnNoText has DictNow.
### 8.3 Where KanzlAI Could Differentiate
Despite the feature gap, KanzlAI has some advantages:
1. **AI-native** — competitors are bolting AI onto 20-year-old software. KanzlAI has Claude API integration from day one. The deadline extraction from PDFs is genuinely useful.
2. **UPC specialization** — the deadline calculator with UPC Rules of Procedure knowledge is unique. No competitor has deep UPC litigation support.
3. **CalDAV sync** — bidirectional sync with external calendars is not common in German Kanzleisoftware.
4. **Modern tech stack** — React + Go + Supabase vs. the .NET/Java/Desktop world of RA-MICRO et al.
5. **Multi-tenant from day 1** — designed for SaaS, not converted from desktop software.
### 8.4 Strategic Recommendation
**Don't compete head-on with RA-MICRO.** The feature gap is 10+ person-years of work. Instead:
**Option A: UPC Niche Tool** — Pivot back to UPC patent litigation. Build the best deadline calculator, case tracker, and AI-powered brief analysis tool for UPC practitioners. There are ~1000 UPC practitioners in Europe who need specialized tooling that RA-MICRO doesn't provide. Charge 200-500 EUR/mo.
**Option B: AI-First Legal Assistant** — Don't call it "Kanzleimanagement." Position as an AI assistant that reads court documents, extracts deadlines, and syncs to the lawyer's existing Kanzleisoftware via CalDAV/iCal. This sidesteps the feature gap entirely.
**Option C: Full Kanzleisoftware** — If you pursue this, beA integration is the first priority, then RVG billing. Without these two, no German lawyer will switch.
---
## 9. Strengths (What's Good, Keep Doing It)
1. **Architecture is solid** — the Go + Next.js + Supabase stack is well-chosen. Clean separation of concerns.
2. **SQL is safe** — parameterized queries throughout. No injection vectors.
3. **Multi-tenant design** — tenant_id scoping with RLS is the right approach.
4. **CalDAV implementation** — genuinely impressive for an MVP. Bidirectional sync with conflict resolution.
5. **Deadline calculator** — ported from youpc.org with holiday awareness. Real domain value.
6. **AI integration** — Claude API with tool use for structured extraction. Clean implementation.
7. **Dashboard UX** — traffic lights, timeline, quick actions. Lawyers will get this immediately.
8. **German-first** — proper legal terminology, German date formats, localized UI.
9. **Test foundation** — 15 backend test files with integration tests. Good starting point.
10. **Docker builds are lean** — multi-stage, Alpine-based, standalone Next.js output.
---
## 10. Priority Roadmap
### P0 — This Week
- [ ] Fix tenant isolation bypass in TenantResolver (1.1)
- [ ] Consolidate tenant resolution logic (1.2)
- [ ] Encrypt CalDAV credentials at rest (1.3)
- [ ] Add CORS middleware + security headers (1.4)
- [ ] Stop leaking internal errors to clients (1.5)
- [ ] Add mutex to HolidayService cache (1.6)
- [ ] Fix rate limiter X-Forwarded-For bypass (1.7)
- [ ] Fix Dockerfile go.sum copy (2.9)
### P1 — Before Demo/Beta
- [ ] Add input validation (length limits, allowed values) (2.1)
- [ ] Add pagination to all list endpoints (2.2)
- [ ] Fix `search_path` connection pool issue (2.8)
- [ ] Add graceful shutdown with signal handling (2.7)
- [ ] Sanitize Content-Disposition filename (2.6)
- [ ] Fix German umlaut typos throughout frontend (2.10)
- [ ] Handle createEvent errors instead of swallowing (2.11)
- [ ] Add React error boundaries (2.12)
- [ ] Implement RLS policies on all tenant-scoped tables (2.13)
- [ ] Add multi-tenant security tests
- [ ] Add database migrations system
- [ ] Add `.env.example` file
- [ ] Add onboarding flow for new users
### P2 — Next Iteration
- [ ] Role-based access control (admin/member/readonly)
- [ ] Global search
- [ ] Email notifications for overdue deadlines
- [ ] Audit trail / activity log per user
- [ ] Auto-trigger AI extraction on document upload
- [ ] Print-friendly views
- [ ] E2E tests with Playwright
- [ ] CI/CD pipeline
### P3 — Strategic
- [ ] Decide market positioning (UPC niche vs. AI assistant vs. full Kanzleisoftware)
- [ ] If Kanzleisoftware: begin beA integration research
- [ ] If Kanzleisoftware: RVG Gebührenrechner
- [ ] If UPC niche: integrate lex-research case law database
---
*This audit was conducted by reading every source file in the repository, running all tests, analyzing the database schema via seed data, and comparing against established German Kanzleisoftware competitors.*

View File

@@ -9,8 +9,10 @@ import (
type contextKey string
const (
userIDKey contextKey = "user_id"
tenantIDKey contextKey = "tenant_id"
userIDKey contextKey = "user_id"
tenantIDKey contextKey = "tenant_id"
ipKey contextKey = "ip_address"
userAgentKey contextKey = "user_agent"
)
func ContextWithUserID(ctx context.Context, userID uuid.UUID) context.Context {
@@ -30,3 +32,23 @@ func TenantFromContext(ctx context.Context) (uuid.UUID, bool) {
id, ok := ctx.Value(tenantIDKey).(uuid.UUID)
return id, ok
}
func ContextWithRequestInfo(ctx context.Context, ip, userAgent string) context.Context {
ctx = context.WithValue(ctx, ipKey, ip)
ctx = context.WithValue(ctx, userAgentKey, userAgent)
return ctx
}
func IPFromContext(ctx context.Context) *string {
if v, ok := ctx.Value(ipKey).(string); ok && v != "" {
return &v
}
return nil
}
func UserAgentFromContext(ctx context.Context) *string {
if v, ok := ctx.Value(userAgentKey).(string); ok && v != "" {
return &v
}
return nil
}

View File

@@ -46,6 +46,13 @@ func (m *Middleware) RequireAuth(next http.Handler) http.Handler {
}
ctx = ContextWithTenantID(ctx, tenantID)
// Capture IP and user-agent for audit logging
ip := r.Header.Get("X-Forwarded-For")
if ip == "" {
ip = r.RemoteAddr
}
ctx = ContextWithRequestInfo(ctx, ip, r.UserAgent())
next.ServeHTTP(w, r.WithContext(ctx))
})
}

View File

@@ -0,0 +1,63 @@
package handlers
import (
"net/http"
"strconv"
"github.com/google/uuid"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/auth"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/services"
)
type AuditLogHandler struct {
svc *services.AuditService
}
func NewAuditLogHandler(svc *services.AuditService) *AuditLogHandler {
return &AuditLogHandler{svc: svc}
}
func (h *AuditLogHandler) List(w http.ResponseWriter, r *http.Request) {
tenantID, ok := auth.TenantFromContext(r.Context())
if !ok {
writeError(w, http.StatusForbidden, "missing tenant")
return
}
q := r.URL.Query()
page, _ := strconv.Atoi(q.Get("page"))
limit, _ := strconv.Atoi(q.Get("limit"))
filter := services.AuditFilter{
EntityType: q.Get("entity_type"),
From: q.Get("from"),
To: q.Get("to"),
Page: page,
Limit: limit,
}
if idStr := q.Get("entity_id"); idStr != "" {
if id, err := uuid.Parse(idStr); err == nil {
filter.EntityID = &id
}
}
if idStr := q.Get("user_id"); idStr != "" {
if id, err := uuid.Parse(idStr); err == nil {
filter.UserID = &id
}
}
entries, total, err := h.svc.List(r.Context(), tenantID, filter)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to fetch audit log")
return
}
writeJSON(w, http.StatusOK, map[string]any{
"entries": entries,
"total": total,
"page": filter.Page,
"limit": filter.Limit,
})
}

View File

@@ -113,7 +113,7 @@ func (h *DeadlineHandlers) Create(w http.ResponseWriter, r *http.Request) {
return
}
deadline, err := h.deadlines.Create(tenantID, input)
deadline, err := h.deadlines.Create(r.Context(), tenantID, input)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to create deadline")
return
@@ -142,7 +142,7 @@ func (h *DeadlineHandlers) Update(w http.ResponseWriter, r *http.Request) {
return
}
deadline, err := h.deadlines.Update(tenantID, deadlineID, input)
deadline, err := h.deadlines.Update(r.Context(), tenantID, deadlineID, input)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to update deadline")
return
@@ -169,7 +169,7 @@ func (h *DeadlineHandlers) Complete(w http.ResponseWriter, r *http.Request) {
return
}
deadline, err := h.deadlines.Complete(tenantID, deadlineID)
deadline, err := h.deadlines.Complete(r.Context(), tenantID, deadlineID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to complete deadline")
return
@@ -196,7 +196,7 @@ func (h *DeadlineHandlers) Delete(w http.ResponseWriter, r *http.Request) {
return
}
err = h.deadlines.Delete(tenantID, deadlineID)
err = h.deadlines.Delete(r.Context(), tenantID, deadlineID)
if err != nil {
writeError(w, http.StatusNotFound, err.Error())
return

View File

@@ -0,0 +1,22 @@
package models
import (
"encoding/json"
"time"
"github.com/google/uuid"
)
type AuditLog struct {
ID int64 `db:"id" json:"id"`
TenantID uuid.UUID `db:"tenant_id" json:"tenant_id"`
UserID *uuid.UUID `db:"user_id" json:"user_id,omitempty"`
Action string `db:"action" json:"action"`
EntityType string `db:"entity_type" json:"entity_type"`
EntityID *uuid.UUID `db:"entity_id" json:"entity_id,omitempty"`
OldValues *json.RawMessage `db:"old_values" json:"old_values,omitempty"`
NewValues *json.RawMessage `db:"new_values" json:"new_values,omitempty"`
IPAddress *string `db:"ip_address" json:"ip_address,omitempty"`
UserAgent *string `db:"user_agent" json:"user_agent,omitempty"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
}

View File

@@ -19,16 +19,17 @@ func New(db *sqlx.DB, authMW *auth.Middleware, cfg *config.Config, calDAVSvc *se
mux := http.NewServeMux()
// Services
tenantSvc := services.NewTenantService(db)
caseSvc := services.NewCaseService(db)
partySvc := services.NewPartyService(db)
appointmentSvc := services.NewAppointmentService(db)
auditSvc := services.NewAuditService(db)
tenantSvc := services.NewTenantService(db, auditSvc)
caseSvc := services.NewCaseService(db, auditSvc)
partySvc := services.NewPartyService(db, auditSvc)
appointmentSvc := services.NewAppointmentService(db, auditSvc)
holidaySvc := services.NewHolidayService(db)
deadlineSvc := services.NewDeadlineService(db)
deadlineSvc := services.NewDeadlineService(db, auditSvc)
deadlineRuleSvc := services.NewDeadlineRuleService(db)
calculator := services.NewDeadlineCalculator(holidaySvc)
storageCli := services.NewStorageClient(cfg.SupabaseURL, cfg.SupabaseServiceKey)
documentSvc := services.NewDocumentService(db, storageCli)
documentSvc := services.NewDocumentService(db, storageCli, auditSvc)
// AI service (optional — only if API key is configured)
var aiH *handlers.AIHandler
@@ -40,10 +41,11 @@ func New(db *sqlx.DB, authMW *auth.Middleware, cfg *config.Config, calDAVSvc *se
// Middleware
tenantResolver := auth.NewTenantResolver(tenantSvc)
noteSvc := services.NewNoteService(db)
noteSvc := services.NewNoteService(db, auditSvc)
dashboardSvc := services.NewDashboardService(db)
// Handlers
auditH := handlers.NewAuditLogHandler(auditSvc)
tenantH := handlers.NewTenantHandler(tenantSvc)
caseH := handlers.NewCaseHandler(caseSvc)
partyH := handlers.NewPartyHandler(partySvc)
@@ -123,6 +125,9 @@ func New(db *sqlx.DB, authMW *auth.Middleware, cfg *config.Config, calDAVSvc *se
// Dashboard
scoped.HandleFunc("GET /api/dashboard", dashboardH.Get)
// Audit log
scoped.HandleFunc("GET /api/audit-log", auditH.List)
// Documents
scoped.HandleFunc("GET /api/cases/{id}/documents", docH.ListByCase)
scoped.HandleFunc("POST /api/cases/{id}/documents", docH.Upload)

View File

@@ -12,11 +12,12 @@ import (
)
type AppointmentService struct {
db *sqlx.DB
db *sqlx.DB
audit *AuditService
}
func NewAppointmentService(db *sqlx.DB) *AppointmentService {
return &AppointmentService{db: db}
func NewAppointmentService(db *sqlx.DB, audit *AuditService) *AppointmentService {
return &AppointmentService{db: db, audit: audit}
}
type AppointmentFilter struct {
@@ -86,6 +87,7 @@ func (s *AppointmentService) Create(ctx context.Context, a *models.Appointment)
if err != nil {
return fmt.Errorf("creating appointment: %w", err)
}
s.audit.Log(ctx, "create", "appointment", &a.ID, nil, a)
return nil
}
@@ -116,6 +118,7 @@ func (s *AppointmentService) Update(ctx context.Context, a *models.Appointment)
if rows == 0 {
return fmt.Errorf("appointment not found")
}
s.audit.Log(ctx, "update", "appointment", &a.ID, nil, a)
return nil
}
@@ -131,5 +134,6 @@ func (s *AppointmentService) Delete(ctx context.Context, tenantID, id uuid.UUID)
if rows == 0 {
return fmt.Errorf("appointment not found")
}
s.audit.Log(ctx, "delete", "appointment", &id, nil, nil)
return nil
}

View File

@@ -0,0 +1,141 @@
package services
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/auth"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/models"
)
type AuditService struct {
db *sqlx.DB
}
func NewAuditService(db *sqlx.DB) *AuditService {
return &AuditService{db: db}
}
// Log records an audit entry. It extracts tenant, user, IP, and user-agent from context.
// Errors are logged but not returned — audit logging must not break business operations.
func (s *AuditService) Log(ctx context.Context, action, entityType string, entityID *uuid.UUID, oldValues, newValues any) {
tenantID, ok := auth.TenantFromContext(ctx)
if !ok {
slog.Warn("audit: missing tenant_id in context", "action", action, "entity_type", entityType)
return
}
var userID *uuid.UUID
if uid, ok := auth.UserFromContext(ctx); ok {
userID = &uid
}
var oldJSON, newJSON *json.RawMessage
if oldValues != nil {
if b, err := json.Marshal(oldValues); err == nil {
raw := json.RawMessage(b)
oldJSON = &raw
}
}
if newValues != nil {
if b, err := json.Marshal(newValues); err == nil {
raw := json.RawMessage(b)
newJSON = &raw
}
}
ip := auth.IPFromContext(ctx)
ua := auth.UserAgentFromContext(ctx)
_, err := s.db.ExecContext(ctx,
`INSERT INTO audit_log (tenant_id, user_id, action, entity_type, entity_id, old_values, new_values, ip_address, user_agent)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
tenantID, userID, action, entityType, entityID, oldJSON, newJSON, ip, ua)
if err != nil {
slog.Error("audit: failed to write log entry",
"error", err,
"action", action,
"entity_type", entityType,
"entity_id", entityID,
)
}
}
// AuditFilter holds query parameters for listing audit log entries.
type AuditFilter struct {
EntityType string
EntityID *uuid.UUID
UserID *uuid.UUID
From string // RFC3339 date
To string // RFC3339 date
Page int
Limit int
}
// List returns paginated audit log entries for a tenant.
func (s *AuditService) List(ctx context.Context, tenantID uuid.UUID, filter AuditFilter) ([]models.AuditLog, int, error) {
if filter.Limit <= 0 {
filter.Limit = 50
}
if filter.Limit > 200 {
filter.Limit = 200
}
if filter.Page <= 0 {
filter.Page = 1
}
offset := (filter.Page - 1) * filter.Limit
where := "WHERE tenant_id = $1"
args := []any{tenantID}
argIdx := 2
if filter.EntityType != "" {
where += fmt.Sprintf(" AND entity_type = $%d", argIdx)
args = append(args, filter.EntityType)
argIdx++
}
if filter.EntityID != nil {
where += fmt.Sprintf(" AND entity_id = $%d", argIdx)
args = append(args, *filter.EntityID)
argIdx++
}
if filter.UserID != nil {
where += fmt.Sprintf(" AND user_id = $%d", argIdx)
args = append(args, *filter.UserID)
argIdx++
}
if filter.From != "" {
where += fmt.Sprintf(" AND created_at >= $%d", argIdx)
args = append(args, filter.From)
argIdx++
}
if filter.To != "" {
where += fmt.Sprintf(" AND created_at <= $%d", argIdx)
args = append(args, filter.To)
argIdx++
}
var total int
if err := s.db.GetContext(ctx, &total, "SELECT COUNT(*) FROM audit_log "+where, args...); err != nil {
return nil, 0, fmt.Errorf("counting audit entries: %w", err)
}
query := fmt.Sprintf("SELECT * FROM audit_log %s ORDER BY created_at DESC LIMIT $%d OFFSET $%d",
where, argIdx, argIdx+1)
args = append(args, filter.Limit, offset)
var entries []models.AuditLog
if err := s.db.SelectContext(ctx, &entries, query, args...); err != nil {
return nil, 0, fmt.Errorf("listing audit entries: %w", err)
}
if entries == nil {
entries = []models.AuditLog{}
}
return entries, total, nil
}

View File

@@ -13,11 +13,12 @@ import (
)
type CaseService struct {
db *sqlx.DB
db *sqlx.DB
audit *AuditService
}
func NewCaseService(db *sqlx.DB) *CaseService {
return &CaseService{db: db}
func NewCaseService(db *sqlx.DB, audit *AuditService) *CaseService {
return &CaseService{db: db, audit: audit}
}
type CaseFilter struct {
@@ -162,6 +163,9 @@ func (s *CaseService) Create(ctx context.Context, tenantID uuid.UUID, userID uui
if err := s.db.GetContext(ctx, &c, "SELECT * FROM cases WHERE id = $1", id); err != nil {
return nil, fmt.Errorf("fetching created case: %w", err)
}
s.audit.Log(ctx, "create", "case", &id, nil, c)
return &c, nil
}
@@ -239,6 +243,9 @@ func (s *CaseService) Update(ctx context.Context, tenantID, caseID uuid.UUID, us
if err := s.db.GetContext(ctx, &updated, "SELECT * FROM cases WHERE id = $1", caseID); err != nil {
return nil, fmt.Errorf("fetching updated case: %w", err)
}
s.audit.Log(ctx, "update", "case", &caseID, current, updated)
return &updated, nil
}
@@ -254,6 +261,7 @@ func (s *CaseService) Delete(ctx context.Context, tenantID, caseID uuid.UUID, us
return sql.ErrNoRows
}
createEvent(ctx, s.db, tenantID, caseID, userID, "case_archived", "Case archived", nil)
s.audit.Log(ctx, "delete", "case", &caseID, map[string]string{"status": "active"}, map[string]string{"status": "archived"})
return nil
}

View File

@@ -1,6 +1,7 @@
package services
import (
"context"
"database/sql"
"fmt"
"time"
@@ -13,12 +14,13 @@ import (
// DeadlineService handles CRUD operations for case deadlines
type DeadlineService struct {
db *sqlx.DB
db *sqlx.DB
audit *AuditService
}
// NewDeadlineService creates a new deadline service
func NewDeadlineService(db *sqlx.DB) *DeadlineService {
return &DeadlineService{db: db}
func NewDeadlineService(db *sqlx.DB, audit *AuditService) *DeadlineService {
return &DeadlineService{db: db, audit: audit}
}
// ListAll returns all deadlines for a tenant, ordered by due_date
@@ -87,7 +89,7 @@ type CreateDeadlineInput struct {
}
// Create inserts a new deadline
func (s *DeadlineService) Create(tenantID uuid.UUID, input CreateDeadlineInput) (*models.Deadline, error) {
func (s *DeadlineService) Create(ctx context.Context, tenantID uuid.UUID, input CreateDeadlineInput) (*models.Deadline, error) {
id := uuid.New()
source := input.Source
if source == "" {
@@ -108,6 +110,7 @@ func (s *DeadlineService) Create(tenantID uuid.UUID, input CreateDeadlineInput)
if err != nil {
return nil, fmt.Errorf("creating deadline: %w", err)
}
s.audit.Log(ctx, "create", "deadline", &id, nil, d)
return &d, nil
}
@@ -123,7 +126,7 @@ type UpdateDeadlineInput struct {
}
// Update modifies an existing deadline
func (s *DeadlineService) Update(tenantID, deadlineID uuid.UUID, input UpdateDeadlineInput) (*models.Deadline, error) {
func (s *DeadlineService) Update(ctx context.Context, tenantID, deadlineID uuid.UUID, input UpdateDeadlineInput) (*models.Deadline, error) {
// First check it exists and belongs to tenant
existing, err := s.GetByID(tenantID, deadlineID)
if err != nil {
@@ -154,11 +157,12 @@ func (s *DeadlineService) Update(tenantID, deadlineID uuid.UUID, input UpdateDea
if err != nil {
return nil, fmt.Errorf("updating deadline: %w", err)
}
s.audit.Log(ctx, "update", "deadline", &deadlineID, existing, d)
return &d, nil
}
// Complete marks a deadline as completed
func (s *DeadlineService) Complete(tenantID, deadlineID uuid.UUID) (*models.Deadline, error) {
func (s *DeadlineService) Complete(ctx context.Context, tenantID, deadlineID uuid.UUID) (*models.Deadline, error) {
query := `UPDATE deadlines SET
status = 'completed',
completed_at = $1,
@@ -176,11 +180,12 @@ func (s *DeadlineService) Complete(tenantID, deadlineID uuid.UUID) (*models.Dead
}
return nil, fmt.Errorf("completing deadline: %w", err)
}
s.audit.Log(ctx, "update", "deadline", &deadlineID, map[string]string{"status": "pending"}, map[string]string{"status": "completed"})
return &d, nil
}
// Delete removes a deadline
func (s *DeadlineService) Delete(tenantID, deadlineID uuid.UUID) error {
func (s *DeadlineService) Delete(ctx context.Context, tenantID, deadlineID uuid.UUID) error {
query := `DELETE FROM deadlines WHERE id = $1 AND tenant_id = $2`
result, err := s.db.Exec(query, deadlineID, tenantID)
if err != nil {
@@ -193,5 +198,6 @@ func (s *DeadlineService) Delete(tenantID, deadlineID uuid.UUID) error {
if rows == 0 {
return fmt.Errorf("deadline not found")
}
s.audit.Log(ctx, "delete", "deadline", &deadlineID, nil, nil)
return nil
}

View File

@@ -18,10 +18,11 @@ const documentBucket = "kanzlai-documents"
type DocumentService struct {
db *sqlx.DB
storage *StorageClient
audit *AuditService
}
func NewDocumentService(db *sqlx.DB, storage *StorageClient) *DocumentService {
return &DocumentService{db: db, storage: storage}
func NewDocumentService(db *sqlx.DB, storage *StorageClient, audit *AuditService) *DocumentService {
return &DocumentService{db: db, storage: storage, audit: audit}
}
type CreateDocumentInput struct {
@@ -97,6 +98,7 @@ func (s *DocumentService) Create(ctx context.Context, tenantID, caseID, userID u
if err := s.db.GetContext(ctx, &doc, "SELECT * FROM documents WHERE id = $1", id); err != nil {
return nil, fmt.Errorf("fetching created document: %w", err)
}
s.audit.Log(ctx, "create", "document", &id, nil, doc)
return &doc, nil
}
@@ -151,6 +153,7 @@ func (s *DocumentService) Delete(ctx context.Context, tenantID, docID, userID uu
// Log case event
createEvent(ctx, s.db, tenantID, doc.CaseID, userID, "document_deleted",
fmt.Sprintf("Document deleted: %s", doc.Title), nil)
s.audit.Log(ctx, "delete", "document", &docID, doc, nil)
return nil
}

View File

@@ -13,11 +13,12 @@ import (
)
type NoteService struct {
db *sqlx.DB
db *sqlx.DB
audit *AuditService
}
func NewNoteService(db *sqlx.DB) *NoteService {
return &NoteService{db: db}
func NewNoteService(db *sqlx.DB, audit *AuditService) *NoteService {
return &NoteService{db: db, audit: audit}
}
// ListByParent returns all notes for a given parent entity, scoped to tenant.
@@ -68,6 +69,7 @@ func (s *NoteService) Create(ctx context.Context, tenantID uuid.UUID, createdBy
if err != nil {
return nil, fmt.Errorf("creating note: %w", err)
}
s.audit.Log(ctx, "create", "note", &id, nil, n)
return &n, nil
}
@@ -85,6 +87,7 @@ func (s *NoteService) Update(ctx context.Context, tenantID, noteID uuid.UUID, co
}
return nil, fmt.Errorf("updating note: %w", err)
}
s.audit.Log(ctx, "update", "note", &noteID, nil, n)
return &n, nil
}
@@ -101,6 +104,7 @@ func (s *NoteService) Delete(ctx context.Context, tenantID, noteID uuid.UUID) er
if rows == 0 {
return fmt.Errorf("note not found")
}
s.audit.Log(ctx, "delete", "note", &noteID, nil, nil)
return nil
}

View File

@@ -13,11 +13,12 @@ import (
)
type PartyService struct {
db *sqlx.DB
db *sqlx.DB
audit *AuditService
}
func NewPartyService(db *sqlx.DB) *PartyService {
return &PartyService{db: db}
func NewPartyService(db *sqlx.DB, audit *AuditService) *PartyService {
return &PartyService{db: db, audit: audit}
}
type CreatePartyInput struct {
@@ -79,6 +80,7 @@ func (s *PartyService) Create(ctx context.Context, tenantID, caseID uuid.UUID, u
if err := s.db.GetContext(ctx, &party, "SELECT * FROM parties WHERE id = $1", id); err != nil {
return nil, fmt.Errorf("fetching created party: %w", err)
}
s.audit.Log(ctx, "create", "party", &id, nil, party)
return &party, nil
}
@@ -135,6 +137,7 @@ func (s *PartyService) Update(ctx context.Context, tenantID, partyID uuid.UUID,
if err := s.db.GetContext(ctx, &updated, "SELECT * FROM parties WHERE id = $1", partyID); err != nil {
return nil, fmt.Errorf("fetching updated party: %w", err)
}
s.audit.Log(ctx, "update", "party", &partyID, current, updated)
return &updated, nil
}
@@ -148,5 +151,6 @@ func (s *PartyService) Delete(ctx context.Context, tenantID, partyID uuid.UUID)
if rows == 0 {
return sql.ErrNoRows
}
s.audit.Log(ctx, "delete", "party", &partyID, nil, nil)
return nil
}

View File

@@ -13,11 +13,12 @@ import (
)
type TenantService struct {
db *sqlx.DB
db *sqlx.DB
audit *AuditService
}
func NewTenantService(db *sqlx.DB) *TenantService {
return &TenantService{db: db}
func NewTenantService(db *sqlx.DB, audit *AuditService) *TenantService {
return &TenantService{db: db, audit: audit}
}
// Create creates a new tenant and assigns the creator as owner.
@@ -49,6 +50,7 @@ func (s *TenantService) Create(ctx context.Context, userID uuid.UUID, name, slug
return nil, fmt.Errorf("commit: %w", err)
}
s.audit.Log(ctx, "create", "tenant", &tenant.ID, nil, tenant)
return &tenant, nil
}
@@ -171,6 +173,7 @@ func (s *TenantService) InviteByEmail(ctx context.Context, tenantID uuid.UUID, e
return nil, fmt.Errorf("invite user: %w", err)
}
s.audit.Log(ctx, "create", "membership", &tenantID, nil, ut)
return &ut, nil
}
@@ -186,6 +189,7 @@ func (s *TenantService) UpdateSettings(ctx context.Context, tenantID uuid.UUID,
if err != nil {
return nil, fmt.Errorf("update settings: %w", err)
}
s.audit.Log(ctx, "update", "settings", &tenantID, nil, settings)
return &tenant, nil
}
@@ -223,5 +227,6 @@ func (s *TenantService) RemoveMember(ctx context.Context, tenantID, userID uuid.
return fmt.Errorf("remove member: %w", err)
}
s.audit.Log(ctx, "delete", "membership", &tenantID, map[string]any{"user_id": userID, "role": role}, nil)
return nil
}

View File

@@ -15,6 +15,7 @@ import {
Users,
StickyNote,
AlertTriangle,
ScrollText,
} from "lucide-react";
import { format } from "date-fns";
import { de } from "date-fns/locale";
@@ -44,6 +45,7 @@ const TABS = [
{ segment: "dokumente", label: "Dokumente", icon: FileText },
{ segment: "parteien", label: "Parteien", icon: Users },
{ segment: "notizen", label: "Notizen", icon: StickyNote },
{ segment: "protokoll", label: "Protokoll", icon: ScrollText },
] as const;
const TAB_LABELS: Record<string, string> = {
@@ -52,6 +54,7 @@ const TAB_LABELS: Record<string, string> = {
dokumente: "Dokumente",
parteien: "Parteien",
notizen: "Notizen",
protokoll: "Protokoll",
};
function CaseDetailSkeleton() {

View File

@@ -0,0 +1,178 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { useParams, useSearchParams } from "next/navigation";
import { api } from "@/lib/api";
import type { AuditLogResponse } from "@/lib/types";
import { format } from "date-fns";
import { de } from "date-fns/locale";
import { Loader2, ChevronLeft, ChevronRight } from "lucide-react";
const ACTION_LABELS: Record<string, string> = {
create: "Erstellt",
update: "Aktualisiert",
delete: "Geloescht",
};
const ACTION_COLORS: Record<string, string> = {
create: "bg-emerald-50 text-emerald-700",
update: "bg-blue-50 text-blue-700",
delete: "bg-red-50 text-red-700",
};
const ENTITY_LABELS: Record<string, string> = {
case: "Akte",
deadline: "Frist",
appointment: "Termin",
document: "Dokument",
party: "Partei",
note: "Notiz",
settings: "Einstellungen",
membership: "Mitgliedschaft",
};
function DiffPreview({
oldValues,
newValues,
}: {
oldValues?: Record<string, unknown>;
newValues?: Record<string, unknown>;
}) {
if (!oldValues && !newValues) return null;
const allKeys = new Set([
...Object.keys(oldValues ?? {}),
...Object.keys(newValues ?? {}),
]);
const changes: { key: string; from?: unknown; to?: unknown }[] = [];
for (const key of allKeys) {
const oldVal = oldValues?.[key];
const newVal = newValues?.[key];
if (JSON.stringify(oldVal) !== JSON.stringify(newVal)) {
changes.push({ key, from: oldVal, to: newVal });
}
}
if (changes.length === 0) return null;
return (
<div className="mt-2 space-y-1">
{changes.slice(0, 5).map((c) => (
<div key={c.key} className="flex items-baseline gap-2 text-xs">
<span className="font-medium text-neutral-500">{c.key}:</span>
{c.from !== undefined && (
<span className="rounded bg-red-50 px-1 text-red-600 line-through">
{String(c.from)}
</span>
)}
{c.to !== undefined && (
<span className="rounded bg-emerald-50 px-1 text-emerald-600">
{String(c.to)}
</span>
)}
</div>
))}
{changes.length > 5 && (
<span className="text-xs text-neutral-400">
+{changes.length - 5} weitere Aenderungen
</span>
)}
</div>
);
}
export default function ProtokollPage() {
const { id } = useParams<{ id: string }>();
const searchParams = useSearchParams();
const page = Number(searchParams.get("page")) || 1;
const { data, isLoading } = useQuery({
queryKey: ["audit-log", id, page],
queryFn: () =>
api.get<AuditLogResponse>(
`/audit-log?entity_id=${id}&page=${page}&limit=50`,
),
});
if (isLoading) {
return (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-5 w-5 animate-spin text-neutral-400" />
</div>
);
}
const entries = data?.entries ?? [];
const total = data?.total ?? 0;
const totalPages = Math.ceil(total / 50);
if (entries.length === 0) {
return (
<div className="py-8 text-center text-sm text-neutral-400">
Keine Protokolleintraege vorhanden.
</div>
);
}
return (
<div>
<div className="space-y-3">
{entries.map((entry) => (
<div
key={entry.id}
className="rounded-md border border-neutral-100 bg-white px-4 py-3"
>
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-2">
<span
className={`inline-block rounded-full px-2 py-0.5 text-xs font-medium ${ACTION_COLORS[entry.action] ?? "bg-neutral-100 text-neutral-600"}`}
>
{ACTION_LABELS[entry.action] ?? entry.action}
</span>
<span className="text-sm font-medium text-neutral-700">
{ENTITY_LABELS[entry.entity_type] ?? entry.entity_type}
</span>
</div>
<span className="shrink-0 text-xs text-neutral-400">
{format(new Date(entry.created_at), "d. MMM yyyy, HH:mm", {
locale: de,
})}
</span>
</div>
<DiffPreview
oldValues={entry.old_values}
newValues={entry.new_values}
/>
</div>
))}
</div>
{totalPages > 1 && (
<div className="mt-4 flex items-center justify-between">
<span className="text-xs text-neutral-400">
{total} Eintraege, Seite {page} von {totalPages}
</span>
<div className="flex gap-1">
{page > 1 && (
<a
href={`?page=${page - 1}`}
className="inline-flex items-center gap-1 rounded-md border border-neutral-200 px-2 py-1 text-xs text-neutral-600 hover:bg-neutral-50"
>
<ChevronLeft className="h-3 w-3" /> Zurueck
</a>
)}
{page < totalPages && (
<a
href={`?page=${page + 1}`}
className="inline-flex items-center gap-1 rounded-md border border-neutral-200 px-2 py-1 text-xs text-neutral-600 hover:bg-neutral-50"
>
Weiter <ChevronRight className="h-3 w-3" />
</a>
)}
</div>
</div>
)}
</div>
);
}

View File

@@ -189,6 +189,27 @@ export interface Note {
updated_at: string;
}
export interface AuditLogEntry {
id: number;
tenant_id: string;
user_id?: string;
action: string;
entity_type: string;
entity_id?: string;
old_values?: Record<string, unknown>;
new_values?: Record<string, unknown>;
ip_address?: string;
user_agent?: string;
created_at: string;
}
export interface AuditLogResponse {
entries: AuditLogEntry[];
total: number;
page: number;
limit: number;
}
export interface ApiError {
error: string;
status: number;