Compare commits

...

8 Commits

Author SHA1 Message Date
m
642877ae54 feat: document templates with auto-fill from case data (P1)
- Database: kanzlai.document_templates table with RLS policies
- Seed: 4 system templates (Klageerwiderung UPC, Berufungsschrift,
  Mandatsbestätigung, Kostenrechnung)
- Backend: TemplateService (CRUD + render), TemplateHandler with
  endpoints: GET/POST /api/templates, GET/PUT/DELETE /api/templates/{id},
  POST /api/templates/{id}/render?case_id=X
- Template variables: case.*, party.*, tenant.*, user.*, date.*, deadline.*
- Frontend: /vorlagen page with category filters, template detail/editor,
  render flow (select case -> preview -> copy/download), variable toolbar
- Quick action: "Schriftsatz erstellen" button on case detail page
- Also: resolved merge conflicts between audit-trail and role-based branches,
  added missing Notification/AuditLog types to frontend
2026-03-30 11:26:25 +02:00
m
8e65463130 feat: role-based permissions — owner/partner/associate/paralegal/secretary (P0) 2026-03-30 11:09:05 +02:00
m
a307b29db8 feat: email notifications + deadline reminder system (P0) 2026-03-30 11:08:53 +02:00
m
5e88384fab feat: append-only audit trail for all mutations (P0) 2026-03-30 11:08:41 +02:00
m
ac20c03f01 feat: email notifications + deadline reminder system
Database:
- notification_preferences table (user_id, tenant_id, reminder days, email/digest toggles)
- notifications table (type, entity link, read/sent tracking, dedup index)

Backend:
- NotificationService with background goroutine checking reminders hourly
- CheckDeadlineReminders: finds deadlines due in N days per user prefs, creates notifications
- Overdue deadline detection and notification
- Daily digest at 8am: compiles pending notifications into one email
- SendEmail via `m mail send` CLI command
- Deduplication: same notification type + entity + day = skip
- API: GET/PATCH notifications, unread count, mark read/all-read
- API: GET/PUT notification-preferences with upsert

Frontend:
- NotificationBell in header with unread count badge (polls every 30s)
- Dropdown panel with notification list, type-colored dots, time-ago, entity links
- Mark individual/all as read
- NotificationSettings in Einstellungen page: reminder day toggles, email toggle, digest toggle
2026-03-30 11:03:17 +02:00
m
c324a2b5c7 fix: critical security hardening — tenant isolation, CORS, error masking, input validation 2026-03-30 11:02:52 +02:00
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
c15d5b72f2 fix: critical security hardening — tenant isolation, CORS, error leaking, input validation
1. Tenant isolation bypass (CRITICAL): TenantResolver now verifies user
   has access to X-Tenant-ID via user_tenants lookup before setting context.
   Added VerifyAccess method to TenantLookup interface and TenantService.

2. Consolidated tenant resolution: Removed duplicate resolveTenant() from
   helpers.go and tenant resolution from auth middleware. TenantResolver is
   now the single source of truth. Deadlines and AI handlers use
   auth.TenantFromContext() instead of direct DB queries.

3. CalDAV credential masking: tenant settings responses now mask CalDAV
   passwords with "********" via maskSettingsPassword helper. Applied to
   GetTenant, ListTenants, and UpdateSettings responses.

4. CORS + security headers: New middleware/security.go with CORS
   (restricted to FRONTEND_ORIGIN) and security headers (X-Frame-Options,
   X-Content-Type-Options, HSTS, Referrer-Policy, X-XSS-Protection).

5. Internal error leaking: All writeError(w, 500, err.Error()) replaced
   with internalError() that logs via slog and returns generic "internal
   error" to client. Same for jsonError in tenant handler.

6. Input validation: Max length on title (500), description (10000),
   case_number (100), search (200). Pagination clamped to max 100.
   Content-Disposition filename sanitized against header injection.

Regression test added for tenant access denial (403 on unauthorized
X-Tenant-ID). All existing tests pass, go vet clean.
2026-03-30 11:01:14 +02:00
50 changed files with 3474 additions and 236 deletions

View File

@@ -36,7 +36,12 @@ func main() {
calDAVSvc.Start() calDAVSvc.Start()
defer calDAVSvc.Stop() defer calDAVSvc.Stop()
handler := router.New(database, authMW, cfg, calDAVSvc) // Start notification reminder service
notifSvc := services.NewNotificationService(database)
notifSvc.Start()
defer notifSvc.Stop()
handler := router.New(database, authMW, cfg, calDAVSvc, notifSvc)
slog.Info("starting KanzlAI API server", "port", cfg.Port) slog.Info("starting KanzlAI API server", "port", cfg.Port)
if err := http.ListenAndServe(":"+cfg.Port, handler); err != nil { if err := http.ListenAndServe(":"+cfg.Port, handler); err != nil {

View File

@@ -9,9 +9,11 @@ import (
type contextKey string type contextKey string
const ( const (
userIDKey contextKey = "user_id" userIDKey contextKey = "user_id"
tenantIDKey contextKey = "tenant_id" tenantIDKey contextKey = "tenant_id"
userRoleKey contextKey = "user_role" ipKey contextKey = "ip_address"
userAgentKey contextKey = "user_agent"
userRoleKey contextKey = "user_role"
) )
func ContextWithUserID(ctx context.Context, userID uuid.UUID) context.Context { func ContextWithUserID(ctx context.Context, userID uuid.UUID) context.Context {
@@ -32,6 +34,26 @@ func TenantFromContext(ctx context.Context) (uuid.UUID, bool) {
return id, ok 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
}
func ContextWithUserRole(ctx context.Context, role string) context.Context { func ContextWithUserRole(ctx context.Context, role string) context.Context {
return context.WithValue(ctx, userRoleKey, role) return context.WithValue(ctx, userRoleKey, role)
} }

View File

@@ -24,31 +24,27 @@ func (m *Middleware) RequireAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token := extractBearerToken(r) token := extractBearerToken(r)
if token == "" { if token == "" {
http.Error(w, "missing authorization token", http.StatusUnauthorized) http.Error(w, `{"error":"missing authorization token"}`, http.StatusUnauthorized)
return return
} }
userID, err := m.verifyJWT(token) userID, err := m.verifyJWT(token)
if err != nil { if err != nil {
http.Error(w, fmt.Sprintf("invalid token: %v", err), http.StatusUnauthorized) http.Error(w, `{"error":"invalid token"}`, http.StatusUnauthorized)
return return
} }
ctx := ContextWithUserID(r.Context(), userID) ctx := ContextWithUserID(r.Context(), userID)
// Resolve tenant and role from user_tenants // Capture IP and user-agent for audit logging
var membership struct { ip := r.Header.Get("X-Forwarded-For")
TenantID uuid.UUID `db:"tenant_id"` if ip == "" {
Role string `db:"role"` ip = r.RemoteAddr
} }
err = m.db.GetContext(r.Context(), &membership, ctx = ContextWithRequestInfo(ctx, ip, r.UserAgent())
"SELECT tenant_id, role FROM user_tenants WHERE user_id = $1 LIMIT 1", userID)
if err != nil { // Tenant resolution is handled by TenantResolver middleware for scoped routes.
http.Error(w, "no tenant found for user", http.StatusForbidden) // Tenant management routes handle their own access control.
return
}
ctx = ContextWithTenantID(ctx, membership.TenantID)
ctx = ContextWithUserRole(ctx, membership.Role)
next.ServeHTTP(w, r.WithContext(ctx)) next.ServeHTTP(w, r.WithContext(ctx))
}) })

View File

@@ -2,21 +2,22 @@ package auth
import ( import (
"context" "context"
"fmt" "log/slog"
"net/http" "net/http"
"github.com/google/uuid" "github.com/google/uuid"
) )
// TenantLookup resolves the default tenant for a user. // TenantLookup resolves and verifies tenant access for a user.
// Defined as an interface to avoid circular dependency with services. // Defined as an interface to avoid circular dependency with services.
type TenantLookup interface { type TenantLookup interface {
FirstTenantForUser(ctx context.Context, userID uuid.UUID) (*uuid.UUID, error) FirstTenantForUser(ctx context.Context, userID uuid.UUID) (*uuid.UUID, error)
VerifyAccess(ctx context.Context, userID, tenantID uuid.UUID) (bool, error)
GetUserRole(ctx context.Context, userID, tenantID uuid.UUID) (string, error) GetUserRole(ctx context.Context, userID, tenantID uuid.UUID) (string, error)
} }
// TenantResolver is middleware that resolves the tenant from X-Tenant-ID header // TenantResolver is middleware that resolves the tenant from X-Tenant-ID header
// or defaults to the user's first tenant. // or defaults to the user's first tenant. Always verifies user has access.
type TenantResolver struct { type TenantResolver struct {
lookup TenantLookup lookup TenantLookup
} }
@@ -29,46 +30,59 @@ func (tr *TenantResolver) Resolve(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
userID, ok := UserFromContext(r.Context()) userID, ok := UserFromContext(r.Context())
if !ok { if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized) http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
return return
} }
var tenantID uuid.UUID var tenantID uuid.UUID
ctx := r.Context()
if header := r.Header.Get("X-Tenant-ID"); header != "" { if header := r.Header.Get("X-Tenant-ID"); header != "" {
parsed, err := uuid.Parse(header) parsed, err := uuid.Parse(header)
if err != nil { if err != nil {
http.Error(w, fmt.Sprintf("invalid X-Tenant-ID: %v", err), http.StatusBadRequest) http.Error(w, `{"error":"invalid X-Tenant-ID"}`, http.StatusBadRequest)
return return
} }
// Verify user has access and get their role // Verify user has access and get their role
role, err := tr.lookup.GetUserRole(r.Context(), userID, parsed) role, err := tr.lookup.GetUserRole(r.Context(), userID, parsed)
if err != nil { if err != nil {
http.Error(w, "error checking tenant access", http.StatusInternalServerError) slog.Error("tenant access check failed", "error", err, "user_id", userID, "tenant_id", parsed)
http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError)
return return
} }
if role == "" { if role == "" {
http.Error(w, "no access to this tenant", http.StatusForbidden) http.Error(w, `{"error":"no access to tenant"}`, http.StatusForbidden)
return return
} }
tenantID = parsed tenantID = parsed
// Override the role from middleware with the correct one for this tenant ctx = ContextWithUserRole(ctx, role)
r = r.WithContext(ContextWithUserRole(r.Context(), role))
} else { } else {
// Default to user's first tenant (role already set by middleware) // Default to user's first tenant
first, err := tr.lookup.FirstTenantForUser(r.Context(), userID) first, err := tr.lookup.FirstTenantForUser(r.Context(), userID)
if err != nil { if err != nil {
http.Error(w, fmt.Sprintf("resolving tenant: %v", err), http.StatusInternalServerError) slog.Error("failed to resolve default tenant", "error", err, "user_id", userID)
http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError)
return return
} }
if first == nil { if first == nil {
http.Error(w, "no tenant found for user", http.StatusBadRequest) http.Error(w, `{"error":"no tenant found for user"}`, http.StatusBadRequest)
return return
} }
tenantID = *first tenantID = *first
// Get role for default tenant
role, err := tr.lookup.GetUserRole(r.Context(), userID, tenantID)
if err != nil {
slog.Error("failed to get user role", "error", err, "user_id", userID, "tenant_id", tenantID)
http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError)
return
}
ctx = ContextWithUserRole(ctx, role)
} }
ctx := ContextWithTenantID(r.Context(), tenantID) ctx = ContextWithTenantID(ctx, tenantID)
next.ServeHTTP(w, r.WithContext(ctx)) next.ServeHTTP(w, r.WithContext(ctx))
}) })
} }

View File

@@ -10,25 +10,34 @@ import (
) )
type mockTenantLookup struct { type mockTenantLookup struct {
tenantID *uuid.UUID tenantID *uuid.UUID
role string err error
err error hasAccess bool
accessErr error
role string
} }
func (m *mockTenantLookup) FirstTenantForUser(ctx context.Context, userID uuid.UUID) (*uuid.UUID, error) { func (m *mockTenantLookup) FirstTenantForUser(ctx context.Context, userID uuid.UUID) (*uuid.UUID, error) {
return m.tenantID, m.err return m.tenantID, m.err
} }
func (m *mockTenantLookup) VerifyAccess(ctx context.Context, userID, tenantID uuid.UUID) (bool, error) {
return m.hasAccess, m.accessErr
}
func (m *mockTenantLookup) GetUserRole(ctx context.Context, userID, tenantID uuid.UUID) (string, error) { func (m *mockTenantLookup) GetUserRole(ctx context.Context, userID, tenantID uuid.UUID) (string, error) {
if m.role != "" { if m.role != "" {
return m.role, m.err return m.role, m.err
} }
return "associate", m.err if m.hasAccess {
return "associate", m.err
}
return "", m.err
} }
func TestTenantResolver_FromHeader(t *testing.T) { func TestTenantResolver_FromHeader(t *testing.T) {
tenantID := uuid.New() tenantID := uuid.New()
tr := NewTenantResolver(&mockTenantLookup{role: "partner"}) tr := NewTenantResolver(&mockTenantLookup{hasAccess: true, role: "partner"})
var gotTenantID uuid.UUID var gotTenantID uuid.UUID
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -55,9 +64,29 @@ func TestTenantResolver_FromHeader(t *testing.T) {
} }
} }
func TestTenantResolver_FromHeader_NoAccess(t *testing.T) {
tenantID := uuid.New()
tr := NewTenantResolver(&mockTenantLookup{hasAccess: false})
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatal("next should not be called")
})
r := httptest.NewRequest("GET", "/api/cases", nil)
r.Header.Set("X-Tenant-ID", tenantID.String())
r = r.WithContext(ContextWithUserID(r.Context(), uuid.New()))
w := httptest.NewRecorder()
tr.Resolve(next).ServeHTTP(w, r)
if w.Code != http.StatusForbidden {
t.Errorf("expected 403, got %d", w.Code)
}
}
func TestTenantResolver_DefaultsToFirst(t *testing.T) { func TestTenantResolver_DefaultsToFirst(t *testing.T) {
tenantID := uuid.New() tenantID := uuid.New()
tr := NewTenantResolver(&mockTenantLookup{tenantID: &tenantID}) tr := NewTenantResolver(&mockTenantLookup{tenantID: &tenantID, role: "associate"})
var gotTenantID uuid.UUID var gotTenantID uuid.UUID
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {

View File

@@ -13,6 +13,7 @@ type Config struct {
SupabaseServiceKey string SupabaseServiceKey string
SupabaseJWTSecret string SupabaseJWTSecret string
AnthropicAPIKey string AnthropicAPIKey string
FrontendOrigin string
} }
func Load() (*Config, error) { func Load() (*Config, error) {
@@ -24,6 +25,7 @@ func Load() (*Config, error) {
SupabaseServiceKey: os.Getenv("SUPABASE_SERVICE_KEY"), SupabaseServiceKey: os.Getenv("SUPABASE_SERVICE_KEY"),
SupabaseJWTSecret: os.Getenv("SUPABASE_JWT_SECRET"), SupabaseJWTSecret: os.Getenv("SUPABASE_JWT_SECRET"),
AnthropicAPIKey: os.Getenv("ANTHROPIC_API_KEY"), AnthropicAPIKey: os.Getenv("ANTHROPIC_API_KEY"),
FrontendOrigin: getEnv("FRONTEND_ORIGIN", "https://kanzlai.msbls.de"),
} }
if cfg.DatabaseURL == "" { if cfg.DatabaseURL == "" {

View File

@@ -5,18 +5,16 @@ import (
"io" "io"
"net/http" "net/http"
"github.com/jmoiron/sqlx" "mgit.msbls.de/m/KanzlAI-mGMT/internal/auth"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/services" "mgit.msbls.de/m/KanzlAI-mGMT/internal/services"
) )
type AIHandler struct { type AIHandler struct {
ai *services.AIService ai *services.AIService
db *sqlx.DB
} }
func NewAIHandler(ai *services.AIService, db *sqlx.DB) *AIHandler { func NewAIHandler(ai *services.AIService) *AIHandler {
return &AIHandler{ai: ai, db: db} return &AIHandler{ai: ai}
} }
// ExtractDeadlines handles POST /api/ai/extract-deadlines // ExtractDeadlines handles POST /api/ai/extract-deadlines
@@ -61,10 +59,14 @@ func (h *AIHandler) ExtractDeadlines(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, "provide either a PDF file or text") writeError(w, http.StatusBadRequest, "provide either a PDF file or text")
return return
} }
if len(text) > maxDescriptionLen {
writeError(w, http.StatusBadRequest, "text exceeds maximum length")
return
}
deadlines, err := h.ai.ExtractDeadlines(r.Context(), pdfData, text) deadlines, err := h.ai.ExtractDeadlines(r.Context(), pdfData, text)
if err != nil { if err != nil {
writeError(w, http.StatusInternalServerError, "AI extraction failed: "+err.Error()) internalError(w, "AI deadline extraction failed", err)
return return
} }
@@ -77,9 +79,9 @@ func (h *AIHandler) ExtractDeadlines(w http.ResponseWriter, r *http.Request) {
// SummarizeCase handles POST /api/ai/summarize-case // SummarizeCase handles POST /api/ai/summarize-case
// Accepts JSON {"case_id": "uuid"}. // Accepts JSON {"case_id": "uuid"}.
func (h *AIHandler) SummarizeCase(w http.ResponseWriter, r *http.Request) { func (h *AIHandler) SummarizeCase(w http.ResponseWriter, r *http.Request) {
tenantID, err := resolveTenant(r, h.db) tenantID, ok := auth.TenantFromContext(r.Context())
if err != nil { if !ok {
handleTenantError(w, err) writeError(w, http.StatusForbidden, "missing tenant")
return return
} }
@@ -104,7 +106,7 @@ func (h *AIHandler) SummarizeCase(w http.ResponseWriter, r *http.Request) {
summary, err := h.ai.SummarizeCase(r.Context(), tenantID, caseID) summary, err := h.ai.SummarizeCase(r.Context(), tenantID, caseID)
if err != nil { if err != nil {
writeError(w, http.StatusInternalServerError, "AI summarization failed: "+err.Error()) internalError(w, "AI case summarization failed", err)
return return
} }

View File

@@ -42,7 +42,7 @@ func TestAIExtractDeadlines_InvalidJSON(t *testing.T) {
} }
} }
func TestAISummarizeCase_MissingCaseID(t *testing.T) { func TestAISummarizeCase_MissingTenant(t *testing.T) {
h := &AIHandler{} h := &AIHandler{}
body := `{"case_id":""}` body := `{"case_id":""}`
@@ -52,9 +52,9 @@ func TestAISummarizeCase_MissingCaseID(t *testing.T) {
h.SummarizeCase(w, r) h.SummarizeCase(w, r)
// Without auth context, the resolveTenant will fail first // Without tenant context, TenantFromContext returns !ok → 403
if w.Code != http.StatusUnauthorized { if w.Code != http.StatusForbidden {
t.Errorf("expected 401, got %d", w.Code) t.Errorf("expected 403, got %d", w.Code)
} }
} }
@@ -67,8 +67,8 @@ func TestAISummarizeCase_InvalidJSON(t *testing.T) {
h.SummarizeCase(w, r) h.SummarizeCase(w, r)
// Without auth context, the resolveTenant will fail first // Without tenant context, TenantFromContext returns !ok → 403
if w.Code != http.StatusUnauthorized { if w.Code != http.StatusForbidden {
t.Errorf("expected 401, got %d", w.Code) t.Errorf("expected 403, got %d", w.Code)
} }
} }

View File

@@ -121,6 +121,10 @@ func (h *AppointmentHandler) Create(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, "title is required") writeError(w, http.StatusBadRequest, "title is required")
return return
} }
if msg := validateStringLength("title", req.Title, maxTitleLen); msg != "" {
writeError(w, http.StatusBadRequest, msg)
return
}
if req.StartAt.IsZero() { if req.StartAt.IsZero() {
writeError(w, http.StatusBadRequest, "start_at is required") writeError(w, http.StatusBadRequest, "start_at is required")
return return
@@ -188,6 +192,10 @@ func (h *AppointmentHandler) Update(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, "title is required") writeError(w, http.StatusBadRequest, "title is required")
return return
} }
if msg := validateStringLength("title", req.Title, maxTitleLen); msg != "" {
writeError(w, http.StatusBadRequest, msg)
return
}
if req.StartAt.IsZero() { if req.StartAt.IsZero() {
writeError(w, http.StatusBadRequest, "start_at is required") writeError(w, http.StatusBadRequest, "start_at is required")
return return

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

@@ -27,7 +27,7 @@ func (h *CalDAVHandler) TriggerSync(w http.ResponseWriter, r *http.Request) {
cfg, err := h.svc.LoadTenantConfig(tenantID) cfg, err := h.svc.LoadTenantConfig(tenantID)
if err != nil { if err != nil {
writeError(w, http.StatusBadRequest, err.Error()) writeError(w, http.StatusBadRequest, "CalDAV not configured for this tenant")
return return
} }

View File

@@ -28,18 +28,25 @@ func (h *CaseHandler) List(w http.ResponseWriter, r *http.Request) {
limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
offset, _ := strconv.Atoi(r.URL.Query().Get("offset")) offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
limit, offset = clampPagination(limit, offset)
search := r.URL.Query().Get("search")
if msg := validateStringLength("search", search, maxSearchLen); msg != "" {
writeError(w, http.StatusBadRequest, msg)
return
}
filter := services.CaseFilter{ filter := services.CaseFilter{
Status: r.URL.Query().Get("status"), Status: r.URL.Query().Get("status"),
Type: r.URL.Query().Get("type"), Type: r.URL.Query().Get("type"),
Search: r.URL.Query().Get("search"), Search: search,
Limit: limit, Limit: limit,
Offset: offset, Offset: offset,
} }
cases, total, err := h.svc.List(r.Context(), tenantID, filter) cases, total, err := h.svc.List(r.Context(), tenantID, filter)
if err != nil { if err != nil {
writeError(w, http.StatusInternalServerError, err.Error()) internalError(w, "failed to list cases", err)
return return
} }
@@ -66,10 +73,18 @@ func (h *CaseHandler) Create(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, "case_number and title are required") writeError(w, http.StatusBadRequest, "case_number and title are required")
return return
} }
if msg := validateStringLength("case_number", input.CaseNumber, maxCaseNumberLen); msg != "" {
writeError(w, http.StatusBadRequest, msg)
return
}
if msg := validateStringLength("title", input.Title, maxTitleLen); msg != "" {
writeError(w, http.StatusBadRequest, msg)
return
}
c, err := h.svc.Create(r.Context(), tenantID, userID, input) c, err := h.svc.Create(r.Context(), tenantID, userID, input)
if err != nil { if err != nil {
writeError(w, http.StatusInternalServerError, err.Error()) internalError(w, "failed to create case", err)
return return
} }
@@ -91,7 +106,7 @@ func (h *CaseHandler) Get(w http.ResponseWriter, r *http.Request) {
detail, err := h.svc.GetByID(r.Context(), tenantID, caseID) detail, err := h.svc.GetByID(r.Context(), tenantID, caseID)
if err != nil { if err != nil {
writeError(w, http.StatusInternalServerError, err.Error()) internalError(w, "failed to get case", err)
return return
} }
if detail == nil { if detail == nil {
@@ -121,10 +136,22 @@ func (h *CaseHandler) Update(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, "invalid JSON body") writeError(w, http.StatusBadRequest, "invalid JSON body")
return return
} }
if input.Title != nil {
if msg := validateStringLength("title", *input.Title, maxTitleLen); msg != "" {
writeError(w, http.StatusBadRequest, msg)
return
}
}
if input.CaseNumber != nil {
if msg := validateStringLength("case_number", *input.CaseNumber, maxCaseNumberLen); msg != "" {
writeError(w, http.StatusBadRequest, msg)
return
}
}
updated, err := h.svc.Update(r.Context(), tenantID, caseID, userID, input) updated, err := h.svc.Update(r.Context(), tenantID, caseID, userID, input)
if err != nil { if err != nil {
writeError(w, http.StatusInternalServerError, err.Error()) internalError(w, "failed to update case", err)
return return
} }
if updated == nil { if updated == nil {

View File

@@ -24,7 +24,7 @@ func (h *DashboardHandler) Get(w http.ResponseWriter, r *http.Request) {
data, err := h.svc.Get(r.Context(), tenantID) data, err := h.svc.Get(r.Context(), tenantID)
if err != nil { if err != nil {
writeError(w, http.StatusInternalServerError, err.Error()) internalError(w, "failed to load dashboard", err)
return return
} }

View File

@@ -4,27 +4,25 @@ import (
"encoding/json" "encoding/json"
"net/http" "net/http"
"github.com/jmoiron/sqlx" "mgit.msbls.de/m/KanzlAI-mGMT/internal/auth"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/services" "mgit.msbls.de/m/KanzlAI-mGMT/internal/services"
) )
// DeadlineHandlers holds handlers for deadline CRUD endpoints // DeadlineHandlers holds handlers for deadline CRUD endpoints
type DeadlineHandlers struct { type DeadlineHandlers struct {
deadlines *services.DeadlineService deadlines *services.DeadlineService
db *sqlx.DB
} }
// NewDeadlineHandlers creates deadline handlers // NewDeadlineHandlers creates deadline handlers
func NewDeadlineHandlers(ds *services.DeadlineService, db *sqlx.DB) *DeadlineHandlers { func NewDeadlineHandlers(ds *services.DeadlineService) *DeadlineHandlers {
return &DeadlineHandlers{deadlines: ds, db: db} return &DeadlineHandlers{deadlines: ds}
} }
// Get handles GET /api/deadlines/{deadlineID} // Get handles GET /api/deadlines/{deadlineID}
func (h *DeadlineHandlers) Get(w http.ResponseWriter, r *http.Request) { func (h *DeadlineHandlers) Get(w http.ResponseWriter, r *http.Request) {
tenantID, err := resolveTenant(r, h.db) tenantID, ok := auth.TenantFromContext(r.Context())
if err != nil { if !ok {
handleTenantError(w, err) writeError(w, http.StatusForbidden, "missing tenant")
return return
} }
@@ -36,7 +34,7 @@ func (h *DeadlineHandlers) Get(w http.ResponseWriter, r *http.Request) {
deadline, err := h.deadlines.GetByID(tenantID, deadlineID) deadline, err := h.deadlines.GetByID(tenantID, deadlineID)
if err != nil { if err != nil {
writeError(w, http.StatusInternalServerError, "failed to fetch deadline") internalError(w, "failed to fetch deadline", err)
return return
} }
if deadline == nil { if deadline == nil {
@@ -49,15 +47,15 @@ func (h *DeadlineHandlers) Get(w http.ResponseWriter, r *http.Request) {
// ListAll handles GET /api/deadlines // ListAll handles GET /api/deadlines
func (h *DeadlineHandlers) ListAll(w http.ResponseWriter, r *http.Request) { func (h *DeadlineHandlers) ListAll(w http.ResponseWriter, r *http.Request) {
tenantID, err := resolveTenant(r, h.db) tenantID, ok := auth.TenantFromContext(r.Context())
if err != nil { if !ok {
handleTenantError(w, err) writeError(w, http.StatusForbidden, "missing tenant")
return return
} }
deadlines, err := h.deadlines.ListAll(tenantID) deadlines, err := h.deadlines.ListAll(tenantID)
if err != nil { if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list deadlines") internalError(w, "failed to list deadlines", err)
return return
} }
@@ -66,9 +64,9 @@ func (h *DeadlineHandlers) ListAll(w http.ResponseWriter, r *http.Request) {
// ListForCase handles GET /api/cases/{caseID}/deadlines // ListForCase handles GET /api/cases/{caseID}/deadlines
func (h *DeadlineHandlers) ListForCase(w http.ResponseWriter, r *http.Request) { func (h *DeadlineHandlers) ListForCase(w http.ResponseWriter, r *http.Request) {
tenantID, err := resolveTenant(r, h.db) tenantID, ok := auth.TenantFromContext(r.Context())
if err != nil { if !ok {
handleTenantError(w, err) writeError(w, http.StatusForbidden, "missing tenant")
return return
} }
@@ -80,7 +78,7 @@ func (h *DeadlineHandlers) ListForCase(w http.ResponseWriter, r *http.Request) {
deadlines, err := h.deadlines.ListForCase(tenantID, caseID) deadlines, err := h.deadlines.ListForCase(tenantID, caseID)
if err != nil { if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list deadlines") internalError(w, "failed to list deadlines for case", err)
return return
} }
@@ -89,9 +87,9 @@ func (h *DeadlineHandlers) ListForCase(w http.ResponseWriter, r *http.Request) {
// Create handles POST /api/cases/{caseID}/deadlines // Create handles POST /api/cases/{caseID}/deadlines
func (h *DeadlineHandlers) Create(w http.ResponseWriter, r *http.Request) { func (h *DeadlineHandlers) Create(w http.ResponseWriter, r *http.Request) {
tenantID, err := resolveTenant(r, h.db) tenantID, ok := auth.TenantFromContext(r.Context())
if err != nil { if !ok {
handleTenantError(w, err) writeError(w, http.StatusForbidden, "missing tenant")
return return
} }
@@ -112,10 +110,14 @@ func (h *DeadlineHandlers) Create(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, "title and due_date are required") writeError(w, http.StatusBadRequest, "title and due_date are required")
return return
} }
if msg := validateStringLength("title", input.Title, maxTitleLen); msg != "" {
writeError(w, http.StatusBadRequest, msg)
return
}
deadline, err := h.deadlines.Create(tenantID, input) deadline, err := h.deadlines.Create(r.Context(), tenantID, input)
if err != nil { if err != nil {
writeError(w, http.StatusInternalServerError, "failed to create deadline") internalError(w, "failed to create deadline", err)
return return
} }
@@ -124,9 +126,9 @@ func (h *DeadlineHandlers) Create(w http.ResponseWriter, r *http.Request) {
// Update handles PUT /api/deadlines/{deadlineID} // Update handles PUT /api/deadlines/{deadlineID}
func (h *DeadlineHandlers) Update(w http.ResponseWriter, r *http.Request) { func (h *DeadlineHandlers) Update(w http.ResponseWriter, r *http.Request) {
tenantID, err := resolveTenant(r, h.db) tenantID, ok := auth.TenantFromContext(r.Context())
if err != nil { if !ok {
handleTenantError(w, err) writeError(w, http.StatusForbidden, "missing tenant")
return return
} }
@@ -142,9 +144,9 @@ func (h *DeadlineHandlers) Update(w http.ResponseWriter, r *http.Request) {
return return
} }
deadline, err := h.deadlines.Update(tenantID, deadlineID, input) deadline, err := h.deadlines.Update(r.Context(), tenantID, deadlineID, input)
if err != nil { if err != nil {
writeError(w, http.StatusInternalServerError, "failed to update deadline") internalError(w, "failed to update deadline", err)
return return
} }
if deadline == nil { if deadline == nil {
@@ -157,9 +159,9 @@ func (h *DeadlineHandlers) Update(w http.ResponseWriter, r *http.Request) {
// Complete handles PATCH /api/deadlines/{deadlineID}/complete // Complete handles PATCH /api/deadlines/{deadlineID}/complete
func (h *DeadlineHandlers) Complete(w http.ResponseWriter, r *http.Request) { func (h *DeadlineHandlers) Complete(w http.ResponseWriter, r *http.Request) {
tenantID, err := resolveTenant(r, h.db) tenantID, ok := auth.TenantFromContext(r.Context())
if err != nil { if !ok {
handleTenantError(w, err) writeError(w, http.StatusForbidden, "missing tenant")
return return
} }
@@ -169,9 +171,9 @@ func (h *DeadlineHandlers) Complete(w http.ResponseWriter, r *http.Request) {
return return
} }
deadline, err := h.deadlines.Complete(tenantID, deadlineID) deadline, err := h.deadlines.Complete(r.Context(), tenantID, deadlineID)
if err != nil { if err != nil {
writeError(w, http.StatusInternalServerError, "failed to complete deadline") internalError(w, "failed to complete deadline", err)
return return
} }
if deadline == nil { if deadline == nil {
@@ -184,9 +186,9 @@ func (h *DeadlineHandlers) Complete(w http.ResponseWriter, r *http.Request) {
// Delete handles DELETE /api/deadlines/{deadlineID} // Delete handles DELETE /api/deadlines/{deadlineID}
func (h *DeadlineHandlers) Delete(w http.ResponseWriter, r *http.Request) { func (h *DeadlineHandlers) Delete(w http.ResponseWriter, r *http.Request) {
tenantID, err := resolveTenant(r, h.db) tenantID, ok := auth.TenantFromContext(r.Context())
if err != nil { if !ok {
handleTenantError(w, err) writeError(w, http.StatusForbidden, "missing tenant")
return return
} }
@@ -196,9 +198,8 @@ func (h *DeadlineHandlers) Delete(w http.ResponseWriter, r *http.Request) {
return return
} }
err = h.deadlines.Delete(tenantID, deadlineID) if err := h.deadlines.Delete(r.Context(), tenantID, deadlineID); err != nil {
if err != nil { writeError(w, http.StatusNotFound, "deadline not found")
writeError(w, http.StatusNotFound, err.Error())
return return
} }

View File

@@ -36,7 +36,7 @@ func (h *DocumentHandler) ListByCase(w http.ResponseWriter, r *http.Request) {
docs, err := h.svc.ListByCase(r.Context(), tenantID, caseID) docs, err := h.svc.ListByCase(r.Context(), tenantID, caseID)
if err != nil { if err != nil {
writeError(w, http.StatusInternalServerError, err.Error()) internalError(w, "failed to list documents", err)
return return
} }
@@ -98,7 +98,7 @@ func (h *DocumentHandler) Upload(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusNotFound, "case not found") writeError(w, http.StatusNotFound, "case not found")
return return
} }
writeError(w, http.StatusInternalServerError, err.Error()) internalError(w, "failed to upload document", err)
return return
} }
@@ -121,16 +121,16 @@ func (h *DocumentHandler) Download(w http.ResponseWriter, r *http.Request) {
body, contentType, title, err := h.svc.Download(r.Context(), tenantID, docID) body, contentType, title, err := h.svc.Download(r.Context(), tenantID, docID)
if err != nil { if err != nil {
if err.Error() == "document not found" || err.Error() == "document has no file" { if err.Error() == "document not found" || err.Error() == "document has no file" {
writeError(w, http.StatusNotFound, err.Error()) writeError(w, http.StatusNotFound, "document not found")
return return
} }
writeError(w, http.StatusInternalServerError, err.Error()) internalError(w, "failed to download document", err)
return return
} }
defer body.Close() defer body.Close()
w.Header().Set("Content-Type", contentType) w.Header().Set("Content-Type", contentType)
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, title)) w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, sanitizeFilename(title)))
io.Copy(w, body) io.Copy(w, body)
} }
@@ -149,7 +149,7 @@ func (h *DocumentHandler) GetMeta(w http.ResponseWriter, r *http.Request) {
doc, err := h.svc.GetByID(r.Context(), tenantID, docID) doc, err := h.svc.GetByID(r.Context(), tenantID, docID)
if err != nil { if err != nil {
writeError(w, http.StatusInternalServerError, err.Error()) internalError(w, "failed to get document metadata", err)
return return
} }
if doc == nil { if doc == nil {

View File

@@ -2,12 +2,12 @@ package handlers
import ( import (
"encoding/json" "encoding/json"
"log/slog"
"net/http" "net/http"
"strings"
"unicode/utf8"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/jmoiron/sqlx"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/auth"
) )
func writeJSON(w http.ResponseWriter, status int, v any) { func writeJSON(w http.ResponseWriter, status int, v any) {
@@ -20,62 +20,9 @@ func writeError(w http.ResponseWriter, status int, msg string) {
writeJSON(w, status, map[string]string{"error": msg}) writeJSON(w, status, map[string]string{"error": msg})
} }
// resolveTenant gets the tenant ID for the authenticated user. // internalError logs the real error and returns a generic message to the client.
// Checks X-Tenant-ID header first, then falls back to user's first tenant. func internalError(w http.ResponseWriter, msg string, err error) {
func resolveTenant(r *http.Request, db *sqlx.DB) (uuid.UUID, error) { slog.Error(msg, "error", err)
userID, ok := auth.UserFromContext(r.Context())
if !ok {
return uuid.Nil, errUnauthorized
}
// Check header first
if headerVal := r.Header.Get("X-Tenant-ID"); headerVal != "" {
tenantID, err := uuid.Parse(headerVal)
if err != nil {
return uuid.Nil, errInvalidTenant
}
// Verify user has access to this tenant
var count int
err = db.Get(&count,
`SELECT COUNT(*) FROM user_tenants WHERE user_id = $1 AND tenant_id = $2`,
userID, tenantID)
if err != nil || count == 0 {
return uuid.Nil, errTenantAccess
}
return tenantID, nil
}
// Fall back to user's first tenant
var tenantID uuid.UUID
err := db.Get(&tenantID,
`SELECT tenant_id FROM user_tenants WHERE user_id = $1 ORDER BY created_at LIMIT 1`,
userID)
if err != nil {
return uuid.Nil, errNoTenant
}
return tenantID, nil
}
type apiError struct {
msg string
status int
}
func (e *apiError) Error() string { return e.msg }
var (
errUnauthorized = &apiError{msg: "unauthorized", status: http.StatusUnauthorized}
errInvalidTenant = &apiError{msg: "invalid tenant ID", status: http.StatusBadRequest}
errTenantAccess = &apiError{msg: "no access to tenant", status: http.StatusForbidden}
errNoTenant = &apiError{msg: "no tenant found for user", status: http.StatusBadRequest}
)
// handleTenantError writes the appropriate error response for tenant resolution errors
func handleTenantError(w http.ResponseWriter, err error) {
if ae, ok := err.(*apiError); ok {
writeError(w, ae.status, ae.msg)
return
}
writeError(w, http.StatusInternalServerError, "internal error") writeError(w, http.StatusInternalServerError, "internal error")
} }
@@ -88,3 +35,74 @@ func parsePathUUID(r *http.Request, key string) (uuid.UUID, error) {
func parseUUID(s string) (uuid.UUID, error) { func parseUUID(s string) (uuid.UUID, error) {
return uuid.Parse(s) return uuid.Parse(s)
} }
// --- Input validation helpers ---
const (
maxTitleLen = 500
maxDescriptionLen = 10000
maxCaseNumberLen = 100
maxSearchLen = 200
maxPaginationLimit = 100
)
// validateStringLength checks if a string exceeds the given max length.
func validateStringLength(field, value string, maxLen int) string {
if utf8.RuneCountInString(value) > maxLen {
return field + " exceeds maximum length"
}
return ""
}
// clampPagination enforces sane pagination defaults and limits.
func clampPagination(limit, offset int) (int, int) {
if limit <= 0 {
limit = 20
}
if limit > maxPaginationLimit {
limit = maxPaginationLimit
}
if offset < 0 {
offset = 0
}
return limit, offset
}
// sanitizeFilename removes characters unsafe for Content-Disposition headers.
func sanitizeFilename(name string) string {
// Remove control characters, quotes, and backslashes
var b strings.Builder
for _, r := range name {
if r < 32 || r == '"' || r == '\\' || r == '/' {
b.WriteRune('_')
} else {
b.WriteRune(r)
}
}
return b.String()
}
// maskSettingsPassword masks the CalDAV password in tenant settings JSON before returning to clients.
func maskSettingsPassword(settings json.RawMessage) json.RawMessage {
if len(settings) == 0 {
return settings
}
var m map[string]json.RawMessage
if err := json.Unmarshal(settings, &m); err != nil {
return settings
}
caldavRaw, ok := m["caldav"]
if !ok {
return settings
}
var caldav map[string]json.RawMessage
if err := json.Unmarshal(caldavRaw, &caldav); err != nil {
return settings
}
if _, ok := caldav["password"]; ok {
caldav["password"], _ = json.Marshal("********")
}
m["caldav"], _ = json.Marshal(caldav)
result, _ := json.Marshal(m)
return result
}

View File

@@ -60,6 +60,10 @@ func (h *NoteHandler) Create(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, "content is required") writeError(w, http.StatusBadRequest, "content is required")
return return
} }
if msg := validateStringLength("content", input.Content, maxDescriptionLen); msg != "" {
writeError(w, http.StatusBadRequest, msg)
return
}
var createdBy *uuid.UUID var createdBy *uuid.UUID
if userID != uuid.Nil { if userID != uuid.Nil {
@@ -100,6 +104,10 @@ func (h *NoteHandler) Update(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, "content is required") writeError(w, http.StatusBadRequest, "content is required")
return return
} }
if msg := validateStringLength("content", req.Content, maxDescriptionLen); msg != "" {
writeError(w, http.StatusBadRequest, msg)
return
}
note, err := h.svc.Update(r.Context(), tenantID, noteID, req.Content) note, err := h.svc.Update(r.Context(), tenantID, noteID, req.Content)
if err != nil { if err != nil {

View File

@@ -0,0 +1,171 @@
package handlers
import (
"encoding/json"
"net/http"
"strconv"
"github.com/jmoiron/sqlx"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/auth"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/services"
)
// NotificationHandler handles notification API endpoints.
type NotificationHandler struct {
svc *services.NotificationService
db *sqlx.DB
}
// NewNotificationHandler creates a new notification handler.
func NewNotificationHandler(svc *services.NotificationService, db *sqlx.DB) *NotificationHandler {
return &NotificationHandler{svc: svc, db: db}
}
// List returns paginated notifications for the authenticated user.
func (h *NotificationHandler) List(w http.ResponseWriter, r *http.Request) {
tenantID, ok := auth.TenantFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "unauthorized")
return
}
userID, ok := auth.UserFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "unauthorized")
return
}
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
notifications, total, err := h.svc.ListForUser(r.Context(), tenantID, userID, limit, offset)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list notifications")
return
}
writeJSON(w, http.StatusOK, map[string]any{
"data": notifications,
"total": total,
})
}
// UnreadCount returns the count of unread notifications.
func (h *NotificationHandler) UnreadCount(w http.ResponseWriter, r *http.Request) {
tenantID, ok := auth.TenantFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "unauthorized")
return
}
userID, ok := auth.UserFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "unauthorized")
return
}
count, err := h.svc.UnreadCount(r.Context(), tenantID, userID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to count notifications")
return
}
writeJSON(w, http.StatusOK, map[string]int{"unread_count": count})
}
// MarkRead marks a single notification as read.
func (h *NotificationHandler) MarkRead(w http.ResponseWriter, r *http.Request) {
tenantID, ok := auth.TenantFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "unauthorized")
return
}
userID, ok := auth.UserFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "unauthorized")
return
}
notifID, err := parsePathUUID(r, "id")
if err != nil {
writeError(w, http.StatusBadRequest, "invalid notification ID")
return
}
if err := h.svc.MarkRead(r.Context(), tenantID, userID, notifID); err != nil {
writeError(w, http.StatusNotFound, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
// MarkAllRead marks all notifications as read.
func (h *NotificationHandler) MarkAllRead(w http.ResponseWriter, r *http.Request) {
tenantID, ok := auth.TenantFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "unauthorized")
return
}
userID, ok := auth.UserFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "unauthorized")
return
}
if err := h.svc.MarkAllRead(r.Context(), tenantID, userID); err != nil {
writeError(w, http.StatusInternalServerError, "failed to mark all read")
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
// GetPreferences returns notification preferences for the authenticated user.
func (h *NotificationHandler) GetPreferences(w http.ResponseWriter, r *http.Request) {
tenantID, ok := auth.TenantFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "unauthorized")
return
}
userID, ok := auth.UserFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "unauthorized")
return
}
pref, err := h.svc.GetPreferences(r.Context(), tenantID, userID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to get preferences")
return
}
writeJSON(w, http.StatusOK, pref)
}
// UpdatePreferences updates notification preferences for the authenticated user.
func (h *NotificationHandler) UpdatePreferences(w http.ResponseWriter, r *http.Request) {
tenantID, ok := auth.TenantFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "unauthorized")
return
}
userID, ok := auth.UserFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "unauthorized")
return
}
var input services.UpdatePreferencesInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
pref, err := h.svc.UpdatePreferences(r.Context(), tenantID, userID, input)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to update preferences")
return
}
writeJSON(w, http.StatusOK, pref)
}

View File

@@ -34,7 +34,7 @@ func (h *PartyHandler) List(w http.ResponseWriter, r *http.Request) {
parties, err := h.svc.ListByCase(r.Context(), tenantID, caseID) parties, err := h.svc.ListByCase(r.Context(), tenantID, caseID)
if err != nil { if err != nil {
writeError(w, http.StatusInternalServerError, err.Error()) internalError(w, "failed to list parties", err)
return return
} }
@@ -67,13 +67,18 @@ func (h *PartyHandler) Create(w http.ResponseWriter, r *http.Request) {
return return
} }
if msg := validateStringLength("name", input.Name, maxTitleLen); msg != "" {
writeError(w, http.StatusBadRequest, msg)
return
}
party, err := h.svc.Create(r.Context(), tenantID, caseID, userID, input) party, err := h.svc.Create(r.Context(), tenantID, caseID, userID, input)
if err != nil { if err != nil {
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
writeError(w, http.StatusNotFound, "case not found") writeError(w, http.StatusNotFound, "case not found")
return return
} }
writeError(w, http.StatusInternalServerError, err.Error()) internalError(w, "failed to create party", err)
return return
} }
@@ -101,7 +106,7 @@ func (h *PartyHandler) Update(w http.ResponseWriter, r *http.Request) {
updated, err := h.svc.Update(r.Context(), tenantID, partyID, input) updated, err := h.svc.Update(r.Context(), tenantID, partyID, input)
if err != nil { if err != nil {
writeError(w, http.StatusInternalServerError, err.Error()) internalError(w, "failed to update party", err)
return return
} }
if updated == nil { if updated == nil {

View File

@@ -0,0 +1,328 @@
package handlers
import (
"encoding/json"
"net/http"
"strconv"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/auth"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/services"
)
type TemplateHandler struct {
templates *services.TemplateService
cases *services.CaseService
parties *services.PartyService
deadlines *services.DeadlineService
tenants *services.TenantService
}
func NewTemplateHandler(
templates *services.TemplateService,
cases *services.CaseService,
parties *services.PartyService,
deadlines *services.DeadlineService,
tenants *services.TenantService,
) *TemplateHandler {
return &TemplateHandler{
templates: templates,
cases: cases,
parties: parties,
deadlines: deadlines,
tenants: tenants,
}
}
// List handles GET /api/templates
func (h *TemplateHandler) 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()
limit, _ := strconv.Atoi(q.Get("limit"))
offset, _ := strconv.Atoi(q.Get("offset"))
limit, offset = clampPagination(limit, offset)
filter := services.TemplateFilter{
Category: q.Get("category"),
Search: q.Get("search"),
Limit: limit,
Offset: offset,
}
if filter.Search != "" {
if msg := validateStringLength("search", filter.Search, maxSearchLen); msg != "" {
writeError(w, http.StatusBadRequest, msg)
return
}
}
templates, total, err := h.templates.List(r.Context(), tenantID, filter)
if err != nil {
internalError(w, "failed to list templates", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{
"data": templates,
"total": total,
})
}
// Get handles GET /api/templates/{id}
func (h *TemplateHandler) Get(w http.ResponseWriter, r *http.Request) {
tenantID, ok := auth.TenantFromContext(r.Context())
if !ok {
writeError(w, http.StatusForbidden, "missing tenant")
return
}
templateID, err := parsePathUUID(r, "id")
if err != nil {
writeError(w, http.StatusBadRequest, "invalid template ID")
return
}
t, err := h.templates.GetByID(r.Context(), tenantID, templateID)
if err != nil {
internalError(w, "failed to get template", err)
return
}
if t == nil {
writeError(w, http.StatusNotFound, "template not found")
return
}
writeJSON(w, http.StatusOK, t)
}
// Create handles POST /api/templates
func (h *TemplateHandler) Create(w http.ResponseWriter, r *http.Request) {
tenantID, ok := auth.TenantFromContext(r.Context())
if !ok {
writeError(w, http.StatusForbidden, "missing tenant")
return
}
var raw struct {
Name string `json:"name"`
Description *string `json:"description,omitempty"`
Category string `json:"category"`
Content string `json:"content"`
Variables any `json:"variables,omitempty"`
}
if err := json.NewDecoder(r.Body).Decode(&raw); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if raw.Name == "" {
writeError(w, http.StatusBadRequest, "name is required")
return
}
if msg := validateStringLength("name", raw.Name, maxTitleLen); msg != "" {
writeError(w, http.StatusBadRequest, msg)
return
}
if raw.Category == "" {
writeError(w, http.StatusBadRequest, "category is required")
return
}
var variables []byte
if raw.Variables != nil {
var err error
variables, err = json.Marshal(raw.Variables)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid variables")
return
}
}
input := services.CreateTemplateInput{
Name: raw.Name,
Description: raw.Description,
Category: raw.Category,
Content: raw.Content,
Variables: variables,
}
t, err := h.templates.Create(r.Context(), tenantID, input)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusCreated, t)
}
// Update handles PUT /api/templates/{id}
func (h *TemplateHandler) Update(w http.ResponseWriter, r *http.Request) {
tenantID, ok := auth.TenantFromContext(r.Context())
if !ok {
writeError(w, http.StatusForbidden, "missing tenant")
return
}
templateID, err := parsePathUUID(r, "id")
if err != nil {
writeError(w, http.StatusBadRequest, "invalid template ID")
return
}
var raw struct {
Name *string `json:"name,omitempty"`
Description *string `json:"description,omitempty"`
Category *string `json:"category,omitempty"`
Content *string `json:"content,omitempty"`
Variables any `json:"variables,omitempty"`
}
if err := json.NewDecoder(r.Body).Decode(&raw); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if raw.Name != nil {
if msg := validateStringLength("name", *raw.Name, maxTitleLen); msg != "" {
writeError(w, http.StatusBadRequest, msg)
return
}
}
var variables []byte
if raw.Variables != nil {
variables, err = json.Marshal(raw.Variables)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid variables")
return
}
}
input := services.UpdateTemplateInput{
Name: raw.Name,
Description: raw.Description,
Category: raw.Category,
Content: raw.Content,
Variables: variables,
}
t, err := h.templates.Update(r.Context(), tenantID, templateID, input)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
if t == nil {
writeError(w, http.StatusNotFound, "template not found")
return
}
writeJSON(w, http.StatusOK, t)
}
// Delete handles DELETE /api/templates/{id}
func (h *TemplateHandler) Delete(w http.ResponseWriter, r *http.Request) {
tenantID, ok := auth.TenantFromContext(r.Context())
if !ok {
writeError(w, http.StatusForbidden, "missing tenant")
return
}
templateID, err := parsePathUUID(r, "id")
if err != nil {
writeError(w, http.StatusBadRequest, "invalid template ID")
return
}
if err := h.templates.Delete(r.Context(), tenantID, templateID); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
}
// Render handles POST /api/templates/{id}/render?case_id=X
func (h *TemplateHandler) Render(w http.ResponseWriter, r *http.Request) {
tenantID, ok := auth.TenantFromContext(r.Context())
if !ok {
writeError(w, http.StatusForbidden, "missing tenant")
return
}
userID, _ := auth.UserFromContext(r.Context())
templateID, err := parsePathUUID(r, "id")
if err != nil {
writeError(w, http.StatusBadRequest, "invalid template ID")
return
}
// Get template
tmpl, err := h.templates.GetByID(r.Context(), tenantID, templateID)
if err != nil {
internalError(w, "failed to get template", err)
return
}
if tmpl == nil {
writeError(w, http.StatusNotFound, "template not found")
return
}
// Build render data
data := services.RenderData{}
// Case data (optional)
caseIDStr := r.URL.Query().Get("case_id")
if caseIDStr != "" {
caseID, err := parseUUID(caseIDStr)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid case_id")
return
}
caseDetail, err := h.cases.GetByID(r.Context(), tenantID, caseID)
if err != nil {
internalError(w, "failed to get case", err)
return
}
if caseDetail == nil {
writeError(w, http.StatusNotFound, "case not found")
return
}
data.Case = &caseDetail.Case
data.Parties = caseDetail.Parties
// Get next upcoming deadline for this case
deadlines, err := h.deadlines.ListForCase(tenantID, caseID)
if err == nil && len(deadlines) > 0 {
// Find next non-completed deadline
for i := range deadlines {
if deadlines[i].Status != "completed" {
data.Deadline = &deadlines[i]
break
}
}
}
}
// Tenant data
tenant, err := h.tenants.GetByID(r.Context(), tenantID)
if err == nil && tenant != nil {
data.Tenant = tenant
}
// User data (userID from context — detailed name/email would need a user table lookup)
data.UserName = userID.String()
data.UserEmail = ""
rendered := h.templates.Render(tmpl, data)
writeJSON(w, http.StatusOK, map[string]any{
"content": rendered,
"template_id": tmpl.ID,
"name": tmpl.Name,
})
}

View File

@@ -2,6 +2,7 @@ package handlers
import ( import (
"encoding/json" "encoding/json"
"log/slog"
"net/http" "net/http"
"github.com/google/uuid" "github.com/google/uuid"
@@ -41,7 +42,8 @@ func (h *TenantHandler) CreateTenant(w http.ResponseWriter, r *http.Request) {
tenant, err := h.svc.Create(r.Context(), userID, req.Name, req.Slug) tenant, err := h.svc.Create(r.Context(), userID, req.Name, req.Slug)
if err != nil { if err != nil {
jsonError(w, err.Error(), http.StatusInternalServerError) slog.Error("failed to create tenant", "error", err)
jsonError(w, "internal error", http.StatusInternalServerError)
return return
} }
@@ -58,10 +60,16 @@ func (h *TenantHandler) ListTenants(w http.ResponseWriter, r *http.Request) {
tenants, err := h.svc.ListForUser(r.Context(), userID) tenants, err := h.svc.ListForUser(r.Context(), userID)
if err != nil { if err != nil {
jsonError(w, err.Error(), http.StatusInternalServerError) slog.Error("failed to list tenants", "error", err)
jsonError(w, "internal error", http.StatusInternalServerError)
return return
} }
// Mask CalDAV passwords in tenant settings
for i := range tenants {
tenants[i].Settings = maskSettingsPassword(tenants[i].Settings)
}
jsonResponse(w, tenants, http.StatusOK) jsonResponse(w, tenants, http.StatusOK)
} }
@@ -82,7 +90,8 @@ func (h *TenantHandler) GetTenant(w http.ResponseWriter, r *http.Request) {
// Verify user has access to this tenant // Verify user has access to this tenant
role, err := h.svc.GetUserRole(r.Context(), userID, tenantID) role, err := h.svc.GetUserRole(r.Context(), userID, tenantID)
if err != nil { if err != nil {
jsonError(w, err.Error(), http.StatusInternalServerError) slog.Error("failed to get user role", "error", err)
jsonError(w, "internal error", http.StatusInternalServerError)
return return
} }
if role == "" { if role == "" {
@@ -92,7 +101,8 @@ func (h *TenantHandler) GetTenant(w http.ResponseWriter, r *http.Request) {
tenant, err := h.svc.GetByID(r.Context(), tenantID) tenant, err := h.svc.GetByID(r.Context(), tenantID)
if err != nil { if err != nil {
jsonError(w, err.Error(), http.StatusInternalServerError) slog.Error("failed to get tenant", "error", err)
jsonError(w, "internal error", http.StatusInternalServerError)
return return
} }
if tenant == nil { if tenant == nil {
@@ -100,6 +110,9 @@ func (h *TenantHandler) GetTenant(w http.ResponseWriter, r *http.Request) {
return return
} }
// Mask CalDAV password before returning
tenant.Settings = maskSettingsPassword(tenant.Settings)
jsonResponse(w, tenant, http.StatusOK) jsonResponse(w, tenant, http.StatusOK)
} }
@@ -120,7 +133,8 @@ func (h *TenantHandler) InviteUser(w http.ResponseWriter, r *http.Request) {
// Only owners and partners can invite // Only owners and partners can invite
role, err := h.svc.GetUserRole(r.Context(), userID, tenantID) role, err := h.svc.GetUserRole(r.Context(), userID, tenantID)
if err != nil { if err != nil {
jsonError(w, err.Error(), http.StatusInternalServerError) slog.Error("failed to get user role", "error", err)
jsonError(w, "internal error", http.StatusInternalServerError)
return return
} }
if role != "owner" && role != "partner" { if role != "owner" && role != "partner" {
@@ -155,7 +169,8 @@ func (h *TenantHandler) InviteUser(w http.ResponseWriter, r *http.Request) {
ut, err := h.svc.InviteByEmail(r.Context(), tenantID, req.Email, req.Role) ut, err := h.svc.InviteByEmail(r.Context(), tenantID, req.Email, req.Role)
if err != nil { if err != nil {
jsonError(w, err.Error(), http.StatusBadRequest) // These are user-facing validation errors (user not found, already member)
jsonError(w, "failed to invite user", http.StatusBadRequest)
return return
} }
@@ -185,7 +200,8 @@ func (h *TenantHandler) RemoveMember(w http.ResponseWriter, r *http.Request) {
// Only owners and partners can remove members (or user removing themselves) // Only owners and partners can remove members (or user removing themselves)
role, err := h.svc.GetUserRole(r.Context(), userID, tenantID) role, err := h.svc.GetUserRole(r.Context(), userID, tenantID)
if err != nil { if err != nil {
jsonError(w, err.Error(), http.StatusInternalServerError) slog.Error("failed to get user role", "error", err)
jsonError(w, "internal error", http.StatusInternalServerError)
return return
} }
if role != "owner" && role != "partner" && userID != memberID { if role != "owner" && role != "partner" && userID != memberID {
@@ -194,7 +210,8 @@ func (h *TenantHandler) RemoveMember(w http.ResponseWriter, r *http.Request) {
} }
if err := h.svc.RemoveMember(r.Context(), tenantID, memberID); err != nil { if err := h.svc.RemoveMember(r.Context(), tenantID, memberID); err != nil {
jsonError(w, err.Error(), http.StatusBadRequest) // These are user-facing validation errors (not a member, last owner, etc.)
jsonError(w, "failed to remove member", http.StatusBadRequest)
return return
} }
@@ -218,7 +235,8 @@ func (h *TenantHandler) UpdateSettings(w http.ResponseWriter, r *http.Request) {
// Only owners and partners can update settings // Only owners and partners can update settings
role, err := h.svc.GetUserRole(r.Context(), userID, tenantID) role, err := h.svc.GetUserRole(r.Context(), userID, tenantID)
if err != nil { if err != nil {
jsonError(w, err.Error(), http.StatusInternalServerError) slog.Error("failed to get user role", "error", err)
jsonError(w, "internal error", http.StatusInternalServerError)
return return
} }
if role != "owner" && role != "partner" { if role != "owner" && role != "partner" {
@@ -234,10 +252,14 @@ func (h *TenantHandler) UpdateSettings(w http.ResponseWriter, r *http.Request) {
tenant, err := h.svc.UpdateSettings(r.Context(), tenantID, settings) tenant, err := h.svc.UpdateSettings(r.Context(), tenantID, settings)
if err != nil { if err != nil {
jsonError(w, err.Error(), http.StatusInternalServerError) slog.Error("failed to update settings", "error", err)
jsonError(w, "internal error", http.StatusInternalServerError)
return return
} }
// Mask CalDAV password before returning
tenant.Settings = maskSettingsPassword(tenant.Settings)
jsonResponse(w, tenant, http.StatusOK) jsonResponse(w, tenant, http.StatusOK)
} }
@@ -258,7 +280,8 @@ func (h *TenantHandler) ListMembers(w http.ResponseWriter, r *http.Request) {
// Verify user has access // Verify user has access
role, err := h.svc.GetUserRole(r.Context(), userID, tenantID) role, err := h.svc.GetUserRole(r.Context(), userID, tenantID)
if err != nil { if err != nil {
jsonError(w, err.Error(), http.StatusInternalServerError) slog.Error("failed to get user role", "error", err)
jsonError(w, "internal error", http.StatusInternalServerError)
return return
} }
if role == "" { if role == "" {
@@ -268,7 +291,8 @@ func (h *TenantHandler) ListMembers(w http.ResponseWriter, r *http.Request) {
members, err := h.svc.ListMembers(r.Context(), tenantID) members, err := h.svc.ListMembers(r.Context(), tenantID)
if err != nil { if err != nil {
jsonError(w, err.Error(), http.StatusInternalServerError) slog.Error("failed to list members", "error", err)
jsonError(w, "internal error", http.StatusInternalServerError)
return return
} }

View File

@@ -46,7 +46,7 @@ func testServer(t *testing.T) (http.Handler, func()) {
} }
authMW := auth.NewMiddleware(jwtSecret, database) authMW := auth.NewMiddleware(jwtSecret, database)
handler := router.New(database, authMW, cfg, nil) handler := router.New(database, authMW, cfg, nil, nil)
return handler, func() { database.Close() } return handler, func() { database.Close() }
} }

View File

@@ -0,0 +1,49 @@
package middleware
import (
"net/http"
"strings"
)
// SecurityHeaders adds standard security headers to all responses.
func SecurityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-XSS-Protection", "1; mode=block")
w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
next.ServeHTTP(w, r)
})
}
// CORS returns middleware that restricts cross-origin requests to the given origin.
// If allowedOrigin is empty, CORS headers are not set (same-origin only).
func CORS(allowedOrigin string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
if allowedOrigin != "" && origin != "" && matchOrigin(origin, allowedOrigin) {
w.Header().Set("Access-Control-Allow-Origin", allowedOrigin)
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Tenant-ID")
w.Header().Set("Access-Control-Max-Age", "86400")
w.Header().Set("Vary", "Origin")
}
// Handle preflight
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
}
// matchOrigin checks if the request origin matches the allowed origin.
func matchOrigin(origin, allowed string) bool {
return strings.EqualFold(strings.TrimRight(origin, "/"), strings.TrimRight(allowed, "/"))
}

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

@@ -0,0 +1,21 @@
package models
import (
"encoding/json"
"time"
"github.com/google/uuid"
)
type DocumentTemplate struct {
ID uuid.UUID `db:"id" json:"id"`
TenantID *uuid.UUID `db:"tenant_id" json:"tenant_id,omitempty"`
Name string `db:"name" json:"name"`
Description *string `db:"description" json:"description,omitempty"`
Category string `db:"category" json:"category"`
Content string `db:"content" json:"content"`
Variables json.RawMessage `db:"variables" json:"variables"`
IsSystem bool `db:"is_system" json:"is_system"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}

View File

@@ -0,0 +1,32 @@
package models
import (
"time"
"github.com/google/uuid"
"github.com/lib/pq"
)
type Notification struct {
ID uuid.UUID `db:"id" json:"id"`
TenantID uuid.UUID `db:"tenant_id" json:"tenant_id"`
UserID uuid.UUID `db:"user_id" json:"user_id"`
Type string `db:"type" json:"type"`
EntityType *string `db:"entity_type" json:"entity_type,omitempty"`
EntityID *uuid.UUID `db:"entity_id" json:"entity_id,omitempty"`
Title string `db:"title" json:"title"`
Body *string `db:"body" json:"body,omitempty"`
SentAt *time.Time `db:"sent_at" json:"sent_at,omitempty"`
ReadAt *time.Time `db:"read_at" json:"read_at,omitempty"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
}
type NotificationPreferences struct {
UserID uuid.UUID `db:"user_id" json:"user_id"`
TenantID uuid.UUID `db:"tenant_id" json:"tenant_id"`
DeadlineReminderDays pq.Int64Array `db:"deadline_reminder_days" json:"deadline_reminder_days"`
EmailEnabled bool `db:"email_enabled" json:"email_enabled"`
DailyDigest bool `db:"daily_digest" json:"daily_digest"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}

View File

@@ -15,41 +15,50 @@ import (
"mgit.msbls.de/m/KanzlAI-mGMT/internal/services" "mgit.msbls.de/m/KanzlAI-mGMT/internal/services"
) )
func New(db *sqlx.DB, authMW *auth.Middleware, cfg *config.Config, calDAVSvc *services.CalDAVService) http.Handler { func New(db *sqlx.DB, authMW *auth.Middleware, cfg *config.Config, calDAVSvc *services.CalDAVService, notifSvc *services.NotificationService) http.Handler {
mux := http.NewServeMux() mux := http.NewServeMux()
// Services // Services
tenantSvc := services.NewTenantService(db) auditSvc := services.NewAuditService(db)
caseSvc := services.NewCaseService(db) tenantSvc := services.NewTenantService(db, auditSvc)
partySvc := services.NewPartyService(db) caseSvc := services.NewCaseService(db, auditSvc)
appointmentSvc := services.NewAppointmentService(db) partySvc := services.NewPartyService(db, auditSvc)
appointmentSvc := services.NewAppointmentService(db, auditSvc)
holidaySvc := services.NewHolidayService(db) holidaySvc := services.NewHolidayService(db)
deadlineSvc := services.NewDeadlineService(db) deadlineSvc := services.NewDeadlineService(db, auditSvc)
deadlineRuleSvc := services.NewDeadlineRuleService(db) deadlineRuleSvc := services.NewDeadlineRuleService(db)
calculator := services.NewDeadlineCalculator(holidaySvc) calculator := services.NewDeadlineCalculator(holidaySvc)
storageCli := services.NewStorageClient(cfg.SupabaseURL, cfg.SupabaseServiceKey) storageCli := services.NewStorageClient(cfg.SupabaseURL, cfg.SupabaseServiceKey)
documentSvc := services.NewDocumentService(db, storageCli) documentSvc := services.NewDocumentService(db, storageCli, auditSvc)
assignmentSvc := services.NewCaseAssignmentService(db) assignmentSvc := services.NewCaseAssignmentService(db)
templateSvc := services.NewTemplateService(db, auditSvc)
// AI service (optional — only if API key is configured) // AI service (optional — only if API key is configured)
var aiH *handlers.AIHandler var aiH *handlers.AIHandler
if cfg.AnthropicAPIKey != "" { if cfg.AnthropicAPIKey != "" {
aiSvc := services.NewAIService(cfg.AnthropicAPIKey, db) aiSvc := services.NewAIService(cfg.AnthropicAPIKey, db)
aiH = handlers.NewAIHandler(aiSvc, db) aiH = handlers.NewAIHandler(aiSvc)
} }
// Middleware // Middleware
tenantResolver := auth.NewTenantResolver(tenantSvc) tenantResolver := auth.NewTenantResolver(tenantSvc)
noteSvc := services.NewNoteService(db) noteSvc := services.NewNoteService(db, auditSvc)
dashboardSvc := services.NewDashboardService(db) dashboardSvc := services.NewDashboardService(db)
// Notification handler (optional — nil in tests)
var notifH *handlers.NotificationHandler
if notifSvc != nil {
notifH = handlers.NewNotificationHandler(notifSvc, db)
}
// Handlers // Handlers
auditH := handlers.NewAuditLogHandler(auditSvc)
tenantH := handlers.NewTenantHandler(tenantSvc) tenantH := handlers.NewTenantHandler(tenantSvc)
caseH := handlers.NewCaseHandler(caseSvc) caseH := handlers.NewCaseHandler(caseSvc)
partyH := handlers.NewPartyHandler(partySvc) partyH := handlers.NewPartyHandler(partySvc)
apptH := handlers.NewAppointmentHandler(appointmentSvc) apptH := handlers.NewAppointmentHandler(appointmentSvc)
deadlineH := handlers.NewDeadlineHandlers(deadlineSvc, db) deadlineH := handlers.NewDeadlineHandlers(deadlineSvc)
ruleH := handlers.NewDeadlineRuleHandlers(deadlineRuleSvc) ruleH := handlers.NewDeadlineRuleHandlers(deadlineRuleSvc)
calcH := handlers.NewCalculateHandlers(calculator, deadlineRuleSvc) calcH := handlers.NewCalculateHandlers(calculator, deadlineRuleSvc)
dashboardH := handlers.NewDashboardHandler(dashboardSvc) dashboardH := handlers.NewDashboardHandler(dashboardSvc)
@@ -57,6 +66,7 @@ func New(db *sqlx.DB, authMW *auth.Middleware, cfg *config.Config, calDAVSvc *se
eventH := handlers.NewCaseEventHandler(db) eventH := handlers.NewCaseEventHandler(db)
docH := handlers.NewDocumentHandler(documentSvc) docH := handlers.NewDocumentHandler(documentSvc)
assignmentH := handlers.NewCaseAssignmentHandler(assignmentSvc) assignmentH := handlers.NewCaseAssignmentHandler(assignmentSvc)
templateH := handlers.NewTemplateHandler(templateSvc, caseSvc, partySvc, deadlineSvc, tenantSvc)
// Public routes // Public routes
mux.HandleFunc("GET /health", handleHealth(db)) mux.HandleFunc("GET /health", handleHealth(db))
@@ -98,7 +108,7 @@ func New(db *sqlx.DB, authMW *auth.Middleware, cfg *config.Config, calDAVSvc *se
scoped.HandleFunc("GET /api/cases", caseH.List) scoped.HandleFunc("GET /api/cases", caseH.List)
scoped.HandleFunc("POST /api/cases", perm(auth.PermCreateCase, caseH.Create)) scoped.HandleFunc("POST /api/cases", perm(auth.PermCreateCase, caseH.Create))
scoped.HandleFunc("GET /api/cases/{id}", caseH.Get) scoped.HandleFunc("GET /api/cases/{id}", caseH.Get)
scoped.HandleFunc("PUT /api/cases/{id}", caseH.Update) // case-level access checked in handler scoped.HandleFunc("PUT /api/cases/{id}", caseH.Update)
scoped.HandleFunc("DELETE /api/cases/{id}", perm(auth.PermCreateCase, caseH.Delete)) scoped.HandleFunc("DELETE /api/cases/{id}", perm(auth.PermCreateCase, caseH.Delete))
// Parties — same access as case editing // Parties — same access as case editing
@@ -124,7 +134,7 @@ func New(db *sqlx.DB, authMW *auth.Middleware, cfg *config.Config, calDAVSvc *se
// Deadline calculator — all can use // Deadline calculator — all can use
scoped.HandleFunc("POST /api/deadlines/calculate", calcH.Calculate) scoped.HandleFunc("POST /api/deadlines/calculate", calcH.Calculate)
// Appointments — all can manage (PermManageAppointments granted to all) // Appointments — all can manage
scoped.HandleFunc("GET /api/appointments/{id}", apptH.Get) scoped.HandleFunc("GET /api/appointments/{id}", apptH.Get)
scoped.HandleFunc("GET /api/appointments", apptH.List) scoped.HandleFunc("GET /api/appointments", apptH.List)
scoped.HandleFunc("POST /api/appointments", perm(auth.PermManageAppointments, apptH.Create)) scoped.HandleFunc("POST /api/appointments", perm(auth.PermManageAppointments, apptH.Create))
@@ -148,12 +158,23 @@ func New(db *sqlx.DB, authMW *auth.Middleware, cfg *config.Config, calDAVSvc *se
// Dashboard — all can view // Dashboard — all can view
scoped.HandleFunc("GET /api/dashboard", dashboardH.Get) scoped.HandleFunc("GET /api/dashboard", dashboardH.Get)
// Documents — all can upload, delete checked in handler (own vs all) // Audit log
scoped.HandleFunc("GET /api/audit-log", auditH.List)
// Documents — all can upload, delete checked in handler
scoped.HandleFunc("GET /api/cases/{id}/documents", docH.ListByCase) scoped.HandleFunc("GET /api/cases/{id}/documents", docH.ListByCase)
scoped.HandleFunc("POST /api/cases/{id}/documents", perm(auth.PermUploadDocuments, docH.Upload)) scoped.HandleFunc("POST /api/cases/{id}/documents", perm(auth.PermUploadDocuments, docH.Upload))
scoped.HandleFunc("GET /api/documents/{docId}", docH.Download) scoped.HandleFunc("GET /api/documents/{docId}", docH.Download)
scoped.HandleFunc("GET /api/documents/{docId}/meta", docH.GetMeta) scoped.HandleFunc("GET /api/documents/{docId}/meta", docH.GetMeta)
scoped.HandleFunc("DELETE /api/documents/{docId}", docH.Delete) // permission check inside handler scoped.HandleFunc("DELETE /api/documents/{docId}", docH.Delete)
// Document templates — all can view, create/edit needs PermCreateCase
scoped.HandleFunc("GET /api/templates", templateH.List)
scoped.HandleFunc("GET /api/templates/{id}", templateH.Get)
scoped.HandleFunc("POST /api/templates", perm(auth.PermCreateCase, templateH.Create))
scoped.HandleFunc("PUT /api/templates/{id}", perm(auth.PermCreateCase, templateH.Update))
scoped.HandleFunc("DELETE /api/templates/{id}", perm(auth.PermCreateCase, templateH.Delete))
scoped.HandleFunc("POST /api/templates/{id}/render", templateH.Render)
// AI endpoints (rate limited: 5 req/min burst 10 per IP) // AI endpoints (rate limited: 5 req/min burst 10 per IP)
if aiH != nil { if aiH != nil {
@@ -162,7 +183,17 @@ func New(db *sqlx.DB, authMW *auth.Middleware, cfg *config.Config, calDAVSvc *se
scoped.HandleFunc("POST /api/ai/summarize-case", perm(auth.PermAIExtraction, aiLimiter.LimitFunc(aiH.SummarizeCase))) scoped.HandleFunc("POST /api/ai/summarize-case", perm(auth.PermAIExtraction, aiLimiter.LimitFunc(aiH.SummarizeCase)))
} }
// CalDAV sync endpoints — settings permission required // Notifications
if notifH != nil {
scoped.HandleFunc("GET /api/notifications", notifH.List)
scoped.HandleFunc("GET /api/notifications/unread-count", notifH.UnreadCount)
scoped.HandleFunc("PATCH /api/notifications/{id}/read", notifH.MarkRead)
scoped.HandleFunc("PATCH /api/notifications/read-all", notifH.MarkAllRead)
scoped.HandleFunc("GET /api/notification-preferences", notifH.GetPreferences)
scoped.HandleFunc("PUT /api/notification-preferences", notifH.UpdatePreferences)
}
// CalDAV sync endpoints
if calDAVSvc != nil { if calDAVSvc != nil {
calDAVH := handlers.NewCalDAVHandler(calDAVSvc) calDAVH := handlers.NewCalDAVHandler(calDAVSvc)
scoped.HandleFunc("POST /api/caldav/sync", perm(auth.PermManageSettings, calDAVH.TriggerSync)) scoped.HandleFunc("POST /api/caldav/sync", perm(auth.PermManageSettings, calDAVH.TriggerSync))
@@ -174,14 +205,20 @@ func New(db *sqlx.DB, authMW *auth.Middleware, cfg *config.Config, calDAVSvc *se
mux.Handle("/api/", authMW.RequireAuth(api)) mux.Handle("/api/", authMW.RequireAuth(api))
return requestLogger(mux) // Apply security middleware stack: CORS -> Security Headers -> Request Logger -> Routes
var handler http.Handler = mux
handler = requestLogger(handler)
handler = middleware.SecurityHeaders(handler)
handler = middleware.CORS(cfg.FrontendOrigin)(handler)
return handler
} }
func handleHealth(db *sqlx.DB) http.HandlerFunc { func handleHealth(db *sqlx.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
if err := db.Ping(); err != nil { if err := db.Ping(); err != nil {
w.WriteHeader(http.StatusServiceUnavailable) w.WriteHeader(http.StatusServiceUnavailable)
json.NewEncoder(w).Encode(map[string]string{"status": "error", "error": err.Error()}) json.NewEncoder(w).Encode(map[string]string{"status": "error"})
return return
} }
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
@@ -219,4 +256,3 @@ func requestLogger(next http.Handler) http.Handler {
) )
}) })
} }

View File

@@ -12,11 +12,12 @@ import (
) )
type AppointmentService struct { type AppointmentService struct {
db *sqlx.DB db *sqlx.DB
audit *AuditService
} }
func NewAppointmentService(db *sqlx.DB) *AppointmentService { func NewAppointmentService(db *sqlx.DB, audit *AuditService) *AppointmentService {
return &AppointmentService{db: db} return &AppointmentService{db: db, audit: audit}
} }
type AppointmentFilter struct { type AppointmentFilter struct {
@@ -86,6 +87,7 @@ func (s *AppointmentService) Create(ctx context.Context, a *models.Appointment)
if err != nil { if err != nil {
return fmt.Errorf("creating appointment: %w", err) return fmt.Errorf("creating appointment: %w", err)
} }
s.audit.Log(ctx, "create", "appointment", &a.ID, nil, a)
return nil return nil
} }
@@ -116,6 +118,7 @@ func (s *AppointmentService) Update(ctx context.Context, a *models.Appointment)
if rows == 0 { if rows == 0 {
return fmt.Errorf("appointment not found") return fmt.Errorf("appointment not found")
} }
s.audit.Log(ctx, "update", "appointment", &a.ID, nil, a)
return nil return nil
} }
@@ -131,5 +134,6 @@ func (s *AppointmentService) Delete(ctx context.Context, tenantID, id uuid.UUID)
if rows == 0 { if rows == 0 {
return fmt.Errorf("appointment not found") return fmt.Errorf("appointment not found")
} }
s.audit.Log(ctx, "delete", "appointment", &id, nil, nil)
return 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 { type CaseService struct {
db *sqlx.DB db *sqlx.DB
audit *AuditService
} }
func NewCaseService(db *sqlx.DB) *CaseService { func NewCaseService(db *sqlx.DB, audit *AuditService) *CaseService {
return &CaseService{db: db} return &CaseService{db: db, audit: audit}
} }
type CaseFilter struct { 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 { 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) return nil, fmt.Errorf("fetching created case: %w", err)
} }
s.audit.Log(ctx, "create", "case", &id, nil, c)
return &c, nil 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 { 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) return nil, fmt.Errorf("fetching updated case: %w", err)
} }
s.audit.Log(ctx, "update", "case", &caseID, current, updated)
return &updated, nil return &updated, nil
} }
@@ -254,6 +261,7 @@ func (s *CaseService) Delete(ctx context.Context, tenantID, caseID uuid.UUID, us
return sql.ErrNoRows return sql.ErrNoRows
} }
createEvent(ctx, s.db, tenantID, caseID, userID, "case_archived", "Case archived", nil) 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 return nil
} }

View File

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

View File

@@ -18,10 +18,11 @@ const documentBucket = "kanzlai-documents"
type DocumentService struct { type DocumentService struct {
db *sqlx.DB db *sqlx.DB
storage *StorageClient storage *StorageClient
audit *AuditService
} }
func NewDocumentService(db *sqlx.DB, storage *StorageClient) *DocumentService { func NewDocumentService(db *sqlx.DB, storage *StorageClient, audit *AuditService) *DocumentService {
return &DocumentService{db: db, storage: storage} return &DocumentService{db: db, storage: storage, audit: audit}
} }
type CreateDocumentInput struct { 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 { 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) return nil, fmt.Errorf("fetching created document: %w", err)
} }
s.audit.Log(ctx, "create", "document", &id, nil, doc)
return &doc, nil return &doc, nil
} }
@@ -151,6 +153,7 @@ func (s *DocumentService) Delete(ctx context.Context, tenantID, docID, userID uu
// Log case event // Log case event
createEvent(ctx, s.db, tenantID, doc.CaseID, userID, "document_deleted", createEvent(ctx, s.db, tenantID, doc.CaseID, userID, "document_deleted",
fmt.Sprintf("Document deleted: %s", doc.Title), nil) fmt.Sprintf("Document deleted: %s", doc.Title), nil)
s.audit.Log(ctx, "delete", "document", &docID, doc, nil)
return nil return nil
} }

View File

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

View File

@@ -0,0 +1,501 @@
package services
import (
"context"
"fmt"
"log/slog"
"os/exec"
"strings"
"sync"
"time"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"github.com/lib/pq"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/models"
)
// NotificationService handles notification CRUD, deadline reminders, and email sending.
type NotificationService struct {
db *sqlx.DB
stopCh chan struct{}
wg sync.WaitGroup
}
// NewNotificationService creates a new notification service.
func NewNotificationService(db *sqlx.DB) *NotificationService {
return &NotificationService{
db: db,
stopCh: make(chan struct{}),
}
}
// Start launches the background reminder checker (every hour) and daily digest (8am).
func (s *NotificationService) Start() {
s.wg.Add(1)
go s.backgroundLoop()
}
// Stop gracefully shuts down background workers.
func (s *NotificationService) Stop() {
close(s.stopCh)
s.wg.Wait()
}
func (s *NotificationService) backgroundLoop() {
defer s.wg.Done()
// Check reminders on startup
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
s.CheckDeadlineReminders(ctx)
cancel()
reminderTicker := time.NewTicker(1 * time.Hour)
defer reminderTicker.Stop()
// Digest ticker: check every 15 minutes, send at 8am
digestTicker := time.NewTicker(15 * time.Minute)
defer digestTicker.Stop()
var lastDigestDate string
for {
select {
case <-s.stopCh:
return
case <-reminderTicker.C:
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
s.CheckDeadlineReminders(ctx)
cancel()
case now := <-digestTicker.C:
today := now.Format("2006-01-02")
hour := now.Hour()
if hour >= 8 && lastDigestDate != today {
lastDigestDate = today
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
s.SendDailyDigests(ctx)
cancel()
}
}
}
}
// CheckDeadlineReminders finds deadlines due in N days matching user preferences and creates notifications.
func (s *NotificationService) CheckDeadlineReminders(ctx context.Context) {
slog.Info("checking deadline reminders")
// Get all user preferences with email enabled
var prefs []models.NotificationPreferences
err := s.db.SelectContext(ctx, &prefs,
`SELECT user_id, tenant_id, deadline_reminder_days, email_enabled, daily_digest, created_at, updated_at
FROM notification_preferences`)
if err != nil {
slog.Error("failed to load notification preferences", "error", err)
return
}
if len(prefs) == 0 {
return
}
// Collect all unique reminder day values across all users
daySet := make(map[int64]bool)
for _, p := range prefs {
for _, d := range p.DeadlineReminderDays {
daySet[d] = true
}
}
if len(daySet) == 0 {
return
}
// Build array of target dates
today := time.Now().Truncate(24 * time.Hour)
var targetDates []string
dayToDate := make(map[string]int64)
for d := range daySet {
target := today.AddDate(0, 0, int(d))
dateStr := target.Format("2006-01-02")
targetDates = append(targetDates, dateStr)
dayToDate[dateStr] = d
}
// Also check overdue deadlines
todayStr := today.Format("2006-01-02")
// Find pending deadlines matching target dates
type deadlineRow struct {
models.Deadline
CaseTitle string `db:"case_title"`
CaseNumber string `db:"case_number"`
}
// Reminder deadlines (due in N days)
var reminderDeadlines []deadlineRow
query, args, err := sqlx.In(
`SELECT d.*, c.title AS case_title, c.case_number
FROM deadlines d
JOIN cases c ON c.id = d.case_id
WHERE d.status = 'pending' AND d.due_date IN (?)`,
targetDates)
if err == nil {
query = s.db.Rebind(query)
err = s.db.SelectContext(ctx, &reminderDeadlines, query, args...)
}
if err != nil {
slog.Error("failed to query reminder deadlines", "error", err)
}
// Overdue deadlines
var overdueDeadlines []deadlineRow
err = s.db.SelectContext(ctx, &overdueDeadlines,
`SELECT d.*, c.title AS case_title, c.case_number
FROM deadlines d
JOIN cases c ON c.id = d.case_id
WHERE d.status = 'pending' AND d.due_date < $1`, todayStr)
if err != nil {
slog.Error("failed to query overdue deadlines", "error", err)
}
// Create notifications for each user based on their tenant and preferences
for _, pref := range prefs {
// Reminder notifications
for _, dl := range reminderDeadlines {
if dl.TenantID != pref.TenantID {
continue
}
daysUntil := dayToDate[dl.DueDate]
// Check if this user cares about this many days
if !containsDay(pref.DeadlineReminderDays, daysUntil) {
continue
}
title := fmt.Sprintf("Frist in %d Tagen: %s", daysUntil, dl.Title)
body := fmt.Sprintf("Akte %s — %s\nFällig am %s", dl.CaseNumber, dl.CaseTitle, dl.DueDate)
entityType := "deadline"
s.CreateNotification(ctx, CreateNotificationInput{
TenantID: pref.TenantID,
UserID: pref.UserID,
Type: "deadline_reminder",
EntityType: &entityType,
EntityID: &dl.ID,
Title: title,
Body: &body,
SendEmail: pref.EmailEnabled && !pref.DailyDigest,
})
}
// Overdue notifications
for _, dl := range overdueDeadlines {
if dl.TenantID != pref.TenantID {
continue
}
title := fmt.Sprintf("Frist überfällig: %s", dl.Title)
body := fmt.Sprintf("Akte %s — %s\nFällig seit %s", dl.CaseNumber, dl.CaseTitle, dl.DueDate)
entityType := "deadline"
s.CreateNotification(ctx, CreateNotificationInput{
TenantID: pref.TenantID,
UserID: pref.UserID,
Type: "deadline_overdue",
EntityType: &entityType,
EntityID: &dl.ID,
Title: title,
Body: &body,
SendEmail: pref.EmailEnabled && !pref.DailyDigest,
})
}
}
}
// SendDailyDigests compiles pending notifications into one email per user.
func (s *NotificationService) SendDailyDigests(ctx context.Context) {
slog.Info("sending daily digests")
// Find users with daily_digest enabled
var prefs []models.NotificationPreferences
err := s.db.SelectContext(ctx, &prefs,
`SELECT user_id, tenant_id, deadline_reminder_days, email_enabled, daily_digest, created_at, updated_at
FROM notification_preferences
WHERE daily_digest = true AND email_enabled = true`)
if err != nil {
slog.Error("failed to load digest preferences", "error", err)
return
}
for _, pref := range prefs {
// Get unsent notifications for this user from the last 24 hours
var notifications []models.Notification
err := s.db.SelectContext(ctx, &notifications,
`SELECT id, tenant_id, user_id, type, entity_type, entity_id, title, body, sent_at, read_at, created_at
FROM notifications
WHERE user_id = $1 AND tenant_id = $2 AND sent_at IS NULL
AND created_at > now() - interval '24 hours'
ORDER BY created_at DESC`,
pref.UserID, pref.TenantID)
if err != nil {
slog.Error("failed to load unsent notifications", "error", err, "user_id", pref.UserID)
continue
}
if len(notifications) == 0 {
continue
}
// Get user email
email := s.getUserEmail(ctx, pref.UserID)
if email == "" {
continue
}
// Build digest
var lines []string
lines = append(lines, fmt.Sprintf("Guten Morgen! Hier ist Ihre Tagesübersicht mit %d Benachrichtigungen:\n", len(notifications)))
for _, n := range notifications {
body := ""
if n.Body != nil {
body = " — " + *n.Body
}
lines = append(lines, fmt.Sprintf("• %s%s", n.Title, body))
}
lines = append(lines, "\n---\nKanzlAI Kanzleimanagement")
subject := fmt.Sprintf("KanzlAI Tagesübersicht — %d Benachrichtigungen", len(notifications))
bodyText := strings.Join(lines, "\n")
if err := SendEmail(email, subject, bodyText); err != nil {
slog.Error("failed to send digest email", "error", err, "user_id", pref.UserID)
continue
}
// Mark all as sent
ids := make([]uuid.UUID, len(notifications))
for i, n := range notifications {
ids[i] = n.ID
}
query, args, err := sqlx.In(
`UPDATE notifications SET sent_at = now() WHERE id IN (?)`, ids)
if err == nil {
query = s.db.Rebind(query)
_, err = s.db.ExecContext(ctx, query, args...)
}
if err != nil {
slog.Error("failed to mark digest notifications sent", "error", err)
}
slog.Info("sent daily digest", "user_id", pref.UserID, "count", len(notifications))
}
}
// CreateNotificationInput holds the data for creating a notification.
type CreateNotificationInput struct {
TenantID uuid.UUID
UserID uuid.UUID
Type string
EntityType *string
EntityID *uuid.UUID
Title string
Body *string
SendEmail bool
}
// CreateNotification stores a notification in the DB and optionally sends an email.
func (s *NotificationService) CreateNotification(ctx context.Context, input CreateNotificationInput) (*models.Notification, error) {
// Dedup: check if we already sent this notification today
if input.EntityID != nil {
var count int
err := s.db.GetContext(ctx, &count,
`SELECT COUNT(*) FROM notifications
WHERE user_id = $1 AND entity_id = $2 AND type = $3
AND created_at::date = CURRENT_DATE`,
input.UserID, input.EntityID, input.Type)
if err == nil && count > 0 {
return nil, nil // Already notified today
}
}
var n models.Notification
err := s.db.QueryRowxContext(ctx,
`INSERT INTO notifications (tenant_id, user_id, type, entity_type, entity_id, title, body)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id, tenant_id, user_id, type, entity_type, entity_id, title, body, sent_at, read_at, created_at`,
input.TenantID, input.UserID, input.Type, input.EntityType, input.EntityID,
input.Title, input.Body).StructScan(&n)
if err != nil {
slog.Error("failed to create notification", "error", err)
return nil, fmt.Errorf("create notification: %w", err)
}
// Send email immediately if requested (non-digest users)
if input.SendEmail {
email := s.getUserEmail(ctx, input.UserID)
if email != "" {
go func() {
if err := SendEmail(email, input.Title, derefStr(input.Body)); err != nil {
slog.Error("failed to send notification email", "error", err, "user_id", input.UserID)
} else {
// Mark as sent
_, _ = s.db.Exec(`UPDATE notifications SET sent_at = now() WHERE id = $1`, n.ID)
}
}()
}
}
return &n, nil
}
// ListForUser returns notifications for a user in a tenant, paginated.
func (s *NotificationService) ListForUser(ctx context.Context, tenantID, userID uuid.UUID, limit, offset int) ([]models.Notification, int, error) {
if limit <= 0 {
limit = 50
}
if limit > 200 {
limit = 200
}
var total int
err := s.db.GetContext(ctx, &total,
`SELECT COUNT(*) FROM notifications WHERE user_id = $1 AND tenant_id = $2`,
userID, tenantID)
if err != nil {
return nil, 0, fmt.Errorf("count notifications: %w", err)
}
var notifications []models.Notification
err = s.db.SelectContext(ctx, &notifications,
`SELECT id, tenant_id, user_id, type, entity_type, entity_id, title, body, sent_at, read_at, created_at
FROM notifications
WHERE user_id = $1 AND tenant_id = $2
ORDER BY created_at DESC
LIMIT $3 OFFSET $4`,
userID, tenantID, limit, offset)
if err != nil {
return nil, 0, fmt.Errorf("list notifications: %w", err)
}
return notifications, total, nil
}
// UnreadCount returns the number of unread notifications for a user.
func (s *NotificationService) UnreadCount(ctx context.Context, tenantID, userID uuid.UUID) (int, error) {
var count int
err := s.db.GetContext(ctx, &count,
`SELECT COUNT(*) FROM notifications WHERE user_id = $1 AND tenant_id = $2 AND read_at IS NULL`,
userID, tenantID)
return count, err
}
// MarkRead marks a single notification as read.
func (s *NotificationService) MarkRead(ctx context.Context, tenantID, userID, notificationID uuid.UUID) error {
result, err := s.db.ExecContext(ctx,
`UPDATE notifications SET read_at = now()
WHERE id = $1 AND user_id = $2 AND tenant_id = $3 AND read_at IS NULL`,
notificationID, userID, tenantID)
if err != nil {
return fmt.Errorf("mark notification read: %w", err)
}
rows, _ := result.RowsAffected()
if rows == 0 {
return fmt.Errorf("notification not found or already read")
}
return nil
}
// MarkAllRead marks all notifications as read for a user.
func (s *NotificationService) MarkAllRead(ctx context.Context, tenantID, userID uuid.UUID) error {
_, err := s.db.ExecContext(ctx,
`UPDATE notifications SET read_at = now()
WHERE user_id = $1 AND tenant_id = $2 AND read_at IS NULL`,
userID, tenantID)
return err
}
// GetPreferences returns notification preferences for a user, creating defaults if needed.
func (s *NotificationService) GetPreferences(ctx context.Context, tenantID, userID uuid.UUID) (*models.NotificationPreferences, error) {
var pref models.NotificationPreferences
err := s.db.GetContext(ctx, &pref,
`SELECT user_id, tenant_id, deadline_reminder_days, email_enabled, daily_digest, created_at, updated_at
FROM notification_preferences
WHERE user_id = $1 AND tenant_id = $2`,
userID, tenantID)
if err != nil {
// Return defaults if no preferences set
return &models.NotificationPreferences{
UserID: userID,
TenantID: tenantID,
DeadlineReminderDays: pq.Int64Array{7, 3, 1},
EmailEnabled: true,
DailyDigest: false,
}, nil
}
return &pref, nil
}
// UpdatePreferences upserts notification preferences for a user.
func (s *NotificationService) UpdatePreferences(ctx context.Context, tenantID, userID uuid.UUID, input UpdatePreferencesInput) (*models.NotificationPreferences, error) {
var pref models.NotificationPreferences
err := s.db.QueryRowxContext(ctx,
`INSERT INTO notification_preferences (user_id, tenant_id, deadline_reminder_days, email_enabled, daily_digest)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (user_id, tenant_id)
DO UPDATE SET deadline_reminder_days = $3, email_enabled = $4, daily_digest = $5, updated_at = now()
RETURNING user_id, tenant_id, deadline_reminder_days, email_enabled, daily_digest, created_at, updated_at`,
userID, tenantID, pq.Int64Array(input.DeadlineReminderDays), input.EmailEnabled, input.DailyDigest).StructScan(&pref)
if err != nil {
return nil, fmt.Errorf("update preferences: %w", err)
}
return &pref, nil
}
// UpdatePreferencesInput holds the data for updating notification preferences.
type UpdatePreferencesInput struct {
DeadlineReminderDays []int64 `json:"deadline_reminder_days"`
EmailEnabled bool `json:"email_enabled"`
DailyDigest bool `json:"daily_digest"`
}
// SendEmail sends an email using the `m mail send` CLI command.
func SendEmail(to, subject, body string) error {
cmd := exec.Command("m", "mail", "send",
"--to", to,
"--subject", subject,
"--body", body,
"--yes")
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("m mail send failed: %w (output: %s)", err, string(output))
}
slog.Info("email sent", "to", to, "subject", subject)
return nil
}
// getUserEmail looks up the email for a user from Supabase auth.users.
func (s *NotificationService) getUserEmail(ctx context.Context, userID uuid.UUID) string {
var email string
err := s.db.GetContext(ctx, &email,
`SELECT email FROM auth.users WHERE id = $1`, userID)
if err != nil {
slog.Error("failed to get user email", "error", err, "user_id", userID)
return ""
}
return email
}
func containsDay(arr pq.Int64Array, day int64) bool {
for _, d := range arr {
if d == day {
return true
}
}
return false
}
func derefStr(s *string) string {
if s == nil {
return ""
}
return *s
}

View File

@@ -13,11 +13,12 @@ import (
) )
type PartyService struct { type PartyService struct {
db *sqlx.DB db *sqlx.DB
audit *AuditService
} }
func NewPartyService(db *sqlx.DB) *PartyService { func NewPartyService(db *sqlx.DB, audit *AuditService) *PartyService {
return &PartyService{db: db} return &PartyService{db: db, audit: audit}
} }
type CreatePartyInput struct { 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 { 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) return nil, fmt.Errorf("fetching created party: %w", err)
} }
s.audit.Log(ctx, "create", "party", &id, nil, party)
return &party, nil 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 { 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) return nil, fmt.Errorf("fetching updated party: %w", err)
} }
s.audit.Log(ctx, "update", "party", &partyID, current, updated)
return &updated, nil return &updated, nil
} }
@@ -148,5 +151,6 @@ func (s *PartyService) Delete(ctx context.Context, tenantID, partyID uuid.UUID)
if rows == 0 { if rows == 0 {
return sql.ErrNoRows return sql.ErrNoRows
} }
s.audit.Log(ctx, "delete", "party", &partyID, nil, nil)
return nil return nil
} }

View File

@@ -0,0 +1,330 @@
package services
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"strings"
"time"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/models"
)
type TemplateService struct {
db *sqlx.DB
audit *AuditService
}
func NewTemplateService(db *sqlx.DB, audit *AuditService) *TemplateService {
return &TemplateService{db: db, audit: audit}
}
type TemplateFilter struct {
Category string
Search string
Limit int
Offset int
}
type CreateTemplateInput struct {
Name string `json:"name"`
Description *string `json:"description,omitempty"`
Category string `json:"category"`
Content string `json:"content"`
Variables []byte `json:"variables,omitempty"`
}
type UpdateTemplateInput struct {
Name *string `json:"name,omitempty"`
Description *string `json:"description,omitempty"`
Category *string `json:"category,omitempty"`
Content *string `json:"content,omitempty"`
Variables []byte `json:"variables,omitempty"`
}
var validCategories = map[string]bool{
"schriftsatz": true,
"vertrag": true,
"korrespondenz": true,
"intern": true,
}
func (s *TemplateService) List(ctx context.Context, tenantID uuid.UUID, filter TemplateFilter) ([]models.DocumentTemplate, int, error) {
if filter.Limit <= 0 {
filter.Limit = 50
}
if filter.Limit > 100 {
filter.Limit = 100
}
// Show system templates + tenant's own templates
where := "WHERE (tenant_id = $1 OR is_system = true)"
args := []any{tenantID}
argIdx := 2
if filter.Category != "" {
where += fmt.Sprintf(" AND category = $%d", argIdx)
args = append(args, filter.Category)
argIdx++
}
if filter.Search != "" {
where += fmt.Sprintf(" AND (name ILIKE $%d OR description ILIKE $%d)", argIdx, argIdx)
args = append(args, "%"+filter.Search+"%")
argIdx++
}
var total int
countQ := "SELECT COUNT(*) FROM document_templates " + where
if err := s.db.GetContext(ctx, &total, countQ, args...); err != nil {
return nil, 0, fmt.Errorf("counting templates: %w", err)
}
query := "SELECT * FROM document_templates " + where + " ORDER BY is_system DESC, name ASC"
query += fmt.Sprintf(" LIMIT $%d OFFSET $%d", argIdx, argIdx+1)
args = append(args, filter.Limit, filter.Offset)
var templates []models.DocumentTemplate
if err := s.db.SelectContext(ctx, &templates, query, args...); err != nil {
return nil, 0, fmt.Errorf("listing templates: %w", err)
}
return templates, total, nil
}
func (s *TemplateService) GetByID(ctx context.Context, tenantID, templateID uuid.UUID) (*models.DocumentTemplate, error) {
var t models.DocumentTemplate
err := s.db.GetContext(ctx, &t,
"SELECT * FROM document_templates WHERE id = $1 AND (tenant_id = $2 OR is_system = true)",
templateID, tenantID)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("getting template: %w", err)
}
return &t, nil
}
func (s *TemplateService) Create(ctx context.Context, tenantID uuid.UUID, input CreateTemplateInput) (*models.DocumentTemplate, error) {
if input.Name == "" {
return nil, fmt.Errorf("name is required")
}
if !validCategories[input.Category] {
return nil, fmt.Errorf("invalid category: %s", input.Category)
}
variables := input.Variables
if variables == nil {
variables = []byte("[]")
}
var t models.DocumentTemplate
err := s.db.GetContext(ctx, &t,
`INSERT INTO document_templates (tenant_id, name, description, category, content, variables, is_system)
VALUES ($1, $2, $3, $4, $5, $6, false)
RETURNING *`,
tenantID, input.Name, input.Description, input.Category, input.Content, variables)
if err != nil {
return nil, fmt.Errorf("creating template: %w", err)
}
s.audit.Log(ctx, "create", "document_template", &t.ID, nil, t)
return &t, nil
}
func (s *TemplateService) Update(ctx context.Context, tenantID, templateID uuid.UUID, input UpdateTemplateInput) (*models.DocumentTemplate, error) {
// Don't allow editing system templates
existing, err := s.GetByID(ctx, tenantID, templateID)
if err != nil {
return nil, err
}
if existing == nil {
return nil, nil
}
if existing.IsSystem {
return nil, fmt.Errorf("system templates cannot be edited")
}
if existing.TenantID == nil || *existing.TenantID != tenantID {
return nil, fmt.Errorf("template does not belong to tenant")
}
sets := []string{}
args := []any{}
argIdx := 1
if input.Name != nil {
sets = append(sets, fmt.Sprintf("name = $%d", argIdx))
args = append(args, *input.Name)
argIdx++
}
if input.Description != nil {
sets = append(sets, fmt.Sprintf("description = $%d", argIdx))
args = append(args, *input.Description)
argIdx++
}
if input.Category != nil {
if !validCategories[*input.Category] {
return nil, fmt.Errorf("invalid category: %s", *input.Category)
}
sets = append(sets, fmt.Sprintf("category = $%d", argIdx))
args = append(args, *input.Category)
argIdx++
}
if input.Content != nil {
sets = append(sets, fmt.Sprintf("content = $%d", argIdx))
args = append(args, *input.Content)
argIdx++
}
if input.Variables != nil {
sets = append(sets, fmt.Sprintf("variables = $%d", argIdx))
args = append(args, input.Variables)
argIdx++
}
if len(sets) == 0 {
return existing, nil
}
sets = append(sets, "updated_at = now()")
query := fmt.Sprintf("UPDATE document_templates SET %s WHERE id = $%d AND tenant_id = $%d RETURNING *",
strings.Join(sets, ", "), argIdx, argIdx+1)
args = append(args, templateID, tenantID)
var t models.DocumentTemplate
if err := s.db.GetContext(ctx, &t, query, args...); err != nil {
return nil, fmt.Errorf("updating template: %w", err)
}
s.audit.Log(ctx, "update", "document_template", &t.ID, existing, t)
return &t, nil
}
func (s *TemplateService) Delete(ctx context.Context, tenantID, templateID uuid.UUID) error {
// Don't allow deleting system templates
existing, err := s.GetByID(ctx, tenantID, templateID)
if err != nil {
return err
}
if existing == nil {
return fmt.Errorf("template not found")
}
if existing.IsSystem {
return fmt.Errorf("system templates cannot be deleted")
}
if existing.TenantID == nil || *existing.TenantID != tenantID {
return fmt.Errorf("template does not belong to tenant")
}
_, err = s.db.ExecContext(ctx, "DELETE FROM document_templates WHERE id = $1 AND tenant_id = $2", templateID, tenantID)
if err != nil {
return fmt.Errorf("deleting template: %w", err)
}
s.audit.Log(ctx, "delete", "document_template", &templateID, existing, nil)
return nil
}
// RenderData holds all the data available for template variable replacement.
type RenderData struct {
Case *models.Case
Parties []models.Party
Tenant *models.Tenant
Deadline *models.Deadline
UserName string
UserEmail string
}
// Render replaces {{placeholders}} in the template content with actual data.
func (s *TemplateService) Render(template *models.DocumentTemplate, data RenderData) string {
content := template.Content
now := time.Now()
replacements := map[string]string{
"{{date.today}}": now.Format("02.01.2006"),
"{{date.today_long}}": formatGermanDate(now),
}
// Case data
if data.Case != nil {
replacements["{{case.number}}"] = data.Case.CaseNumber
replacements["{{case.title}}"] = data.Case.Title
if data.Case.Court != nil {
replacements["{{case.court}}"] = *data.Case.Court
}
if data.Case.CourtRef != nil {
replacements["{{case.court_ref}}"] = *data.Case.CourtRef
}
}
// Party data
for _, p := range data.Parties {
role := ""
if p.Role != nil {
role = *p.Role
}
switch role {
case "claimant", "plaintiff", "klaeger":
replacements["{{party.claimant.name}}"] = p.Name
if p.Representative != nil {
replacements["{{party.claimant.representative}}"] = *p.Representative
}
case "defendant", "beklagter":
replacements["{{party.defendant.name}}"] = p.Name
if p.Representative != nil {
replacements["{{party.defendant.representative}}"] = *p.Representative
}
}
}
// Tenant data
if data.Tenant != nil {
replacements["{{tenant.name}}"] = data.Tenant.Name
// Extract address from settings if available
replacements["{{tenant.address}}"] = extractSettingsField(data.Tenant.Settings, "address")
}
// User data
replacements["{{user.name}}"] = data.UserName
replacements["{{user.email}}"] = data.UserEmail
// Deadline data
if data.Deadline != nil {
replacements["{{deadline.title}}"] = data.Deadline.Title
replacements["{{deadline.due_date}}"] = data.Deadline.DueDate
}
for placeholder, value := range replacements {
content = strings.ReplaceAll(content, placeholder, value)
}
return content
}
func formatGermanDate(t time.Time) string {
months := []string{
"Januar", "Februar", "März", "April", "Mai", "Juni",
"Juli", "August", "September", "Oktober", "November", "Dezember",
}
return fmt.Sprintf("%d. %s %d", t.Day(), months[t.Month()-1], t.Year())
}
func extractSettingsField(settings []byte, field string) string {
if len(settings) == 0 {
return ""
}
var m map[string]any
if err := json.Unmarshal(settings, &m); err != nil {
return ""
}
if v, ok := m[field]; ok {
if s, ok := v.(string); ok {
return s
}
}
return ""
}

View File

@@ -13,11 +13,12 @@ import (
) )
type TenantService struct { type TenantService struct {
db *sqlx.DB db *sqlx.DB
audit *AuditService
} }
func NewTenantService(db *sqlx.DB) *TenantService { func NewTenantService(db *sqlx.DB, audit *AuditService) *TenantService {
return &TenantService{db: db} return &TenantService{db: db, audit: audit}
} }
// Create creates a new tenant and assigns the creator as owner. // 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) return nil, fmt.Errorf("commit: %w", err)
} }
s.audit.Log(ctx, "create", "tenant", &tenant.ID, nil, tenant)
return &tenant, nil return &tenant, nil
} }
@@ -101,6 +103,19 @@ func (s *TenantService) GetUserRole(ctx context.Context, userID, tenantID uuid.U
return role, nil return role, nil
} }
// VerifyAccess checks if a user has access to a given tenant.
func (s *TenantService) VerifyAccess(ctx context.Context, userID, tenantID uuid.UUID) (bool, error) {
var exists bool
err := s.db.GetContext(ctx, &exists,
`SELECT EXISTS(SELECT 1 FROM user_tenants WHERE user_id = $1 AND tenant_id = $2)`,
userID, tenantID,
)
if err != nil {
return false, fmt.Errorf("verify tenant access: %w", err)
}
return exists, nil
}
// FirstTenantForUser returns the user's first tenant (by name), used as default. // FirstTenantForUser returns the user's first tenant (by name), used as default.
func (s *TenantService) FirstTenantForUser(ctx context.Context, userID uuid.UUID) (*uuid.UUID, error) { func (s *TenantService) FirstTenantForUser(ctx context.Context, userID uuid.UUID) (*uuid.UUID, error) {
var tenantID uuid.UUID var tenantID uuid.UUID
@@ -171,6 +186,7 @@ func (s *TenantService) InviteByEmail(ctx context.Context, tenantID uuid.UUID, e
return nil, fmt.Errorf("invite user: %w", err) return nil, fmt.Errorf("invite user: %w", err)
} }
s.audit.Log(ctx, "create", "membership", &tenantID, nil, ut)
return &ut, nil return &ut, nil
} }
@@ -186,6 +202,7 @@ func (s *TenantService) UpdateSettings(ctx context.Context, tenantID uuid.UUID,
if err != nil { if err != nil {
return nil, fmt.Errorf("update settings: %w", err) return nil, fmt.Errorf("update settings: %w", err)
} }
s.audit.Log(ctx, "update", "settings", &tenantID, nil, settings)
return &tenant, nil return &tenant, nil
} }
@@ -257,5 +274,6 @@ func (s *TenantService) RemoveMember(ctx context.Context, tenantID, userID uuid.
return fmt.Errorf("remove member: %w", err) 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 return nil
} }

View File

@@ -16,6 +16,8 @@ import {
UserCheck, UserCheck,
StickyNote, StickyNote,
AlertTriangle, AlertTriangle,
ScrollText,
FilePlus,
} from "lucide-react"; } from "lucide-react";
import { format } from "date-fns"; import { format } from "date-fns";
import { de } from "date-fns/locale"; import { de } from "date-fns/locale";
@@ -46,6 +48,7 @@ const TABS = [
{ segment: "parteien", label: "Parteien", icon: Users }, { segment: "parteien", label: "Parteien", icon: Users },
{ segment: "mitarbeiter", label: "Mitarbeiter", icon: UserCheck }, { segment: "mitarbeiter", label: "Mitarbeiter", icon: UserCheck },
{ segment: "notizen", label: "Notizen", icon: StickyNote }, { segment: "notizen", label: "Notizen", icon: StickyNote },
{ segment: "protokoll", label: "Protokoll", icon: ScrollText },
] as const; ] as const;
const TAB_LABELS: Record<string, string> = { const TAB_LABELS: Record<string, string> = {
@@ -55,6 +58,7 @@ const TAB_LABELS: Record<string, string> = {
parteien: "Parteien", parteien: "Parteien",
mitarbeiter: "Mitarbeiter", mitarbeiter: "Mitarbeiter",
notizen: "Notizen", notizen: "Notizen",
protokoll: "Protokoll",
}; };
function CaseDetailSkeleton() { function CaseDetailSkeleton() {
@@ -168,19 +172,28 @@ export default function CaseDetailLayout({
{caseDetail.court_ref && <span>({caseDetail.court_ref})</span>} {caseDetail.court_ref && <span>({caseDetail.court_ref})</span>}
</div> </div>
</div> </div>
<div className="text-right text-xs text-neutral-400"> <div className="flex flex-col items-end gap-2">
<p> <Link
Erstellt:{" "} href={`/vorlagen?case_id=${id}`}
{format(new Date(caseDetail.created_at), "d. MMM yyyy", { className="flex items-center gap-1.5 rounded-md bg-neutral-900 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-neutral-800"
locale: de, >
})} <FilePlus className="h-3.5 w-3.5" />
</p> Schriftsatz erstellen
<p> </Link>
Aktualisiert:{" "} <div className="text-right text-xs text-neutral-400">
{format(new Date(caseDetail.updated_at), "d. MMM yyyy", { <p>
locale: de, Erstellt:{" "}
})} {format(new Date(caseDetail.created_at), "d. MMM yyyy", {
</p> locale: de,
})}
</p>
<p>
Aktualisiert:{" "}
{format(new Date(caseDetail.updated_at), "d. MMM yyyy", {
locale: de,
})}
</p>
</div>
</div> </div>
</div> </div>

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

@@ -1,11 +1,12 @@
"use client"; "use client";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { Settings, Calendar, Users } from "lucide-react"; import { Settings, Calendar, Users, Bell } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
import type { Tenant } from "@/lib/types"; import type { Tenant } from "@/lib/types";
import { CalDAVSettings } from "@/components/settings/CalDAVSettings"; import { CalDAVSettings } from "@/components/settings/CalDAVSettings";
import { NotificationSettings } from "@/components/settings/NotificationSettings";
import { SkeletonCard } from "@/components/ui/Skeleton"; import { SkeletonCard } from "@/components/ui/Skeleton";
import { EmptyState } from "@/components/ui/EmptyState"; import { EmptyState } from "@/components/ui/EmptyState";
@@ -97,6 +98,19 @@ export default function EinstellungenPage() {
</div> </div>
</section> </section>
{/* Notification Settings */}
<section className="rounded-xl border border-neutral-200 bg-white p-5">
<div className="flex items-center gap-2.5 border-b border-neutral-100 pb-3">
<Bell className="h-4 w-4 text-neutral-500" />
<h2 className="text-sm font-semibold text-neutral-900">
Benachrichtigungen
</h2>
</div>
<div className="mt-4">
<NotificationSettings />
</div>
</section>
{/* CalDAV Settings */} {/* CalDAV Settings */}
<section className="rounded-xl border border-neutral-200 bg-white p-5"> <section className="rounded-xl border border-neutral-200 bg-white p-5">
<div className="flex items-center gap-2.5 border-b border-neutral-100 pb-3"> <div className="flex items-center gap-2.5 border-b border-neutral-100 pb-3">

View File

@@ -0,0 +1,174 @@
"use client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useParams, useRouter } from "next/navigation";
import { api } from "@/lib/api";
import type { DocumentTemplate } from "@/lib/types";
import { TEMPLATE_CATEGORY_LABELS } from "@/lib/types";
import { Breadcrumb } from "@/components/layout/Breadcrumb";
import { TemplateEditor } from "@/components/templates/TemplateEditor";
import Link from "next/link";
import {
Loader2,
Lock,
Trash2,
FileDown,
ArrowRight,
} from "lucide-react";
import { toast } from "sonner";
import { useState } from "react";
export default function TemplateDetailPage() {
const { id } = useParams<{ id: string }>();
const router = useRouter();
const queryClient = useQueryClient();
const [isEditing, setIsEditing] = useState(false);
const { data: template, isLoading } = useQuery({
queryKey: ["template", id],
queryFn: () => api.get<DocumentTemplate>(`/templates/${id}`),
});
const deleteMutation = useMutation({
mutationFn: () => api.delete(`/templates/${id}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["templates"] });
toast.success("Vorlage gelöscht");
router.push("/vorlagen");
},
onError: () => toast.error("Fehler beim Löschen"),
});
const updateMutation = useMutation({
mutationFn: (data: Partial<DocumentTemplate>) =>
api.put<DocumentTemplate>(`/templates/${id}`, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["template", id] });
queryClient.invalidateQueries({ queryKey: ["templates"] });
toast.success("Vorlage gespeichert");
setIsEditing(false);
},
onError: () => toast.error("Fehler beim Speichern"),
});
if (isLoading) {
return (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-5 w-5 animate-spin text-neutral-400" />
</div>
);
}
if (!template) {
return (
<div className="py-12 text-center text-sm text-neutral-500">
Vorlage nicht gefunden
</div>
);
}
return (
<div className="animate-fade-in space-y-4">
<Breadcrumb
items={[
{ label: "Dashboard", href: "/dashboard" },
{ label: "Vorlagen", href: "/vorlagen" },
{ label: template.name },
]}
/>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<div className="flex items-center gap-2">
<h1 className="text-lg font-semibold text-neutral-900">
{template.name}
</h1>
{template.is_system && (
<Lock className="h-4 w-4 text-neutral-400" aria-label="Systemvorlage" />
)}
</div>
<div className="mt-1 flex items-center gap-2">
<span className="rounded-full bg-neutral-100 px-2 py-0.5 text-xs text-neutral-600">
{TEMPLATE_CATEGORY_LABELS[template.category] ?? template.category}
</span>
{template.description && (
<span className="text-xs text-neutral-500">
{template.description}
</span>
)}
</div>
</div>
<div className="flex items-center gap-2">
<Link
href={`/vorlagen/${id}/render`}
className="flex items-center gap-1.5 rounded-md bg-neutral-900 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-neutral-800"
>
<FileDown className="h-3.5 w-3.5" />
Dokument erstellen
<ArrowRight className="h-3.5 w-3.5" />
</Link>
{!template.is_system && (
<>
<button
onClick={() => setIsEditing(!isEditing)}
className="rounded-md border border-neutral-200 bg-white px-3 py-1.5 text-sm text-neutral-700 transition-colors hover:bg-neutral-50"
>
{isEditing ? "Abbrechen" : "Bearbeiten"}
</button>
<button
onClick={() => {
if (confirm("Vorlage wirklich löschen?")) {
deleteMutation.mutate();
}
}}
className="rounded-md border border-red-200 bg-white p-1.5 text-red-600 transition-colors hover:bg-red-50"
>
<Trash2 className="h-4 w-4" />
</button>
</>
)}
</div>
</div>
{isEditing ? (
<TemplateEditor
template={template}
onSave={(data) => updateMutation.mutate(data)}
isSaving={updateMutation.isPending}
/>
) : (
<div className="space-y-4">
{/* Variables */}
{template.variables && template.variables.length > 0 && (
<div className="rounded-lg border border-neutral-200 bg-white p-4">
<h3 className="mb-2 text-sm font-medium text-neutral-700">
Variablen
</h3>
<div className="flex flex-wrap gap-1.5">
{template.variables.map((v: string) => (
<code
key={v}
className="rounded bg-neutral-100 px-1.5 py-0.5 text-xs text-neutral-600"
>
{`{{${v}}}`}
</code>
))}
</div>
</div>
)}
{/* Content preview */}
<div className="rounded-lg border border-neutral-200 bg-white p-6">
<h3 className="mb-3 text-sm font-medium text-neutral-700">
Vorschau
</h3>
<div className="prose prose-sm prose-neutral max-w-none whitespace-pre-wrap font-mono text-xs leading-relaxed text-neutral-700">
{template.content}
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,177 @@
"use client";
import { useQuery, useMutation } from "@tanstack/react-query";
import { useParams } from "next/navigation";
import { api } from "@/lib/api";
import type { DocumentTemplate, Case, RenderResponse } from "@/lib/types";
import { Breadcrumb } from "@/components/layout/Breadcrumb";
import {
Loader2,
FileDown,
Copy,
Check,
} from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
export default function RenderTemplatePage() {
const { id } = useParams<{ id: string }>();
const [selectedCaseId, setSelectedCaseId] = useState("");
const [rendered, setRendered] = useState<RenderResponse | null>(null);
const [copied, setCopied] = useState(false);
const { data: template, isLoading: templateLoading } = useQuery({
queryKey: ["template", id],
queryFn: () => api.get<DocumentTemplate>(`/templates/${id}`),
});
const { data: casesData, isLoading: casesLoading } = useQuery({
queryKey: ["cases"],
queryFn: () =>
api.get<{ data: Case[]; total: number }>("/cases?limit=100"),
});
const cases = casesData?.data ?? [];
const renderMutation = useMutation({
mutationFn: () =>
api.post<RenderResponse>(
`/templates/${id}/render${selectedCaseId ? `?case_id=${selectedCaseId}` : ""}`,
),
onSuccess: (data) => setRendered(data),
onError: () => toast.error("Fehler beim Erstellen"),
});
const handleCopy = async () => {
if (!rendered) return;
await navigator.clipboard.writeText(rendered.content);
setCopied(true);
toast.success("In Zwischenablage kopiert");
setTimeout(() => setCopied(false), 2000);
};
const handleDownload = () => {
if (!rendered) return;
const blob = new Blob([rendered.content], { type: "text/markdown" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${rendered.name.replace(/\s+/g, "_")}.md`;
a.click();
URL.revokeObjectURL(url);
toast.success("Dokument heruntergeladen");
};
if (templateLoading) {
return (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-5 w-5 animate-spin text-neutral-400" />
</div>
);
}
if (!template) {
return (
<div className="py-12 text-center text-sm text-neutral-500">
Vorlage nicht gefunden
</div>
);
}
return (
<div className="animate-fade-in space-y-4">
<Breadcrumb
items={[
{ label: "Dashboard", href: "/dashboard" },
{ label: "Vorlagen", href: "/vorlagen" },
{ label: template.name, href: `/vorlagen/${id}` },
{ label: "Dokument erstellen" },
]}
/>
<h1 className="text-lg font-semibold text-neutral-900">
Dokument erstellen
</h1>
<p className="text-sm text-neutral-500">
Vorlage &ldquo;{template.name}&rdquo; mit Falldaten befüllen
</p>
{/* Step 1: Select case */}
<div className="rounded-lg border border-neutral-200 bg-white p-4">
<h3 className="mb-3 text-sm font-medium text-neutral-700">
1. Akte auswählen
</h3>
{casesLoading ? (
<Loader2 className="h-4 w-4 animate-spin text-neutral-400" />
) : (
<select
value={selectedCaseId}
onChange={(e) => {
setSelectedCaseId(e.target.value);
setRendered(null);
}}
className="w-full rounded-md border border-neutral-200 px-3 py-2 text-sm text-neutral-700 focus:border-neutral-400 focus:outline-none"
>
<option value="">Ohne Akte (nur Datumsvariablen)</option>
{cases.map((c) => (
<option key={c.id} value={c.id}>
{c.case_number} {c.title}
</option>
))}
</select>
)}
</div>
{/* Step 2: Render */}
<div className="rounded-lg border border-neutral-200 bg-white p-4">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-neutral-700">
2. Vorschau erstellen
</h3>
<button
onClick={() => renderMutation.mutate()}
disabled={renderMutation.isPending}
className="flex items-center gap-1.5 rounded-md bg-neutral-900 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-neutral-800 disabled:opacity-50"
>
{renderMutation.isPending ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<FileDown className="h-3.5 w-3.5" />
)}
Vorschau
</button>
</div>
{rendered && (
<div className="mt-4">
<div className="mb-2 flex items-center justify-end gap-2">
<button
onClick={handleCopy}
className="flex items-center gap-1 rounded-md border border-neutral-200 px-2.5 py-1 text-xs text-neutral-600 transition-colors hover:bg-neutral-50"
>
{copied ? (
<Check className="h-3 w-3" />
) : (
<Copy className="h-3 w-3" />
)}
{copied ? "Kopiert" : "Kopieren"}
</button>
<button
onClick={handleDownload}
className="flex items-center gap-1 rounded-md border border-neutral-200 px-2.5 py-1 text-xs text-neutral-600 transition-colors hover:bg-neutral-50"
>
<FileDown className="h-3 w-3" />
Herunterladen
</button>
</div>
<div className="rounded-lg border border-neutral-200 bg-neutral-50 p-6">
<div className="whitespace-pre-wrap font-mono text-xs leading-relaxed text-neutral-700">
{rendered.content}
</div>
</div>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,46 @@
"use client";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useRouter } from "next/navigation";
import { api } from "@/lib/api";
import type { DocumentTemplate } from "@/lib/types";
import { Breadcrumb } from "@/components/layout/Breadcrumb";
import { TemplateEditor } from "@/components/templates/TemplateEditor";
import { toast } from "sonner";
export default function NeueVorlagePage() {
const router = useRouter();
const queryClient = useQueryClient();
const createMutation = useMutation({
mutationFn: (data: Partial<DocumentTemplate>) =>
api.post<DocumentTemplate>("/templates", data),
onSuccess: (result) => {
queryClient.invalidateQueries({ queryKey: ["templates"] });
toast.success("Vorlage erstellt");
router.push(`/vorlagen/${result.id}`);
},
onError: () => toast.error("Fehler beim Erstellen"),
});
return (
<div className="animate-fade-in space-y-4">
<Breadcrumb
items={[
{ label: "Dashboard", href: "/dashboard" },
{ label: "Vorlagen", href: "/vorlagen" },
{ label: "Neue Vorlage" },
]}
/>
<h1 className="text-lg font-semibold text-neutral-900">
Neue Vorlage erstellen
</h1>
<TemplateEditor
onSave={(data) => createMutation.mutate(data)}
isSaving={createMutation.isPending}
/>
</div>
);
}

View File

@@ -0,0 +1,121 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { api } from "@/lib/api";
import type { DocumentTemplate } from "@/lib/types";
import { TEMPLATE_CATEGORY_LABELS } from "@/lib/types";
import { Breadcrumb } from "@/components/layout/Breadcrumb";
import Link from "next/link";
import { FileText, Plus, Loader2, Lock } from "lucide-react";
import { useState } from "react";
const CATEGORIES = ["", "schriftsatz", "vertrag", "korrespondenz", "intern"];
export default function VorlagenPage() {
const [category, setCategory] = useState("");
const { data, isLoading } = useQuery({
queryKey: ["templates", category],
queryFn: () =>
api.get<{ data: DocumentTemplate[]; total: number }>(
`/templates${category ? `?category=${category}` : ""}`,
),
});
const templates = data?.data ?? [];
return (
<div className="animate-fade-in space-y-4">
<div>
<Breadcrumb
items={[
{ label: "Dashboard", href: "/dashboard" },
{ label: "Vorlagen" },
]}
/>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-lg font-semibold text-neutral-900">
Vorlagen
</h1>
<p className="mt-0.5 text-sm text-neutral-500">
Dokumentvorlagen mit automatischer Befüllung
</p>
</div>
<Link
href="/vorlagen/neu"
className="flex items-center gap-1.5 rounded-md bg-neutral-900 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-neutral-800"
>
<Plus className="h-3.5 w-3.5" />
Neue Vorlage
</Link>
</div>
</div>
{/* Category filter */}
<div className="flex gap-1.5 overflow-x-auto">
{CATEGORIES.map((cat) => (
<button
key={cat}
onClick={() => setCategory(cat)}
className={`whitespace-nowrap rounded-md px-3 py-1.5 text-sm transition-colors ${
category === cat
? "bg-neutral-900 font-medium text-white"
: "bg-white text-neutral-600 ring-1 ring-neutral-200 hover:bg-neutral-50"
}`}
>
{cat === "" ? "Alle" : TEMPLATE_CATEGORY_LABELS[cat] ?? cat}
</button>
))}
</div>
{isLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-5 w-5 animate-spin text-neutral-400" />
</div>
) : templates.length === 0 ? (
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed border-neutral-300 py-12 text-center">
<FileText className="mb-2 h-8 w-8 text-neutral-300" />
<p className="text-sm text-neutral-500">Keine Vorlagen gefunden</p>
</div>
) : (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{templates.map((t) => (
<Link
key={t.id}
href={`/vorlagen/${t.id}`}
className="group rounded-lg border border-neutral-200 bg-white p-4 transition-colors hover:border-neutral-300 hover:shadow-sm"
>
<div className="flex items-start justify-between">
<div className="flex items-center gap-2">
<FileText className="h-4 w-4 text-neutral-400" />
<h3 className="text-sm font-medium text-neutral-900 group-hover:text-neutral-700">
{t.name}
</h3>
</div>
{t.is_system && (
<Lock className="h-3.5 w-3.5 text-neutral-300" aria-label="Systemvorlage" />
)}
</div>
{t.description && (
<p className="mt-1.5 text-xs text-neutral-500 line-clamp-2">
{t.description}
</p>
)}
<div className="mt-3 flex items-center gap-2">
<span className="rounded-full bg-neutral-100 px-2 py-0.5 text-xs text-neutral-600">
{TEMPLATE_CATEGORY_LABELS[t.category] ?? t.category}
</span>
{t.is_system && (
<span className="rounded-full bg-blue-50 px-2 py-0.5 text-xs text-blue-600">
System
</span>
)}
</div>
</Link>
))}
</div>
)}
</div>
);
}

View File

@@ -2,6 +2,7 @@
import { createClient } from "@/lib/supabase/client"; import { createClient } from "@/lib/supabase/client";
import { TenantSwitcher } from "./TenantSwitcher"; import { TenantSwitcher } from "./TenantSwitcher";
import { NotificationBell } from "@/components/notifications/NotificationBell";
import { LogOut } from "lucide-react"; import { LogOut } from "lucide-react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
@@ -29,6 +30,7 @@ export function Header() {
<div className="w-8 lg:w-0" /> <div className="w-8 lg:w-0" />
<div className="flex items-center gap-2 sm:gap-3"> <div className="flex items-center gap-2 sm:gap-3">
<TenantSwitcher /> <TenantSwitcher />
<NotificationBell />
{email && ( {email && (
<span className="hidden text-sm text-neutral-500 sm:inline"> <span className="hidden text-sm text-neutral-500 sm:inline">
{email} {email}

View File

@@ -9,6 +9,7 @@ import {
Calendar, Calendar,
Brain, Brain,
Settings, Settings,
FileText,
Menu, Menu,
X, X,
} from "lucide-react"; } from "lucide-react";
@@ -27,6 +28,7 @@ const allNavigation: NavItem[] = [
{ name: "Akten", href: "/cases", icon: FolderOpen }, { name: "Akten", href: "/cases", icon: FolderOpen },
{ name: "Fristen", href: "/fristen", icon: Clock }, { name: "Fristen", href: "/fristen", icon: Clock },
{ name: "Termine", href: "/termine", icon: Calendar }, { name: "Termine", href: "/termine", icon: Calendar },
{ name: "Vorlagen", href: "/vorlagen", icon: FileText },
{ name: "AI Analyse", href: "/ai/extract", icon: Brain, permission: "ai_extraction" }, { name: "AI Analyse", href: "/ai/extract", icon: Brain, permission: "ai_extraction" },
{ name: "Einstellungen", href: "/einstellungen", icon: Settings, permission: "manage_settings" }, { name: "Einstellungen", href: "/einstellungen", icon: Settings, permission: "manage_settings" },
]; ];

View File

@@ -0,0 +1,205 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Bell, Check, CheckCheck, ExternalLink } from "lucide-react";
import { api } from "@/lib/api";
import type { Notification, NotificationListResponse } from "@/lib/types";
function getEntityLink(n: Notification): string | null {
if (!n.entity_type || !n.entity_id) return null;
switch (n.entity_type) {
case "deadline":
return `/fristen/${n.entity_id}`;
case "appointment":
return `/termine/${n.entity_id}`;
case "case":
return `/akten/${n.entity_id}`;
default:
return null;
}
}
function getTypeColor(type: Notification["type"]): string {
switch (type) {
case "deadline_overdue":
return "bg-red-500";
case "deadline_reminder":
return "bg-amber-500";
case "case_update":
return "bg-blue-500";
case "assignment":
return "bg-violet-500";
default:
return "bg-neutral-500";
}
}
function timeAgo(dateStr: string): string {
const now = new Date();
const date = new Date(dateStr);
const diffMs = now.getTime() - date.getTime();
const diffMin = Math.floor(diffMs / 60000);
if (diffMin < 1) return "gerade eben";
if (diffMin < 60) return `vor ${diffMin} Min.`;
const diffHours = Math.floor(diffMin / 60);
if (diffHours < 24) return `vor ${diffHours} Std.`;
const diffDays = Math.floor(diffHours / 24);
if (diffDays === 1) return "gestern";
return `vor ${diffDays} Tagen`;
}
export function NotificationBell() {
const [open, setOpen] = useState(false);
const panelRef = useRef<HTMLDivElement>(null);
const queryClient = useQueryClient();
const { data: unreadData } = useQuery({
queryKey: ["notifications-unread-count"],
queryFn: () =>
api.get<{ unread_count: number }>("/api/notifications/unread-count"),
refetchInterval: 30_000,
});
const { data: notifData } = useQuery({
queryKey: ["notifications"],
queryFn: () =>
api.get<NotificationListResponse>("/api/notifications?limit=20"),
enabled: open,
});
const markRead = useMutation({
mutationFn: (id: string) =>
api.patch(`/api/notifications/${id}/read`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["notifications"] });
queryClient.invalidateQueries({
queryKey: ["notifications-unread-count"],
});
},
});
const markAllRead = useMutation({
mutationFn: () => api.patch("/api/notifications/read-all"),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["notifications"] });
queryClient.invalidateQueries({
queryKey: ["notifications-unread-count"],
});
},
});
// Close on click outside
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (panelRef.current && !panelRef.current.contains(e.target as Node)) {
setOpen(false);
}
}
if (open) {
document.addEventListener("mousedown", handleClickOutside);
}
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [open]);
const unreadCount = unreadData?.unread_count ?? 0;
const notifications = notifData?.data ?? [];
return (
<div className="relative" ref={panelRef}>
<button
onClick={() => setOpen(!open)}
className="relative rounded-md p-1.5 text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-neutral-600"
title="Benachrichtigungen"
>
<Bell className="h-4 w-4" />
{unreadCount > 0 && (
<span className="absolute -right-0.5 -top-0.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-red-500 px-1 text-[10px] font-bold text-white">
{unreadCount > 99 ? "99+" : unreadCount}
</span>
)}
</button>
{open && (
<div className="absolute right-0 top-full z-50 mt-2 w-80 rounded-xl border border-neutral-200 bg-white shadow-lg sm:w-96">
{/* Header */}
<div className="flex items-center justify-between border-b border-neutral-100 px-4 py-3">
<h3 className="text-sm font-semibold text-neutral-900">
Benachrichtigungen
</h3>
{unreadCount > 0 && (
<button
onClick={() => markAllRead.mutate()}
className="flex items-center gap-1 text-xs text-neutral-500 hover:text-neutral-700"
>
<CheckCheck className="h-3 w-3" />
Alle gelesen
</button>
)}
</div>
{/* Notification list */}
<div className="max-h-96 overflow-y-auto">
{notifications.length === 0 ? (
<div className="p-6 text-center text-sm text-neutral-400">
Keine Benachrichtigungen
</div>
) : (
notifications.map((n) => {
const link = getEntityLink(n);
return (
<div
key={n.id}
className={`flex items-start gap-3 border-b border-neutral-50 px-4 py-3 transition-colors last:border-0 ${
n.read_at
? "bg-white"
: "bg-blue-50/50"
}`}
>
<div
className={`mt-1.5 h-2 w-2 flex-shrink-0 rounded-full ${getTypeColor(n.type)}`}
/>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-neutral-900 leading-snug">
{n.title}
</p>
{n.body && (
<p className="mt-0.5 text-xs text-neutral-500 line-clamp-2">
{n.body}
</p>
)}
<div className="mt-1.5 flex items-center gap-2">
<span className="text-[11px] text-neutral-400">
{timeAgo(n.created_at)}
</span>
{link && (
<a
href={link}
onClick={() => setOpen(false)}
className="flex items-center gap-0.5 text-[11px] text-blue-600 hover:text-blue-700"
>
<ExternalLink className="h-2.5 w-2.5" />
Anzeigen
</a>
)}
</div>
</div>
{!n.read_at && (
<button
onClick={() => markRead.mutate(n.id)}
className="flex-shrink-0 rounded p-1 text-neutral-400 hover:bg-neutral-100 hover:text-neutral-600"
title="Als gelesen markieren"
>
<Check className="h-3 w-3" />
</button>
)}
</div>
);
})
)}
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,167 @@
"use client";
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { api } from "@/lib/api";
import type { NotificationPreferences } from "@/lib/types";
const REMINDER_OPTIONS = [
{ value: 14, label: "14 Tage" },
{ value: 7, label: "7 Tage" },
{ value: 3, label: "3 Tage" },
{ value: 1, label: "1 Tag" },
];
export function NotificationSettings() {
const queryClient = useQueryClient();
const [saved, setSaved] = useState(false);
const { data: prefs, isLoading } = useQuery({
queryKey: ["notification-preferences"],
queryFn: () =>
api.get<NotificationPreferences>("/api/notification-preferences"),
});
const [reminderDays, setReminderDays] = useState<number[]>([]);
const [emailEnabled, setEmailEnabled] = useState(true);
const [dailyDigest, setDailyDigest] = useState(false);
const [initialized, setInitialized] = useState(false);
// Sync state from server once loaded
if (prefs && !initialized) {
setReminderDays(prefs.deadline_reminder_days);
setEmailEnabled(prefs.email_enabled);
setDailyDigest(prefs.daily_digest);
setInitialized(true);
}
const update = useMutation({
mutationFn: (input: {
deadline_reminder_days: number[];
email_enabled: boolean;
daily_digest: boolean;
}) => api.put<NotificationPreferences>("/api/notification-preferences", input),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ["notification-preferences"],
});
setSaved(true);
setTimeout(() => setSaved(false), 2000);
},
});
function toggleDay(day: number) {
setReminderDays((prev) =>
prev.includes(day) ? prev.filter((d) => d !== day) : [...prev, day].sort((a, b) => b - a),
);
}
function handleSave() {
update.mutate({
deadline_reminder_days: reminderDays,
email_enabled: emailEnabled,
daily_digest: dailyDigest,
});
}
if (isLoading) {
return (
<div className="animate-pulse space-y-3">
<div className="h-4 w-48 rounded bg-neutral-200" />
<div className="h-8 w-full rounded bg-neutral-100" />
<div className="h-8 w-full rounded bg-neutral-100" />
</div>
);
}
return (
<div className="space-y-5">
{/* Reminder days */}
<div>
<p className="text-sm font-medium text-neutral-700">
Fristen-Erinnerungen
</p>
<p className="mt-0.5 text-xs text-neutral-500">
Erinnern Sie mich vor Fristablauf:
</p>
<div className="mt-2 flex flex-wrap gap-2">
{REMINDER_OPTIONS.map((opt) => (
<button
key={opt.value}
onClick={() => toggleDay(opt.value)}
className={`rounded-lg border px-3 py-1.5 text-sm transition-colors ${
reminderDays.includes(opt.value)
? "border-blue-500 bg-blue-50 text-blue-700"
: "border-neutral-200 bg-white text-neutral-600 hover:border-neutral-300"
}`}
>
{opt.label}
</button>
))}
</div>
</div>
{/* Email toggle */}
<label className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-neutral-700">
E-Mail-Benachrichtigungen
</p>
<p className="text-xs text-neutral-500">
Erinnerungen per E-Mail erhalten
</p>
</div>
<button
onClick={() => setEmailEnabled(!emailEnabled)}
className={`relative h-6 w-11 rounded-full transition-colors ${
emailEnabled ? "bg-blue-500" : "bg-neutral-300"
}`}
>
<span
className={`absolute left-0.5 top-0.5 h-5 w-5 rounded-full bg-white shadow transition-transform ${
emailEnabled ? "translate-x-5" : "translate-x-0"
}`}
/>
</button>
</label>
{/* Daily digest toggle */}
<label className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-neutral-700">
Tagesübersicht
</p>
<p className="text-xs text-neutral-500">
Alle Benachrichtigungen gesammelt um 8:00 Uhr per E-Mail
</p>
</div>
<button
onClick={() => setDailyDigest(!dailyDigest)}
className={`relative h-6 w-11 rounded-full transition-colors ${
dailyDigest ? "bg-blue-500" : "bg-neutral-300"
}`}
>
<span
className={`absolute left-0.5 top-0.5 h-5 w-5 rounded-full bg-white shadow transition-transform ${
dailyDigest ? "translate-x-5" : "translate-x-0"
}`}
/>
</button>
</label>
{/* Save */}
<div className="flex items-center gap-3 pt-2">
<button
onClick={handleSave}
disabled={update.isPending}
className="rounded-md bg-neutral-900 px-4 py-2 text-sm font-medium text-white hover:bg-neutral-800 disabled:opacity-50"
>
{update.isPending ? "Speichern..." : "Speichern"}
</button>
{saved && (
<span className="text-sm text-green-600">Gespeichert</span>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,161 @@
"use client";
import type { DocumentTemplate } from "@/lib/types";
import { TEMPLATE_CATEGORY_LABELS } from "@/lib/types";
import { Loader2, Plus } from "lucide-react";
import { useState, useRef } from "react";
const AVAILABLE_VARIABLES = [
{ group: "Akte", vars: ["case.number", "case.title", "case.court", "case.court_ref"] },
{ group: "Parteien", vars: ["party.claimant.name", "party.defendant.name", "party.claimant.representative", "party.defendant.representative"] },
{ group: "Kanzlei", vars: ["tenant.name", "tenant.address"] },
{ group: "Benutzer", vars: ["user.name", "user.email"] },
{ group: "Datum", vars: ["date.today", "date.today_long"] },
{ group: "Frist", vars: ["deadline.title", "deadline.due_date"] },
];
interface Props {
template?: DocumentTemplate;
onSave: (data: Partial<DocumentTemplate>) => void;
isSaving: boolean;
}
export function TemplateEditor({ template, onSave, isSaving }: Props) {
const [name, setName] = useState(template?.name ?? "");
const [description, setDescription] = useState(template?.description ?? "");
const [category, setCategory] = useState<string>(template?.category ?? "schriftsatz");
const [content, setContent] = useState(template?.content ?? "");
const textareaRef = useRef<HTMLTextAreaElement>(null);
const insertVariable = (variable: string) => {
const el = textareaRef.current;
if (!el) return;
const placeholder = `{{${variable}}}`;
const start = el.selectionStart;
const end = el.selectionEnd;
const newContent =
content.substring(0, start) + placeholder + content.substring(end);
setContent(newContent);
// Restore cursor position after the inserted text
requestAnimationFrame(() => {
el.focus();
el.selectionStart = el.selectionEnd = start + placeholder.length;
});
};
const handleSave = () => {
if (!name.trim()) return;
onSave({
name: name.trim(),
description: description.trim() || undefined,
category: category as DocumentTemplate["category"],
content,
variables: AVAILABLE_VARIABLES.flatMap((g) => g.vars).filter((v) =>
content.includes(`{{${v}}}`),
),
});
};
return (
<div className="space-y-4">
{/* Metadata */}
<div className="grid gap-3 rounded-lg border border-neutral-200 bg-white p-4 sm:grid-cols-2">
<div>
<label className="mb-1 block text-xs font-medium text-neutral-600">
Name
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="z.B. Klageerwiderung"
className="w-full rounded-md border border-neutral-200 px-3 py-2 text-sm focus:border-neutral-400 focus:outline-none"
/>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-neutral-600">
Kategorie
</label>
<select
value={category}
onChange={(e) => setCategory(e.target.value)}
className="w-full rounded-md border border-neutral-200 px-3 py-2 text-sm focus:border-neutral-400 focus:outline-none"
>
{Object.entries(TEMPLATE_CATEGORY_LABELS).map(([key, label]) => (
<option key={key} value={key}>
{label}
</option>
))}
</select>
</div>
<div className="sm:col-span-2">
<label className="mb-1 block text-xs font-medium text-neutral-600">
Beschreibung
</label>
<input
type="text"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Optionale Beschreibung"
className="w-full rounded-md border border-neutral-200 px-3 py-2 text-sm focus:border-neutral-400 focus:outline-none"
/>
</div>
</div>
{/* Variable toolbar */}
<div className="rounded-lg border border-neutral-200 bg-white p-4">
<h3 className="mb-2 text-xs font-medium text-neutral-600">
Variablen einfügen
</h3>
<div className="space-y-2">
{AVAILABLE_VARIABLES.map((group) => (
<div key={group.group} className="flex flex-wrap items-center gap-1.5">
<span className="text-xs font-medium text-neutral-400 w-16 shrink-0">
{group.group}
</span>
{group.vars.map((v) => (
<button
key={v}
onClick={() => insertVariable(v)}
className="flex items-center gap-0.5 rounded bg-neutral-100 px-1.5 py-0.5 text-xs text-neutral-600 transition-colors hover:bg-neutral-200"
>
<Plus className="h-2.5 w-2.5" />
{v}
</button>
))}
</div>
))}
</div>
</div>
{/* Content editor */}
<div className="rounded-lg border border-neutral-200 bg-white p-4">
<label className="mb-2 block text-xs font-medium text-neutral-600">
Inhalt (Markdown)
</label>
<textarea
ref={textareaRef}
value={content}
onChange={(e) => setContent(e.target.value)}
rows={24}
placeholder="# Dokumenttitel&#10;&#10;Schreiben Sie hier den Vorlageninhalt...&#10;&#10;Verwenden Sie {{variablen}} für automatische Befüllung."
className="w-full rounded-md border border-neutral-200 px-3 py-2 font-mono text-sm leading-relaxed focus:border-neutral-400 focus:outline-none"
/>
</div>
{/* Save button */}
<div className="flex justify-end">
<button
onClick={handleSave}
disabled={!name.trim() || isSaving}
className="flex items-center gap-1.5 rounded-md bg-neutral-900 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-neutral-800 disabled:opacity-50"
>
{isSaving && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
{template ? "Speichern" : "Vorlage erstellen"}
</button>
</div>
</div>
);
}

View File

@@ -223,6 +223,82 @@ export const CASE_ASSIGNMENT_ROLE_LABELS: Record<CaseAssignmentRole, string> = {
viewer: "Einsicht", viewer: "Einsicht",
}; };
// Document Templates
export interface DocumentTemplate {
id: string;
tenant_id?: string;
name: string;
description?: string;
category: "schriftsatz" | "vertrag" | "korrespondenz" | "intern";
content: string;
variables: string[];
is_system: boolean;
created_at: string;
updated_at: string;
}
export const TEMPLATE_CATEGORY_LABELS: Record<string, string> = {
schriftsatz: "Schriftsatz",
vertrag: "Vertrag",
korrespondenz: "Korrespondenz",
intern: "Intern",
};
export interface RenderResponse {
content: string;
template_id: string;
name: string;
}
// Notifications
export interface Notification {
id: string;
tenant_id: string;
user_id: string;
type: string;
title: string;
message: string;
body?: string;
entity_type?: string;
entity_id?: string;
read: boolean;
read_at?: string;
created_at: string;
}
export interface NotificationListResponse {
data: Notification[];
notifications: Notification[];
total: number;
unread_count: number;
}
export interface NotificationPreferences {
deadline_reminder_days: number[];
email_enabled: boolean;
daily_digest: boolean;
}
// Audit Log
export interface AuditLogEntry {
id: string;
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;
}
export interface ApiError { export interface ApiError {
error: string; error: string;
status: number; status: number;