Compare commits

..

8 Commits

Author SHA1 Message Date
m
a89ef26ebd feat: UPC deadline determination — event-driven model with proceeding timeline
Full event-driven deadline determination system ported from youpc.org:

Backend:
- DetermineService: walks proceeding event tree, calculates cascading
  dates with holiday adjustment and conditional logic
- GET /api/proceeding-types/{code}/timeline — full event tree structure
- POST /api/deadlines/determine — calculate timeline with conditions
- POST /api/cases/{caseID}/deadlines/batch — batch-create deadlines
- DeadlineRule model: added is_spawn, spawn_label fields
- GetFullTimeline: recursive CTE following cross-type spawn branches
- Conditional deadlines: condition_rule_id toggles alt_duration/rule_code
  (e.g. Reply changes from RoP.029b to RoP.029a when CCR is filed)
- Seed SQL with full UPC event trees (INF, REV, CCR, APM, APP, AMD)

Frontend:
- DeadlineWizard: interactive proceeding timeline with step-by-step flow
  1. Select proceeding type (visual cards)
  2. Enter trigger event date
  3. Toggle conditional branches (CCR, Appeal, Amend)
  4. See full calculated timeline with color-coded urgency
  5. Batch-create all deadlines on a selected case
- Visual timeline tree with party icons, rule codes, duration badges
- Kept existing DeadlineCalculator as "Schnell" quick mode

Also resolved merge conflicts across 6 files (auth, router, handlers)
merging role-based permissions + audit trail features.
2026-03-30 11:33:59 +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
48 changed files with 3604 additions and 259 deletions

View File

@@ -36,7 +36,12 @@ func main() {
calDAVSvc.Start()
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)
if err := http.ListenAndServe(":"+cfg.Port, handler); err != nil {

View File

@@ -9,9 +9,11 @@ import (
type contextKey string
const (
userIDKey contextKey = "user_id"
tenantIDKey contextKey = "tenant_id"
userRoleKey contextKey = "user_role"
userIDKey contextKey = "user_id"
tenantIDKey contextKey = "tenant_id"
ipKey contextKey = "ip_address"
userAgentKey contextKey = "user_agent"
userRoleKey contextKey = "user_role"
)
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
}
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 {
return context.WithValue(ctx, userRoleKey, role)
}

View File

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

View File

@@ -2,13 +2,13 @@ package auth
import (
"context"
"fmt"
"log/slog"
"net/http"
"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.
type TenantLookup interface {
FirstTenantForUser(ctx context.Context, userID uuid.UUID) (*uuid.UUID, error)
@@ -16,7 +16,7 @@ type TenantLookup interface {
}
// 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 {
lookup TenantLookup
}
@@ -29,46 +29,59 @@ func (tr *TenantResolver) Resolve(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
userID, ok := UserFromContext(r.Context())
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
return
}
var tenantID uuid.UUID
ctx := r.Context()
if header := r.Header.Get("X-Tenant-ID"); header != "" {
parsed, err := uuid.Parse(header)
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
}
// Verify user has access and get their role
role, err := tr.lookup.GetUserRole(r.Context(), userID, parsed)
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
}
if role == "" {
http.Error(w, "no access to this tenant", http.StatusForbidden)
http.Error(w, `{"error":"no access to tenant"}`, http.StatusForbidden)
return
}
tenantID = parsed
// Override the role from middleware with the correct one for this tenant
r = r.WithContext(ContextWithUserRole(r.Context(), role))
ctx = ContextWithUserRole(ctx, role)
} 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)
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
}
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
}
tenantID = *first
// Look up 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))
})
}

View File

@@ -20,10 +20,7 @@ func (m *mockTenantLookup) FirstTenantForUser(ctx context.Context, userID uuid.U
}
func (m *mockTenantLookup) GetUserRole(ctx context.Context, userID, tenantID uuid.UUID) (string, error) {
if m.role != "" {
return m.role, m.err
}
return "associate", m.err
return m.role, m.err
}
func TestTenantResolver_FromHeader(t *testing.T) {
@@ -31,12 +28,14 @@ func TestTenantResolver_FromHeader(t *testing.T) {
tr := NewTenantResolver(&mockTenantLookup{role: "partner"})
var gotTenantID uuid.UUID
var gotRole string
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id, ok := TenantFromContext(r.Context())
if !ok {
t.Fatal("tenant ID not in context")
}
gotTenantID = id
gotRole = UserRoleFromContext(r.Context())
w.WriteHeader(http.StatusOK)
})
@@ -53,11 +52,34 @@ func TestTenantResolver_FromHeader(t *testing.T) {
if gotTenantID != tenantID {
t.Errorf("expected tenant %s, got %s", tenantID, gotTenantID)
}
if gotRole != "partner" {
t.Errorf("expected role partner, got %s", gotRole)
}
}
func TestTenantResolver_FromHeader_NoAccess(t *testing.T) {
tenantID := uuid.New()
tr := NewTenantResolver(&mockTenantLookup{role: ""})
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) {
tenantID := uuid.New()
tr := NewTenantResolver(&mockTenantLookup{tenantID: &tenantID})
tr := NewTenantResolver(&mockTenantLookup{tenantID: &tenantID, role: "associate"})
var gotTenantID uuid.UUID
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {

View File

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

View File

@@ -5,18 +5,16 @@ import (
"io"
"net/http"
"github.com/jmoiron/sqlx"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/auth"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/services"
)
type AIHandler struct {
ai *services.AIService
db *sqlx.DB
}
func NewAIHandler(ai *services.AIService, db *sqlx.DB) *AIHandler {
return &AIHandler{ai: ai, db: db}
func NewAIHandler(ai *services.AIService) *AIHandler {
return &AIHandler{ai: ai}
}
// 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")
return
}
if len(text) > maxDescriptionLen {
writeError(w, http.StatusBadRequest, "text exceeds maximum length")
return
}
deadlines, err := h.ai.ExtractDeadlines(r.Context(), pdfData, text)
if err != nil {
writeError(w, http.StatusInternalServerError, "AI extraction failed: "+err.Error())
internalError(w, "AI deadline extraction failed", err)
return
}
@@ -77,9 +79,9 @@ func (h *AIHandler) ExtractDeadlines(w http.ResponseWriter, r *http.Request) {
// SummarizeCase handles POST /api/ai/summarize-case
// Accepts JSON {"case_id": "uuid"}.
func (h *AIHandler) SummarizeCase(w http.ResponseWriter, r *http.Request) {
tenantID, err := resolveTenant(r, h.db)
if err != nil {
handleTenantError(w, err)
tenantID, ok := auth.TenantFromContext(r.Context())
if !ok {
writeError(w, http.StatusForbidden, "missing tenant")
return
}
@@ -104,7 +106,7 @@ func (h *AIHandler) SummarizeCase(w http.ResponseWriter, r *http.Request) {
summary, err := h.ai.SummarizeCase(r.Context(), tenantID, caseID)
if err != nil {
writeError(w, http.StatusInternalServerError, "AI summarization failed: "+err.Error())
internalError(w, "AI case summarization failed", err)
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{}
body := `{"case_id":""}`
@@ -52,9 +52,9 @@ func TestAISummarizeCase_MissingCaseID(t *testing.T) {
h.SummarizeCase(w, r)
// Without auth context, the resolveTenant will fail first
if w.Code != http.StatusUnauthorized {
t.Errorf("expected 401, got %d", w.Code)
// Without tenant context, TenantFromContext returns !ok → 403
if w.Code != http.StatusForbidden {
t.Errorf("expected 403, got %d", w.Code)
}
}
@@ -67,8 +67,8 @@ func TestAISummarizeCase_InvalidJSON(t *testing.T) {
h.SummarizeCase(w, r)
// Without auth context, the resolveTenant will fail first
if w.Code != http.StatusUnauthorized {
t.Errorf("expected 401, got %d", w.Code)
// Without tenant context, TenantFromContext returns !ok → 403
if w.Code != http.StatusForbidden {
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")
return
}
if msg := validateStringLength("title", req.Title, maxTitleLen); msg != "" {
writeError(w, http.StatusBadRequest, msg)
return
}
if req.StartAt.IsZero() {
writeError(w, http.StatusBadRequest, "start_at is required")
return
@@ -188,6 +192,10 @@ func (h *AppointmentHandler) Update(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, "title is required")
return
}
if msg := validateStringLength("title", req.Title, maxTitleLen); msg != "" {
writeError(w, http.StatusBadRequest, msg)
return
}
if req.StartAt.IsZero() {
writeError(w, http.StatusBadRequest, "start_at is required")
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)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
writeError(w, http.StatusBadRequest, "CalDAV not configured for this tenant")
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"))
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{
Status: r.URL.Query().Get("status"),
Type: r.URL.Query().Get("type"),
Search: r.URL.Query().Get("search"),
Search: search,
Limit: limit,
Offset: offset,
}
cases, total, err := h.svc.List(r.Context(), tenantID, filter)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
internalError(w, "failed to list cases", err)
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")
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)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
internalError(w, "failed to create case", err)
return
}
@@ -91,7 +106,7 @@ func (h *CaseHandler) Get(w http.ResponseWriter, r *http.Request) {
detail, err := h.svc.GetByID(r.Context(), tenantID, caseID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
internalError(w, "failed to get case", err)
return
}
if detail == nil {
@@ -121,10 +136,22 @@ func (h *CaseHandler) Update(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, "invalid JSON body")
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)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
internalError(w, "failed to update case", err)
return
}
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)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
internalError(w, "failed to load dashboard", err)
return
}

View File

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

View File

@@ -0,0 +1,127 @@
package handlers
import (
"encoding/json"
"net/http"
"github.com/google/uuid"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/auth"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/services"
)
// DetermineHandlers holds handlers for deadline determination endpoints
type DetermineHandlers struct {
determine *services.DetermineService
deadlines *services.DeadlineService
}
// NewDetermineHandlers creates determine handlers
func NewDetermineHandlers(determine *services.DetermineService, deadlines *services.DeadlineService) *DetermineHandlers {
return &DetermineHandlers{determine: determine, deadlines: deadlines}
}
// GetTimeline handles GET /api/proceeding-types/{code}/timeline
// Returns the full event tree for a proceeding type (no date calculations)
func (h *DetermineHandlers) GetTimeline(w http.ResponseWriter, r *http.Request) {
code := r.PathValue("code")
if code == "" {
writeError(w, http.StatusBadRequest, "proceeding type code required")
return
}
timeline, pt, err := h.determine.GetTimeline(code)
if err != nil {
writeError(w, http.StatusNotFound, "proceeding type not found")
return
}
writeJSON(w, http.StatusOK, map[string]any{
"proceeding_type": pt,
"timeline": timeline,
})
}
// Determine handles POST /api/deadlines/determine
// Calculates the full timeline with cascading dates and conditional logic
func (h *DetermineHandlers) Determine(w http.ResponseWriter, r *http.Request) {
var req services.DetermineRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.ProceedingType == "" || req.TriggerEventDate == "" {
writeError(w, http.StatusBadRequest, "proceeding_type and trigger_event_date are required")
return
}
resp, err := h.determine.Determine(req)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, resp)
}
// BatchCreate handles POST /api/cases/{caseID}/deadlines/batch
// Creates multiple deadlines on a case from determined timeline
func (h *DetermineHandlers) BatchCreate(w http.ResponseWriter, r *http.Request) {
tenantID, ok := auth.TenantFromContext(r.Context())
if !ok {
writeError(w, http.StatusForbidden, "missing tenant")
return
}
caseID, err := parsePathUUID(r, "caseID")
if err != nil {
writeError(w, http.StatusBadRequest, "invalid case ID")
return
}
var req struct {
Deadlines []struct {
Title string `json:"title"`
DueDate string `json:"due_date"`
OriginalDueDate *string `json:"original_due_date,omitempty"`
RuleID *uuid.UUID `json:"rule_id,omitempty"`
RuleCode *string `json:"rule_code,omitempty"`
Notes *string `json:"notes,omitempty"`
} `json:"deadlines"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if len(req.Deadlines) == 0 {
writeError(w, http.StatusBadRequest, "at least one deadline is required")
return
}
var created int
for _, d := range req.Deadlines {
if d.Title == "" || d.DueDate == "" {
continue
}
input := services.CreateDeadlineInput{
CaseID: caseID,
Title: d.Title,
DueDate: d.DueDate,
Source: "determined",
RuleID: d.RuleID,
Notes: d.Notes,
}
_, err := h.deadlines.Create(r.Context(), tenantID, input)
if err != nil {
internalError(w, "failed to create deadline", err)
return
}
created++
}
writeJSON(w, http.StatusCreated, map[string]any{
"created": created,
})
}

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)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
internalError(w, "failed to list documents", err)
return
}
@@ -98,7 +98,7 @@ func (h *DocumentHandler) Upload(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusNotFound, "case not found")
return
}
writeError(w, http.StatusInternalServerError, err.Error())
internalError(w, "failed to upload document", err)
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)
if err != nil {
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
}
writeError(w, http.StatusInternalServerError, err.Error())
internalError(w, "failed to download document", err)
return
}
defer body.Close()
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)
}
@@ -149,7 +149,7 @@ func (h *DocumentHandler) GetMeta(w http.ResponseWriter, r *http.Request) {
doc, err := h.svc.GetByID(r.Context(), tenantID, docID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
internalError(w, "failed to get document metadata", err)
return
}
if doc == nil {

View File

@@ -2,12 +2,12 @@ package handlers
import (
"encoding/json"
"log/slog"
"net/http"
"strings"
"unicode/utf8"
"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) {
@@ -20,62 +20,9 @@ func writeError(w http.ResponseWriter, status int, msg string) {
writeJSON(w, status, map[string]string{"error": msg})
}
// resolveTenant gets the tenant ID for the authenticated user.
// Checks X-Tenant-ID header first, then falls back to user's first tenant.
func resolveTenant(r *http.Request, db *sqlx.DB) (uuid.UUID, error) {
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
}
// internalError logs the real error and returns a generic message to the client.
func internalError(w http.ResponseWriter, msg string, err error) {
slog.Error(msg, "error", err)
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) {
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")
return
}
if msg := validateStringLength("content", input.Content, maxDescriptionLen); msg != "" {
writeError(w, http.StatusBadRequest, msg)
return
}
var createdBy *uuid.UUID
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")
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)
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)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
internalError(w, "failed to list parties", err)
return
}
@@ -67,13 +67,18 @@ func (h *PartyHandler) Create(w http.ResponseWriter, r *http.Request) {
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)
if err != nil {
if err == sql.ErrNoRows {
writeError(w, http.StatusNotFound, "case not found")
return
}
writeError(w, http.StatusInternalServerError, err.Error())
internalError(w, "failed to create party", err)
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)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
internalError(w, "failed to update party", err)
return
}
if updated == nil {

View File

@@ -2,6 +2,7 @@ package handlers
import (
"encoding/json"
"log/slog"
"net/http"
"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)
if err != nil {
jsonError(w, err.Error(), http.StatusInternalServerError)
slog.Error("failed to create tenant", "error", err)
jsonError(w, "internal error", http.StatusInternalServerError)
return
}
@@ -58,10 +60,16 @@ func (h *TenantHandler) ListTenants(w http.ResponseWriter, r *http.Request) {
tenants, err := h.svc.ListForUser(r.Context(), userID)
if err != nil {
jsonError(w, err.Error(), http.StatusInternalServerError)
slog.Error("failed to list tenants", "error", err)
jsonError(w, "internal error", http.StatusInternalServerError)
return
}
// Mask CalDAV passwords in tenant settings
for i := range tenants {
tenants[i].Settings = maskSettingsPassword(tenants[i].Settings)
}
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
role, err := h.svc.GetUserRole(r.Context(), userID, tenantID)
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
}
if role == "" {
@@ -92,7 +101,8 @@ func (h *TenantHandler) GetTenant(w http.ResponseWriter, r *http.Request) {
tenant, err := h.svc.GetByID(r.Context(), tenantID)
if err != nil {
jsonError(w, err.Error(), http.StatusInternalServerError)
slog.Error("failed to get tenant", "error", err)
jsonError(w, "internal error", http.StatusInternalServerError)
return
}
if tenant == nil {
@@ -100,6 +110,9 @@ func (h *TenantHandler) GetTenant(w http.ResponseWriter, r *http.Request) {
return
}
// Mask CalDAV password before returning
tenant.Settings = maskSettingsPassword(tenant.Settings)
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
role, err := h.svc.GetUserRole(r.Context(), userID, tenantID)
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
}
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)
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
}
@@ -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)
role, err := h.svc.GetUserRole(r.Context(), userID, tenantID)
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
}
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 {
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
}
@@ -218,7 +235,8 @@ func (h *TenantHandler) UpdateSettings(w http.ResponseWriter, r *http.Request) {
// Only owners and partners can update settings
role, err := h.svc.GetUserRole(r.Context(), userID, tenantID)
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
}
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)
if err != nil {
jsonError(w, err.Error(), http.StatusInternalServerError)
slog.Error("failed to update settings", "error", err)
jsonError(w, "internal error", http.StatusInternalServerError)
return
}
// Mask CalDAV password before returning
tenant.Settings = maskSettingsPassword(tenant.Settings)
jsonResponse(w, tenant, http.StatusOK)
}
@@ -258,7 +280,8 @@ func (h *TenantHandler) ListMembers(w http.ResponseWriter, r *http.Request) {
// Verify user has access
role, err := h.svc.GetUserRole(r.Context(), userID, tenantID)
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
}
if role == "" {
@@ -268,7 +291,8 @@ func (h *TenantHandler) ListMembers(w http.ResponseWriter, r *http.Request) {
members, err := h.svc.ListMembers(r.Context(), tenantID)
if err != nil {
jsonError(w, err.Error(), http.StatusInternalServerError)
slog.Error("failed to list members", "error", err)
jsonError(w, "internal error", http.StatusInternalServerError)
return
}

View File

@@ -46,7 +46,7 @@ func testServer(t *testing.T) (http.Handler, func()) {
}
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() }
}

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

@@ -26,6 +26,8 @@ type DeadlineRule struct {
AltDurationValue *int `db:"alt_duration_value" json:"alt_duration_value,omitempty"`
AltDurationUnit *string `db:"alt_duration_unit" json:"alt_duration_unit,omitempty"`
AltRuleCode *string `db:"alt_rule_code" json:"alt_rule_code,omitempty"`
IsSpawn bool `db:"is_spawn" json:"is_spawn"`
SpawnLabel *string `db:"spawn_label" json:"spawn_label,omitempty"`
IsActive bool `db:"is_active" json:"is_active"`
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,43 +15,53 @@ import (
"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()
// Services
tenantSvc := services.NewTenantService(db)
caseSvc := services.NewCaseService(db)
partySvc := services.NewPartyService(db)
appointmentSvc := services.NewAppointmentService(db)
auditSvc := services.NewAuditService(db)
tenantSvc := services.NewTenantService(db, auditSvc)
caseSvc := services.NewCaseService(db, auditSvc)
partySvc := services.NewPartyService(db, auditSvc)
appointmentSvc := services.NewAppointmentService(db, auditSvc)
holidaySvc := services.NewHolidayService(db)
deadlineSvc := services.NewDeadlineService(db)
deadlineSvc := services.NewDeadlineService(db, auditSvc)
deadlineRuleSvc := services.NewDeadlineRuleService(db)
calculator := services.NewDeadlineCalculator(holidaySvc)
determineSvc := services.NewDetermineService(db, calculator)
storageCli := services.NewStorageClient(cfg.SupabaseURL, cfg.SupabaseServiceKey)
documentSvc := services.NewDocumentService(db, storageCli)
documentSvc := services.NewDocumentService(db, storageCli, auditSvc)
assignmentSvc := services.NewCaseAssignmentService(db)
// AI service (optional — only if API key is configured)
var aiH *handlers.AIHandler
if cfg.AnthropicAPIKey != "" {
aiSvc := services.NewAIService(cfg.AnthropicAPIKey, db)
aiH = handlers.NewAIHandler(aiSvc, db)
aiH = handlers.NewAIHandler(aiSvc)
}
// Middleware
tenantResolver := auth.NewTenantResolver(tenantSvc)
noteSvc := services.NewNoteService(db)
noteSvc := services.NewNoteService(db, auditSvc)
dashboardSvc := services.NewDashboardService(db)
// Notification handler (optional — nil in tests)
var notifH *handlers.NotificationHandler
if notifSvc != nil {
notifH = handlers.NewNotificationHandler(notifSvc, db)
}
// Handlers
auditH := handlers.NewAuditLogHandler(auditSvc)
tenantH := handlers.NewTenantHandler(tenantSvc)
caseH := handlers.NewCaseHandler(caseSvc)
partyH := handlers.NewPartyHandler(partySvc)
apptH := handlers.NewAppointmentHandler(appointmentSvc)
deadlineH := handlers.NewDeadlineHandlers(deadlineSvc, db)
deadlineH := handlers.NewDeadlineHandlers(deadlineSvc)
ruleH := handlers.NewDeadlineRuleHandlers(deadlineRuleSvc)
calcH := handlers.NewCalculateHandlers(calculator, deadlineRuleSvc)
determineH := handlers.NewDetermineHandlers(determineSvc, deadlineSvc)
dashboardH := handlers.NewDashboardHandler(dashboardSvc)
noteH := handlers.NewNoteHandler(noteSvc)
eventH := handlers.NewCaseEventHandler(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("POST /api/cases", perm(auth.PermCreateCase, caseH.Create))
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))
// Parties — same access as case editing
@@ -124,6 +134,11 @@ func New(db *sqlx.DB, authMW *auth.Middleware, cfg *config.Config, calDAVSvc *se
// Deadline calculator — all can use
scoped.HandleFunc("POST /api/deadlines/calculate", calcH.Calculate)
// Deadline determination — full timeline calculation with conditions
scoped.HandleFunc("GET /api/proceeding-types/{code}/timeline", determineH.GetTimeline)
scoped.HandleFunc("POST /api/deadlines/determine", determineH.Determine)
scoped.HandleFunc("POST /api/cases/{caseID}/deadlines/batch", perm(auth.PermManageDeadlines, determineH.BatchCreate))
// Appointments — all can manage (PermManageAppointments granted to all)
scoped.HandleFunc("GET /api/appointments/{id}", apptH.Get)
scoped.HandleFunc("GET /api/appointments", apptH.List)
@@ -148,12 +163,15 @@ func New(db *sqlx.DB, authMW *auth.Middleware, cfg *config.Config, calDAVSvc *se
// Dashboard — all can view
scoped.HandleFunc("GET /api/dashboard", dashboardH.Get)
// Audit log
scoped.HandleFunc("GET /api/audit-log", auditH.List)
// Documents — all can upload, delete checked in handler (own vs all)
scoped.HandleFunc("GET /api/cases/{id}/documents", docH.ListByCase)
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}/meta", docH.GetMeta)
scoped.HandleFunc("DELETE /api/documents/{docId}", docH.Delete) // permission check inside handler
scoped.HandleFunc("DELETE /api/documents/{docId}", docH.Delete)
// AI endpoints (rate limited: 5 req/min burst 10 per IP)
if aiH != nil {
@@ -162,6 +180,16 @@ 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)))
}
// 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 — settings permission required
if calDAVSvc != nil {
calDAVH := handlers.NewCalDAVHandler(calDAVSvc)
@@ -174,14 +202,20 @@ func New(db *sqlx.DB, authMW *auth.Middleware, cfg *config.Config, calDAVSvc *se
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 {
return func(w http.ResponseWriter, r *http.Request) {
if err := db.Ping(); err != nil {
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
}
w.Header().Set("Content-Type", "application/json")
@@ -219,4 +253,3 @@ func requestLogger(next http.Handler) http.Handler {
)
})
}

View File

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

View File

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

View File

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

View File

@@ -8,6 +8,12 @@ import (
"mgit.msbls.de/m/KanzlAI-mGMT/internal/models"
)
const ruleColumns = `id, proceeding_type_id, parent_id, code, name, description,
primary_party, event_type, is_mandatory, duration_value, duration_unit,
timing, rule_code, deadline_notes, sequence_order, condition_rule_id,
alt_duration_value, alt_duration_unit, alt_rule_code,
is_spawn, spawn_label, is_active, created_at, updated_at`
// DeadlineRuleService handles deadline rule queries
type DeadlineRuleService struct {
db *sqlx.DB
@@ -25,21 +31,13 @@ func (s *DeadlineRuleService) List(proceedingTypeID *int) ([]models.DeadlineRule
if proceedingTypeID != nil {
err = s.db.Select(&rules,
`SELECT id, proceeding_type_id, parent_id, code, name, description,
primary_party, event_type, is_mandatory, duration_value, duration_unit,
timing, rule_code, deadline_notes, sequence_order, condition_rule_id,
alt_duration_value, alt_duration_unit, alt_rule_code, is_active,
created_at, updated_at
`SELECT `+ruleColumns+`
FROM deadline_rules
WHERE proceeding_type_id = $1 AND is_active = true
ORDER BY sequence_order`, *proceedingTypeID)
} else {
err = s.db.Select(&rules,
`SELECT id, proceeding_type_id, parent_id, code, name, description,
primary_party, event_type, is_mandatory, duration_value, duration_unit,
timing, rule_code, deadline_notes, sequence_order, condition_rule_id,
alt_duration_value, alt_duration_unit, alt_rule_code, is_active,
created_at, updated_at
`SELECT `+ruleColumns+`
FROM deadline_rules
WHERE is_active = true
ORDER BY proceeding_type_id, sequence_order`)
@@ -72,11 +70,7 @@ func (s *DeadlineRuleService) GetRuleTree(proceedingTypeCode string) ([]RuleTree
// Get all rules for this proceeding type
var rules []models.DeadlineRule
err = s.db.Select(&rules,
`SELECT id, proceeding_type_id, parent_id, code, name, description,
primary_party, event_type, is_mandatory, duration_value, duration_unit,
timing, rule_code, deadline_notes, sequence_order, condition_rule_id,
alt_duration_value, alt_duration_unit, alt_rule_code, is_active,
created_at, updated_at
`SELECT `+ruleColumns+`
FROM deadline_rules
WHERE proceeding_type_id = $1 AND is_active = true
ORDER BY sequence_order`, pt.ID)
@@ -87,6 +81,36 @@ func (s *DeadlineRuleService) GetRuleTree(proceedingTypeCode string) ([]RuleTree
return buildTree(rules), nil
}
// GetFullTimeline returns the full event tree for a proceeding type using a recursive CTE.
// Unlike GetRuleTree, this follows parent_id across proceeding types (includes cross-type spawns).
func (s *DeadlineRuleService) GetFullTimeline(proceedingTypeCode string) ([]models.DeadlineRule, *models.ProceedingType, error) {
var pt models.ProceedingType
err := s.db.Get(&pt,
`SELECT id, code, name, description, jurisdiction, default_color, sort_order, is_active
FROM proceeding_types
WHERE code = $1 AND is_active = true`, proceedingTypeCode)
if err != nil {
return nil, nil, fmt.Errorf("resolving proceeding type %q: %w", proceedingTypeCode, err)
}
var rules []models.DeadlineRule
err = s.db.Select(&rules, `
WITH RECURSIVE tree AS (
SELECT * FROM deadline_rules
WHERE proceeding_type_id = $1 AND parent_id IS NULL AND is_active = true
UNION ALL
SELECT dr.* FROM deadline_rules dr
JOIN tree t ON dr.parent_id = t.id
WHERE dr.is_active = true
)
SELECT `+ruleColumns+` FROM tree ORDER BY sequence_order`, pt.ID)
if err != nil {
return nil, nil, fmt.Errorf("fetching timeline for type %q: %w", proceedingTypeCode, err)
}
return rules, &pt, nil
}
// GetByIDs returns deadline rules by their IDs
func (s *DeadlineRuleService) GetByIDs(ids []string) ([]models.DeadlineRule, error) {
if len(ids) == 0 {
@@ -94,11 +118,7 @@ func (s *DeadlineRuleService) GetByIDs(ids []string) ([]models.DeadlineRule, err
}
query, args, err := sqlx.In(
`SELECT id, proceeding_type_id, parent_id, code, name, description,
primary_party, event_type, is_mandatory, duration_value, duration_unit,
timing, rule_code, deadline_notes, sequence_order, condition_rule_id,
alt_duration_value, alt_duration_unit, alt_rule_code, is_active,
created_at, updated_at
`SELECT `+ruleColumns+`
FROM deadline_rules
WHERE id IN (?) AND is_active = true
ORDER BY sequence_order`, ids)
@@ -119,11 +139,7 @@ func (s *DeadlineRuleService) GetByIDs(ids []string) ([]models.DeadlineRule, err
func (s *DeadlineRuleService) GetRulesForProceedingType(proceedingTypeID int) ([]models.DeadlineRule, error) {
var rules []models.DeadlineRule
err := s.db.Select(&rules,
`SELECT id, proceeding_type_id, parent_id, code, name, description,
primary_party, event_type, is_mandatory, duration_value, duration_unit,
timing, rule_code, deadline_notes, sequence_order, condition_rule_id,
alt_duration_value, alt_duration_unit, alt_rule_code, is_active,
created_at, updated_at
`SELECT `+ruleColumns+`
FROM deadline_rules
WHERE proceeding_type_id = $1 AND is_active = true
ORDER BY sequence_order`, proceedingTypeID)

View File

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

View File

@@ -0,0 +1,236 @@
package services
import (
"fmt"
"time"
"github.com/jmoiron/sqlx"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/models"
)
// DetermineService handles event-driven deadline determination.
// It walks the proceeding event tree and calculates cascading dates.
type DetermineService struct {
rules *DeadlineRuleService
calculator *DeadlineCalculator
}
// NewDetermineService creates a new determine service
func NewDetermineService(db *sqlx.DB, calculator *DeadlineCalculator) *DetermineService {
return &DetermineService{
rules: NewDeadlineRuleService(db),
calculator: calculator,
}
}
// TimelineEvent represents a calculated event in the proceeding timeline
type TimelineEvent struct {
ID string `json:"id"`
Code string `json:"code,omitempty"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
PrimaryParty string `json:"primary_party,omitempty"`
EventType string `json:"event_type,omitempty"`
IsMandatory bool `json:"is_mandatory"`
DurationValue int `json:"duration_value"`
DurationUnit string `json:"duration_unit"`
RuleCode string `json:"rule_code,omitempty"`
DeadlineNotes string `json:"deadline_notes,omitempty"`
IsSpawn bool `json:"is_spawn"`
SpawnLabel string `json:"spawn_label,omitempty"`
HasCondition bool `json:"has_condition"`
ConditionRuleID string `json:"condition_rule_id,omitempty"`
AltRuleCode string `json:"alt_rule_code,omitempty"`
AltDurationValue *int `json:"alt_duration_value,omitempty"`
AltDurationUnit string `json:"alt_duration_unit,omitempty"`
Date string `json:"date,omitempty"`
OriginalDate string `json:"original_date,omitempty"`
WasAdjusted bool `json:"was_adjusted"`
Children []TimelineEvent `json:"children,omitempty"`
}
// DetermineRequest is the input for POST /api/deadlines/determine
type DetermineRequest struct {
ProceedingType string `json:"proceeding_type"`
TriggerEventDate string `json:"trigger_event_date"`
Conditions map[string]bool `json:"conditions"`
}
// DetermineResponse is the output of the determine endpoint
type DetermineResponse struct {
ProceedingType string `json:"proceeding_type"`
ProceedingName string `json:"proceeding_name"`
ProceedingColor string `json:"proceeding_color"`
TriggerDate string `json:"trigger_event_date"`
Timeline []TimelineEvent `json:"timeline"`
TotalDeadlines int `json:"total_deadlines"`
}
// GetTimeline returns the proceeding event tree (without date calculations)
func (s *DetermineService) GetTimeline(proceedingTypeCode string) ([]TimelineEvent, *models.ProceedingType, error) {
rules, pt, err := s.rules.GetFullTimeline(proceedingTypeCode)
if err != nil {
return nil, nil, err
}
tree := buildTimelineTree(rules)
return tree, pt, nil
}
// Determine calculates the full timeline with cascading dates
func (s *DetermineService) Determine(req DetermineRequest) (*DetermineResponse, error) {
timeline, pt, err := s.GetTimeline(req.ProceedingType)
if err != nil {
return nil, fmt.Errorf("loading timeline: %w", err)
}
triggerDate, err := time.Parse("2006-01-02", req.TriggerEventDate)
if err != nil {
return nil, fmt.Errorf("invalid trigger_event_date: %w", err)
}
conditions := req.Conditions
if conditions == nil {
conditions = make(map[string]bool)
}
total := s.calculateDates(timeline, triggerDate, conditions)
return &DetermineResponse{
ProceedingType: pt.Code,
ProceedingName: pt.Name,
ProceedingColor: pt.DefaultColor,
TriggerDate: req.TriggerEventDate,
Timeline: timeline,
TotalDeadlines: total,
}, nil
}
// calculateDates walks the tree and calculates dates for each node
func (s *DetermineService) calculateDates(events []TimelineEvent, parentDate time.Time, conditions map[string]bool) int {
total := 0
for i := range events {
ev := &events[i]
// Skip inactive spawns: if this is a spawn node and conditions don't include it, skip
if ev.IsSpawn && !conditions[ev.ID] {
continue
}
durationValue := ev.DurationValue
durationUnit := ev.DurationUnit
ruleCode := ev.RuleCode
// Apply conditional logic
if ev.HasCondition && ev.ConditionRuleID != "" {
if conditions[ev.ConditionRuleID] {
if ev.AltDurationValue != nil {
durationValue = *ev.AltDurationValue
}
if ev.AltDurationUnit != "" {
durationUnit = ev.AltDurationUnit
}
if ev.AltRuleCode != "" {
ruleCode = ev.AltRuleCode
}
}
}
// Calculate this node's date
if durationValue > 0 {
rule := models.DeadlineRule{
DurationValue: durationValue,
DurationUnit: durationUnit,
}
adjusted, original, wasAdjusted := s.calculator.CalculateEndDate(parentDate, rule)
ev.Date = adjusted.Format("2006-01-02")
ev.OriginalDate = original.Format("2006-01-02")
ev.WasAdjusted = wasAdjusted
} else {
ev.Date = parentDate.Format("2006-01-02")
ev.OriginalDate = parentDate.Format("2006-01-02")
}
ev.RuleCode = ruleCode
total++
// Recurse: children's dates cascade from this node's date
if len(ev.Children) > 0 {
childDate, _ := time.Parse("2006-01-02", ev.Date)
total += s.calculateDates(ev.Children, childDate, conditions)
}
}
return total
}
// buildTimelineTree converts flat rules to a tree of TimelineEvents
func buildTimelineTree(rules []models.DeadlineRule) []TimelineEvent {
nodeMap := make(map[string]*TimelineEvent, len(rules))
var roots []TimelineEvent
// Create event nodes
for _, r := range rules {
ev := ruleToEvent(r)
nodeMap[r.ID.String()] = &ev
}
// Build tree by parent_id
for _, r := range rules {
ev := nodeMap[r.ID.String()]
if r.ParentID != nil {
parentKey := r.ParentID.String()
if parent, ok := nodeMap[parentKey]; ok {
parent.Children = append(parent.Children, *ev)
continue
}
}
roots = append(roots, *ev)
}
return roots
}
func ruleToEvent(r models.DeadlineRule) TimelineEvent {
ev := TimelineEvent{
ID: r.ID.String(),
Name: r.Name,
IsMandatory: r.IsMandatory,
DurationValue: r.DurationValue,
DurationUnit: r.DurationUnit,
IsSpawn: r.IsSpawn,
HasCondition: r.ConditionRuleID != nil,
}
if r.Code != nil {
ev.Code = *r.Code
}
if r.Description != nil {
ev.Description = *r.Description
}
if r.PrimaryParty != nil {
ev.PrimaryParty = *r.PrimaryParty
}
if r.EventType != nil {
ev.EventType = *r.EventType
}
if r.RuleCode != nil {
ev.RuleCode = *r.RuleCode
}
if r.DeadlineNotes != nil {
ev.DeadlineNotes = *r.DeadlineNotes
}
if r.SpawnLabel != nil {
ev.SpawnLabel = *r.SpawnLabel
}
if r.ConditionRuleID != nil {
ev.ConditionRuleID = r.ConditionRuleID.String()
}
if r.AltRuleCode != nil {
ev.AltRuleCode = *r.AltRuleCode
}
ev.AltDurationValue = r.AltDurationValue
if r.AltDurationUnit != nil {
ev.AltDurationUnit = *r.AltDurationUnit
}
return ev
}

View File

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

View File

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

View File

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

View File

@@ -13,11 +13,12 @@ import (
)
type TenantService struct {
db *sqlx.DB
db *sqlx.DB
audit *AuditService
}
func NewTenantService(db *sqlx.DB) *TenantService {
return &TenantService{db: db}
func NewTenantService(db *sqlx.DB, audit *AuditService) *TenantService {
return &TenantService{db: db, audit: audit}
}
// Create creates a new tenant and assigns the creator as owner.
@@ -49,6 +50,7 @@ func (s *TenantService) Create(ctx context.Context, userID uuid.UUID, name, slug
return nil, fmt.Errorf("commit: %w", err)
}
s.audit.Log(ctx, "create", "tenant", &tenant.ID, nil, tenant)
return &tenant, nil
}
@@ -101,6 +103,19 @@ func (s *TenantService) GetUserRole(ctx context.Context, userID, tenantID uuid.U
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.
func (s *TenantService) FirstTenantForUser(ctx context.Context, userID uuid.UUID) (*uuid.UUID, error) {
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)
}
s.audit.Log(ctx, "create", "membership", &tenantID, nil, ut)
return &ut, nil
}
@@ -186,6 +202,7 @@ func (s *TenantService) UpdateSettings(ctx context.Context, tenantID uuid.UUID,
if err != nil {
return nil, fmt.Errorf("update settings: %w", err)
}
s.audit.Log(ctx, "update", "settings", &tenantID, nil, settings)
return &tenant, nil
}
@@ -257,5 +274,6 @@ func (s *TenantService) RemoveMember(ctx context.Context, tenantID, userID uuid.
return fmt.Errorf("remove member: %w", err)
}
s.audit.Log(ctx, "delete", "membership", &tenantID, map[string]any{"user_id": userID, "role": role}, nil)
return nil
}

View File

@@ -0,0 +1,466 @@
-- UPC Proceeding Timeline: Full event tree with conditional deadlines
-- Ported from youpc.org migrations 039 + 040
-- Run against kanzlai schema in flexsiebels Supabase instance
-- ========================================
-- 1. Add is_spawn + spawn_label columns
-- ========================================
ALTER TABLE deadline_rules
ADD COLUMN IF NOT EXISTS is_spawn BOOLEAN DEFAULT false,
ADD COLUMN IF NOT EXISTS spawn_label TEXT;
-- ========================================
-- 2. Clear existing UPC rules (fresh seed)
-- ========================================
DELETE FROM deadline_rules WHERE proceeding_type_id IN (
SELECT id FROM proceeding_types WHERE code IN ('INF', 'REV', 'CCR', 'APM', 'APP', 'AMD')
);
-- ========================================
-- 3. Ensure all proceeding types exist
-- ========================================
INSERT INTO proceeding_types (code, name, description, is_active, sort_order, default_color)
VALUES
('INF', 'Infringement', 'Patent infringement proceedings', true, 1, '#3b82f6'),
('REV', 'Revocation', 'Standalone revocation proceedings', true, 2, '#ef4444'),
('CCR', 'Counterclaim for Revocation', 'Counterclaim for revocation within infringement', true, 3, '#ef4444'),
('APM', 'Provisional Measures', 'Application for preliminary injunction', true, 4, '#f59e0b'),
('APP', 'Appeal', 'Appeal to the Court of Appeal', true, 5, '#8b5cf6'),
('AMD', 'Application to Amend Patent', 'Sub-proceeding for patent amendment during revocation', true, 6, '#10b981')
ON CONFLICT (code) DO UPDATE SET
name = EXCLUDED.name,
description = EXCLUDED.description,
default_color = EXCLUDED.default_color,
sort_order = EXCLUDED.sort_order,
is_active = EXCLUDED.is_active;
-- ========================================
-- 4. Seed all proceeding events
-- ========================================
DO $$
DECLARE
v_inf INTEGER;
v_rev INTEGER;
v_ccr INTEGER;
v_apm INTEGER;
v_app INTEGER;
v_amd INTEGER;
-- INF event IDs
v_inf_soc UUID;
v_inf_sod UUID;
v_inf_reply UUID;
v_inf_rejoin UUID;
v_inf_interim UUID;
v_inf_oral UUID;
v_inf_decision UUID;
v_inf_prelim UUID;
-- CCR event IDs
v_ccr_root UUID;
v_ccr_defence UUID;
v_ccr_reply UUID;
v_ccr_rejoin UUID;
v_ccr_interim UUID;
v_ccr_oral UUID;
v_ccr_decision UUID;
-- REV event IDs
v_rev_app UUID;
v_rev_defence UUID;
v_rev_reply UUID;
v_rev_rejoin UUID;
v_rev_interim UUID;
v_rev_oral UUID;
v_rev_decision UUID;
-- PI event IDs
v_pi_app UUID;
v_pi_resp UUID;
v_pi_oral UUID;
-- APP event IDs
v_app_notice UUID;
v_app_grounds UUID;
v_app_response UUID;
v_app_oral UUID;
BEGIN
SELECT id INTO v_inf FROM proceeding_types WHERE code = 'INF';
SELECT id INTO v_rev FROM proceeding_types WHERE code = 'REV';
SELECT id INTO v_ccr FROM proceeding_types WHERE code = 'CCR';
SELECT id INTO v_apm FROM proceeding_types WHERE code = 'APM';
SELECT id INTO v_app FROM proceeding_types WHERE code = 'APP';
SELECT id INTO v_amd FROM proceeding_types WHERE code = 'AMD';
-- ========================================
-- INFRINGEMENT PROCEEDINGS
-- ========================================
-- Root: Statement of Claim
v_inf_soc := gen_random_uuid();
INSERT INTO deadline_rules (id, proceeding_type_id, parent_id, code, name, description,
primary_party, event_type, is_mandatory, duration_value, duration_unit,
rule_code, deadline_notes, is_spawn, spawn_label, sequence_order, is_active)
VALUES (v_inf_soc, v_inf, NULL, 'inf.soc', 'Statement of Claim',
'Claimant files the statement of claim with the Registry',
'claimant', 'filing', true, 0, 'months', NULL, NULL, false, NULL, 0, true);
-- Preliminary Objection (from SoC)
v_inf_prelim := gen_random_uuid();
INSERT INTO deadline_rules (id, proceeding_type_id, parent_id, code, name, description,
primary_party, event_type, is_mandatory, duration_value, duration_unit,
rule_code, deadline_notes, is_spawn, spawn_label, sequence_order, is_active)
VALUES (v_inf_prelim, v_inf, v_inf_soc, 'inf.prelim', 'Preliminary Objection',
'Defendant raises preliminary objection (jurisdiction, admissibility)',
'defendant', 'filing', false, 1, 'months', 'R.19',
'Rarely triggers separate decision; usually decided with main case',
false, NULL, 1, true);
-- Statement of Defence (from SoC)
v_inf_sod := gen_random_uuid();
INSERT INTO deadline_rules (id, proceeding_type_id, parent_id, code, name, description,
primary_party, event_type, is_mandatory, duration_value, duration_unit,
rule_code, deadline_notes, is_spawn, spawn_label, sequence_order, is_active)
VALUES (v_inf_sod, v_inf, v_inf_soc, 'inf.sod', 'Statement of Defence',
'Defendant files the statement of defence',
'defendant', 'filing', true, 3, 'months', 'RoP.023', NULL,
false, NULL, 2, true);
-- Reply to Defence (from SoD) — CONDITIONAL: rule code changes if CCR
v_inf_reply := gen_random_uuid();
INSERT INTO deadline_rules (id, proceeding_type_id, parent_id, code, name, description,
primary_party, event_type, is_mandatory, duration_value, duration_unit,
rule_code, deadline_notes, is_spawn, spawn_label, sequence_order, is_active)
VALUES (v_inf_reply, v_inf, v_inf_sod, 'inf.reply', 'Reply to Defence',
'Claimant''s reply to the statement of defence (includes Defence to Counterclaim if CCR active)',
'claimant', 'filing', true, 2, 'months', 'RoP.029b', NULL,
false, NULL, 1, true);
-- Rejoinder (from Reply) — CONDITIONAL: duration changes if CCR
v_inf_rejoin := gen_random_uuid();
INSERT INTO deadline_rules (id, proceeding_type_id, parent_id, code, name, description,
primary_party, event_type, is_mandatory, duration_value, duration_unit,
rule_code, deadline_notes, is_spawn, spawn_label, sequence_order, is_active)
VALUES (v_inf_rejoin, v_inf, v_inf_reply, 'inf.rejoin', 'Rejoinder',
'Defendant''s rejoinder to the reply',
'defendant', 'filing', true, 1, 'months', 'RoP.029c', NULL,
false, NULL, 0, true);
-- Interim Conference
v_inf_interim := gen_random_uuid();
INSERT INTO deadline_rules (id, proceeding_type_id, parent_id, code, name, description,
primary_party, event_type, is_mandatory, duration_value, duration_unit,
rule_code, deadline_notes, is_spawn, spawn_label, sequence_order, is_active)
VALUES (v_inf_interim, v_inf, v_inf_rejoin, 'inf.interim', 'Interim Conference',
'Interim conference with the judge-rapporteur',
'court', 'hearing', true, 0, 'months', NULL, NULL, false, NULL, 0, true);
-- Oral Hearing
v_inf_oral := gen_random_uuid();
INSERT INTO deadline_rules (id, proceeding_type_id, parent_id, code, name, description,
primary_party, event_type, is_mandatory, duration_value, duration_unit,
rule_code, deadline_notes, is_spawn, spawn_label, sequence_order, is_active)
VALUES (v_inf_oral, v_inf, v_inf_interim, 'inf.oral', 'Oral Hearing',
'Oral hearing before the panel',
'court', 'hearing', true, 0, 'months', NULL, NULL, false, NULL, 0, true);
-- Decision
v_inf_decision := gen_random_uuid();
INSERT INTO deadline_rules (id, proceeding_type_id, parent_id, code, name, description,
primary_party, event_type, is_mandatory, duration_value, duration_unit,
rule_code, deadline_notes, is_spawn, spawn_label, sequence_order, is_active)
VALUES (v_inf_decision, v_inf, v_inf_oral, 'inf.decision', 'Decision',
'Panel delivers its decision',
'court', 'decision', true, 0, 'months', NULL, NULL, false, NULL, 0, true);
-- Appeal (spawn from Decision — cross-type to APP)
INSERT INTO deadline_rules (id, proceeding_type_id, parent_id, code, name, description,
primary_party, event_type, is_mandatory, duration_value, duration_unit,
rule_code, deadline_notes, is_spawn, spawn_label, sequence_order, is_active)
VALUES (gen_random_uuid(), v_app, v_inf_decision, 'inf.appeal', 'Appeal',
'Appeal against infringement decision to Court of Appeal',
'both', 'filing', true, 2, 'months', 'RoP.220.1', NULL,
true, 'Appeal filed', 0, true);
-- ========================================
-- COUNTERCLAIM FOR REVOCATION (spawn from SoD)
-- ========================================
v_ccr_root := gen_random_uuid();
INSERT INTO deadline_rules (id, proceeding_type_id, parent_id, code, name, description,
primary_party, event_type, is_mandatory, duration_value, duration_unit,
rule_code, deadline_notes, is_spawn, spawn_label, sequence_order, is_active)
VALUES (v_ccr_root, v_ccr, v_inf_sod, 'ccr.counterclaim', 'Counterclaim for Revocation',
'Defendant files counterclaim challenging patent validity (included in SoD)',
'defendant', 'filing', true, 0, 'months', NULL, NULL,
true, 'Includes counterclaim for revocation', 0, true);
-- Defence to Counterclaim
v_ccr_defence := gen_random_uuid();
INSERT INTO deadline_rules (id, proceeding_type_id, parent_id, code, name, description,
primary_party, event_type, is_mandatory, duration_value, duration_unit,
rule_code, deadline_notes, is_spawn, spawn_label, sequence_order, is_active)
VALUES (v_ccr_defence, v_ccr, v_ccr_root, 'ccr.defence', 'Defence to Counterclaim',
'Patent proprietor files defence to revocation counterclaim',
'claimant', 'filing', true, 3, 'months', 'RoP.050', NULL,
false, NULL, 0, true);
-- Reply in CCR
v_ccr_reply := gen_random_uuid();
INSERT INTO deadline_rules (id, proceeding_type_id, parent_id, code, name, description,
primary_party, event_type, is_mandatory, duration_value, duration_unit,
rule_code, deadline_notes, is_spawn, spawn_label, sequence_order, is_active)
VALUES (v_ccr_reply, v_ccr, v_ccr_defence, 'ccr.reply', 'Reply in CCR',
'Reply in the counterclaim for revocation',
'defendant', 'filing', true, 2, 'months', NULL,
'Timing overlaps with infringement Rejoinder',
false, NULL, 1, true);
-- Rejoinder in CCR
v_ccr_rejoin := gen_random_uuid();
INSERT INTO deadline_rules (id, proceeding_type_id, parent_id, code, name, description,
primary_party, event_type, is_mandatory, duration_value, duration_unit,
rule_code, deadline_notes, is_spawn, spawn_label, sequence_order, is_active)
VALUES (v_ccr_rejoin, v_ccr, v_ccr_reply, 'ccr.rejoin', 'Rejoinder in CCR',
'Rejoinder in the counterclaim for revocation',
'claimant', 'filing', true, 2, 'months', NULL, NULL,
false, NULL, 0, true);
-- Interim Conference
v_ccr_interim := gen_random_uuid();
INSERT INTO deadline_rules (id, proceeding_type_id, parent_id, code, name, description,
primary_party, event_type, is_mandatory, duration_value, duration_unit,
rule_code, deadline_notes, is_spawn, spawn_label, sequence_order, is_active)
VALUES (v_ccr_interim, v_ccr, v_ccr_rejoin, 'ccr.interim', 'Interim Conference',
'Interim conference covering revocation issues',
'court', 'hearing', true, 0, 'months', NULL,
'May be combined with infringement IC',
false, NULL, 0, true);
-- Oral Hearing
v_ccr_oral := gen_random_uuid();
INSERT INTO deadline_rules (id, proceeding_type_id, parent_id, code, name, description,
primary_party, event_type, is_mandatory, duration_value, duration_unit,
rule_code, deadline_notes, is_spawn, spawn_label, sequence_order, is_active)
VALUES (v_ccr_oral, v_ccr, v_ccr_interim, 'ccr.oral', 'Oral Hearing',
'Oral hearing on validity',
'court', 'hearing', true, 0, 'months', NULL, NULL,
false, NULL, 0, true);
-- Decision
v_ccr_decision := gen_random_uuid();
INSERT INTO deadline_rules (id, proceeding_type_id, parent_id, code, name, description,
primary_party, event_type, is_mandatory, duration_value, duration_unit,
rule_code, deadline_notes, is_spawn, spawn_label, sequence_order, is_active)
VALUES (v_ccr_decision, v_ccr, v_ccr_oral, 'ccr.decision', 'Decision',
'Decision on validity of the patent',
'court', 'decision', true, 0, 'months', NULL, NULL,
false, NULL, 0, true);
-- Appeal from CCR
INSERT INTO deadline_rules (id, proceeding_type_id, parent_id, code, name, description,
primary_party, event_type, is_mandatory, duration_value, duration_unit,
rule_code, deadline_notes, is_spawn, spawn_label, sequence_order, is_active)
VALUES (gen_random_uuid(), v_app, v_ccr_decision, 'ccr.appeal', 'Appeal',
'Appeal against revocation decision to Court of Appeal',
'both', 'filing', true, 2, 'months', 'RoP.220.1', NULL,
true, 'Appeal filed', 0, true);
-- Application to Amend Patent (spawn from Defence to CCR)
INSERT INTO deadline_rules (id, proceeding_type_id, parent_id, code, name, description,
primary_party, event_type, is_mandatory, duration_value, duration_unit,
rule_code, deadline_notes, is_spawn, spawn_label, sequence_order, is_active)
VALUES (gen_random_uuid(), v_amd, v_ccr_defence, 'ccr.amend', 'Application to Amend Patent',
'Patent proprietor applies to amend the patent during revocation proceedings',
'claimant', 'filing', false, 0, 'months', NULL, NULL,
true, 'Includes application to amend patent', 2, true);
-- ========================================
-- STANDALONE REVOCATION
-- ========================================
v_rev_app := gen_random_uuid();
INSERT INTO deadline_rules (id, proceeding_type_id, parent_id, code, name, description,
primary_party, event_type, is_mandatory, duration_value, duration_unit,
rule_code, deadline_notes, is_spawn, spawn_label, sequence_order, is_active)
VALUES (v_rev_app, v_rev, NULL, 'rev.app', 'Application for Revocation',
'Applicant files standalone application for revocation of the patent',
'claimant', 'filing', true, 0, 'months', NULL, NULL,
false, NULL, 0, true);
v_rev_defence := gen_random_uuid();
INSERT INTO deadline_rules (id, proceeding_type_id, parent_id, code, name, description,
primary_party, event_type, is_mandatory, duration_value, duration_unit,
rule_code, deadline_notes, is_spawn, spawn_label, sequence_order, is_active)
VALUES (v_rev_defence, v_rev, v_rev_app, 'rev.defence', 'Defence to Revocation',
'Patent proprietor files defence to revocation application',
'defendant', 'filing', true, 3, 'months', NULL, NULL,
false, NULL, 0, true);
v_rev_reply := gen_random_uuid();
INSERT INTO deadline_rules (id, proceeding_type_id, parent_id, code, name, description,
primary_party, event_type, is_mandatory, duration_value, duration_unit,
rule_code, deadline_notes, is_spawn, spawn_label, sequence_order, is_active)
VALUES (v_rev_reply, v_rev, v_rev_defence, 'rev.reply', 'Reply',
'Reply in standalone revocation proceedings',
'claimant', 'filing', true, 2, 'months', NULL, NULL,
false, NULL, 1, true);
v_rev_rejoin := gen_random_uuid();
INSERT INTO deadline_rules (id, proceeding_type_id, parent_id, code, name, description,
primary_party, event_type, is_mandatory, duration_value, duration_unit,
rule_code, deadline_notes, is_spawn, spawn_label, sequence_order, is_active)
VALUES (v_rev_rejoin, v_rev, v_rev_reply, 'rev.rejoin', 'Rejoinder',
'Rejoinder in standalone revocation proceedings',
'defendant', 'filing', true, 2, 'months', NULL, NULL,
false, NULL, 0, true);
v_rev_interim := gen_random_uuid();
INSERT INTO deadline_rules (id, proceeding_type_id, parent_id, code, name, description,
primary_party, event_type, is_mandatory, duration_value, duration_unit,
rule_code, deadline_notes, is_spawn, spawn_label, sequence_order, is_active)
VALUES (v_rev_interim, v_rev, v_rev_rejoin, 'rev.interim', 'Interim Conference',
'Interim conference with the judge-rapporteur',
'court', 'hearing', true, 0, 'months', NULL, NULL,
false, NULL, 0, true);
v_rev_oral := gen_random_uuid();
INSERT INTO deadline_rules (id, proceeding_type_id, parent_id, code, name, description,
primary_party, event_type, is_mandatory, duration_value, duration_unit,
rule_code, deadline_notes, is_spawn, spawn_label, sequence_order, is_active)
VALUES (v_rev_oral, v_rev, v_rev_interim, 'rev.oral', 'Oral Hearing',
'Oral hearing on validity in standalone revocation',
'court', 'hearing', true, 0, 'months', NULL, NULL,
false, NULL, 0, true);
v_rev_decision := gen_random_uuid();
INSERT INTO deadline_rules (id, proceeding_type_id, parent_id, code, name, description,
primary_party, event_type, is_mandatory, duration_value, duration_unit,
rule_code, deadline_notes, is_spawn, spawn_label, sequence_order, is_active)
VALUES (v_rev_decision, v_rev, v_rev_oral, 'rev.decision', 'Decision',
'Decision on patent validity',
'court', 'decision', true, 0, 'months', NULL, NULL,
false, NULL, 0, true);
-- Appeal from REV
INSERT INTO deadline_rules (id, proceeding_type_id, parent_id, code, name, description,
primary_party, event_type, is_mandatory, duration_value, duration_unit,
rule_code, deadline_notes, is_spawn, spawn_label, sequence_order, is_active)
VALUES (gen_random_uuid(), v_app, v_rev_decision, 'rev.appeal', 'Appeal',
'Appeal against revocation decision to Court of Appeal',
'both', 'filing', true, 2, 'months', 'RoP.220.1', NULL,
true, 'Appeal filed', 0, true);
-- Application to Amend Patent from REV Defence
INSERT INTO deadline_rules (id, proceeding_type_id, parent_id, code, name, description,
primary_party, event_type, is_mandatory, duration_value, duration_unit,
rule_code, deadline_notes, is_spawn, spawn_label, sequence_order, is_active)
VALUES (gen_random_uuid(), v_amd, v_rev_defence, 'rev.amend', 'Application to Amend Patent',
'Patent proprietor applies to amend the patent',
'claimant', 'filing', false, 0, 'months', NULL, NULL,
true, 'Includes application to amend patent', 2, true);
-- ========================================
-- PRELIMINARY INJUNCTION
-- ========================================
v_pi_app := gen_random_uuid();
INSERT INTO deadline_rules (id, proceeding_type_id, parent_id, code, name, description,
primary_party, event_type, is_mandatory, duration_value, duration_unit,
rule_code, deadline_notes, is_spawn, spawn_label, sequence_order, is_active)
VALUES (v_pi_app, v_apm, NULL, 'pi.app', 'Application for Provisional Measures',
'Claimant files application for preliminary injunction',
'claimant', 'filing', true, 0, 'months', NULL, NULL,
false, NULL, 0, true);
v_pi_resp := gen_random_uuid();
INSERT INTO deadline_rules (id, proceeding_type_id, parent_id, code, name, description,
primary_party, event_type, is_mandatory, duration_value, duration_unit,
rule_code, deadline_notes, is_spawn, spawn_label, sequence_order, is_active)
VALUES (v_pi_resp, v_apm, v_pi_app, 'pi.response', 'Response to PI Application',
'Defendant files response to preliminary injunction application',
'defendant', 'filing', true, 0, 'months', NULL,
'Deadline set by court',
false, NULL, 0, true);
v_pi_oral := gen_random_uuid();
INSERT INTO deadline_rules (id, proceeding_type_id, parent_id, code, name, description,
primary_party, event_type, is_mandatory, duration_value, duration_unit,
rule_code, deadline_notes, is_spawn, spawn_label, sequence_order, is_active)
VALUES (v_pi_oral, v_apm, v_pi_resp, 'pi.oral', 'Oral Hearing',
'Oral hearing on provisional measures',
'court', 'hearing', true, 0, 'months', NULL, NULL,
false, NULL, 0, true);
INSERT INTO deadline_rules (id, proceeding_type_id, parent_id, code, name, description,
primary_party, event_type, is_mandatory, duration_value, duration_unit,
rule_code, deadline_notes, is_spawn, spawn_label, sequence_order, is_active)
VALUES (gen_random_uuid(), v_apm, v_pi_oral, 'pi.order', 'Order on Provisional Measures',
'Court issues order on preliminary injunction',
'court', 'decision', true, 0, 'months', NULL, NULL,
false, NULL, 0, true);
-- ========================================
-- APPEAL (standalone)
-- ========================================
v_app_notice := gen_random_uuid();
INSERT INTO deadline_rules (id, proceeding_type_id, parent_id, code, name, description,
primary_party, event_type, is_mandatory, duration_value, duration_unit,
rule_code, deadline_notes, is_spawn, spawn_label, sequence_order, is_active)
VALUES (v_app_notice, v_app, NULL, 'app.notice', 'Notice of Appeal',
'Appellant files notice of appeal with the Court of Appeal',
'both', 'filing', true, 0, 'months', NULL, NULL,
false, NULL, 0, true);
v_app_grounds := gen_random_uuid();
INSERT INTO deadline_rules (id, proceeding_type_id, parent_id, code, name, description,
primary_party, event_type, is_mandatory, duration_value, duration_unit,
rule_code, deadline_notes, is_spawn, spawn_label, sequence_order, is_active)
VALUES (v_app_grounds, v_app, v_app_notice, 'app.grounds', 'Statement of Grounds of Appeal',
'Appellant files statement of grounds',
'both', 'filing', true, 2, 'months', 'RoP.220.1', NULL,
false, NULL, 0, true);
v_app_response := gen_random_uuid();
INSERT INTO deadline_rules (id, proceeding_type_id, parent_id, code, name, description,
primary_party, event_type, is_mandatory, duration_value, duration_unit,
rule_code, deadline_notes, is_spawn, spawn_label, sequence_order, is_active)
VALUES (v_app_response, v_app, v_app_grounds, 'app.response', 'Response to Appeal',
'Respondent files response to the appeal',
'both', 'filing', true, 2, 'months', NULL, NULL,
false, NULL, 0, true);
v_app_oral := gen_random_uuid();
INSERT INTO deadline_rules (id, proceeding_type_id, parent_id, code, name, description,
primary_party, event_type, is_mandatory, duration_value, duration_unit,
rule_code, deadline_notes, is_spawn, spawn_label, sequence_order, is_active)
VALUES (v_app_oral, v_app, v_app_response, 'app.oral', 'Oral Hearing',
'Oral hearing before the Court of Appeal',
'court', 'hearing', true, 0, 'months', NULL, NULL,
false, NULL, 0, true);
INSERT INTO deadline_rules (id, proceeding_type_id, parent_id, code, name, description,
primary_party, event_type, is_mandatory, duration_value, duration_unit,
rule_code, deadline_notes, is_spawn, spawn_label, sequence_order, is_active)
VALUES (gen_random_uuid(), v_app, v_app_oral, 'app.decision', 'Decision',
'Court of Appeal delivers its decision',
'court', 'decision', true, 0, 'months', NULL, NULL,
false, NULL, 0, true);
-- ========================================
-- 5. Set conditional deadlines (from 040)
-- ========================================
-- Reply to Defence: rule code changes when CCR is active
-- Default: RoP.029b | With CCR: RoP.029a
UPDATE deadline_rules
SET condition_rule_id = v_ccr_root,
alt_rule_code = 'RoP.029a'
WHERE id = v_inf_reply;
-- Rejoinder: duration changes when CCR is active
-- Default: 1 month RoP.029c | With CCR: 2 months RoP.029d
UPDATE deadline_rules
SET condition_rule_id = v_ccr_root,
alt_duration_value = 2,
alt_duration_unit = 'months',
alt_rule_code = 'RoP.029d'
WHERE id = v_inf_rejoin;
END $$;

View File

@@ -16,6 +16,7 @@ import {
UserCheck,
StickyNote,
AlertTriangle,
ScrollText,
} from "lucide-react";
import { format } from "date-fns";
import { de } from "date-fns/locale";
@@ -46,6 +47,7 @@ const TABS = [
{ segment: "parteien", label: "Parteien", icon: Users },
{ segment: "mitarbeiter", label: "Mitarbeiter", icon: UserCheck },
{ segment: "notizen", label: "Notizen", icon: StickyNote },
{ segment: "protokoll", label: "Protokoll", icon: ScrollText },
] as const;
const TAB_LABELS: Record<string, string> = {
@@ -55,6 +57,7 @@ const TAB_LABELS: Record<string, string> = {
parteien: "Parteien",
mitarbeiter: "Mitarbeiter",
notizen: "Notizen",
protokoll: "Protokoll",
};
function CaseDetailSkeleton() {

View File

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

View File

@@ -1,11 +1,12 @@
"use client";
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 { api } from "@/lib/api";
import type { Tenant } from "@/lib/types";
import { CalDAVSettings } from "@/components/settings/CalDAVSettings";
import { NotificationSettings } from "@/components/settings/NotificationSettings";
import { SkeletonCard } from "@/components/ui/Skeleton";
import { EmptyState } from "@/components/ui/EmptyState";
@@ -97,6 +98,19 @@ export default function EinstellungenPage() {
</div>
</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 */}
<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">

View File

@@ -1,28 +1,61 @@
"use client";
import { DeadlineCalculator } from "@/components/deadlines/DeadlineCalculator";
import { DeadlineWizard } from "@/components/deadlines/DeadlineWizard";
import { ArrowLeft } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
export default function FristenrechnerPage() {
const [mode, setMode] = useState<"wizard" | "quick">("wizard");
return (
<div className="animate-fade-in space-y-4">
<div>
<Link
href="/fristen"
className="mb-2 inline-flex items-center gap-1 text-sm text-neutral-500 transition-colors hover:text-neutral-700"
>
<ArrowLeft className="h-3.5 w-3.5" />
Zurück zu Fristen
</Link>
<h1 className="text-lg font-semibold text-neutral-900">
Fristenrechner
</h1>
<p className="mt-0.5 text-sm text-neutral-500">
Berechnen Sie Fristen basierend auf Verfahrensart und Auslösedatum
</p>
<div className="flex items-start justify-between">
<div>
<Link
href="/fristen"
className="mb-2 inline-flex items-center gap-1 text-sm text-neutral-500 transition-colors hover:text-neutral-700"
>
<ArrowLeft className="h-3.5 w-3.5" />
Zurueck zu Fristen
</Link>
<h1 className="text-lg font-semibold text-neutral-900">
Fristenbestimmung
</h1>
<p className="mt-0.5 text-sm text-neutral-500">
{mode === "wizard"
? "Vollstaendige Verfahrens-Timeline mit automatischer Fristenberechnung"
: "Schnellberechnung einzelner Fristen nach Verfahrensart"}
</p>
</div>
{/* Mode toggle */}
<div className="flex rounded-md border border-neutral-200 bg-neutral-50 p-0.5">
<button
onClick={() => setMode("wizard")}
className={`rounded px-3 py-1 text-xs font-medium transition-colors ${
mode === "wizard"
? "bg-white text-neutral-900 shadow-sm"
: "text-neutral-500 hover:text-neutral-700"
}`}
>
Verfahren
</button>
<button
onClick={() => setMode("quick")}
className={`rounded px-3 py-1 text-xs font-medium transition-colors ${
mode === "quick"
? "bg-white text-neutral-900 shadow-sm"
: "text-neutral-500 hover:text-neutral-700"
}`}
>
Schnell
</button>
</div>
</div>
<DeadlineCalculator />
{mode === "wizard" ? <DeadlineWizard /> : <DeadlineCalculator />}
</div>
);
}

View File

@@ -0,0 +1,622 @@
"use client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { api } from "@/lib/api";
import type {
ProceedingType,
TimelineResponse,
DetermineResponse,
TimelineEvent,
Case,
} from "@/lib/types";
import { format, parseISO, isPast, isThisWeek, isBefore, addDays } from "date-fns";
import { de } from "date-fns/locale";
import {
Scale,
Calendar,
ChevronRight,
ChevronDown,
GitBranch,
Check,
Clock,
AlertTriangle,
FileText,
Users,
Gavel,
ArrowRight,
RotateCcw,
Loader2,
CheckCircle2,
FolderOpen,
} from "lucide-react";
import { useState, useCallback, useMemo } from "react";
import { toast } from "sonner";
// --- Helpers ---
function formatDuration(value: number, unit: string): string {
if (value === 0) return "";
const labels: Record<string, string> = {
days: value === 1 ? "Tag" : "Tage",
weeks: value === 1 ? "Woche" : "Wochen",
months: value === 1 ? "Monat" : "Monate",
};
return `${value} ${labels[unit] || unit}`;
}
function getPartyIcon(party?: string) {
switch (party) {
case "claimant":
return <Scale className="h-3.5 w-3.5" />;
case "defendant":
return <Users className="h-3.5 w-3.5" />;
case "court":
return <Gavel className="h-3.5 w-3.5" />;
default:
return <FileText className="h-3.5 w-3.5" />;
}
}
function getPartyLabel(party?: string): string {
switch (party) {
case "claimant":
return "Klaeger";
case "defendant":
return "Beklagter";
case "court":
return "Gericht";
case "both":
return "Beide Parteien";
default:
return "";
}
}
function getEventTypeLabel(type?: string): string {
switch (type) {
case "filing":
return "Einreichung";
case "hearing":
return "Verhandlung";
case "decision":
return "Entscheidung";
default:
return "";
}
}
type Urgency = "past" | "overdue" | "this_week" | "upcoming" | "future" | "none";
function getUrgency(dateStr?: string): Urgency {
if (!dateStr) return "none";
const date = parseISO(dateStr);
const today = new Date();
today.setHours(0, 0, 0, 0);
if (isPast(date) && isBefore(date, today)) return "overdue";
if (isThisWeek(date, { weekStartsOn: 1 })) return "this_week";
if (isBefore(date, addDays(today, 30))) return "upcoming";
return "future";
}
const urgencyStyles: Record<Urgency, { dot: string; text: string; bg: string }> = {
past: { dot: "bg-neutral-400", text: "text-neutral-500", bg: "bg-neutral-50" },
overdue: { dot: "bg-red-500", text: "text-red-700", bg: "bg-red-50" },
this_week: { dot: "bg-amber-500", text: "text-amber-700", bg: "bg-amber-50" },
upcoming: { dot: "bg-blue-500", text: "text-blue-700", bg: "bg-blue-50" },
future: { dot: "bg-green-500", text: "text-green-700", bg: "bg-green-50" },
none: { dot: "bg-neutral-300", text: "text-neutral-500", bg: "bg-neutral-50" },
};
// --- Spawn Extraction ---
function extractSpawns(events: TimelineEvent[]): TimelineEvent[] {
const spawns: TimelineEvent[] = [];
function walk(evts: TimelineEvent[]) {
for (const ev of evts) {
if (ev.is_spawn) spawns.push(ev);
if (ev.children) walk(ev.children);
}
}
walk(events);
return spawns;
}
// --- Flat timeline extraction ---
function flattenTimeline(events: TimelineEvent[], depth = 0): (TimelineEvent & { depth: number })[] {
const result: (TimelineEvent & { depth: number })[] = [];
for (const ev of events) {
result.push({ ...ev, depth });
if (ev.children && ev.children.length > 0) {
result.push(...flattenTimeline(ev.children, depth + 1));
}
}
return result;
}
// --- Main Component ---
export function DeadlineWizard() {
const [selectedType, setSelectedType] = useState<string>("");
const [triggerDate, setTriggerDate] = useState("");
const [conditions, setConditions] = useState<Record<string, boolean>>({});
const [selectedCaseId, setSelectedCaseId] = useState<string>("");
const [showBatchPanel, setShowBatchPanel] = useState(false);
const queryClient = useQueryClient();
// Fetch proceeding types
const { data: proceedingTypes, isLoading: typesLoading } = useQuery({
queryKey: ["proceeding-types"],
queryFn: () => api.get<ProceedingType[]>("/proceeding-types"),
});
// Fetch timeline structure when type is selected
const { data: timelineData } = useQuery({
queryKey: ["timeline", selectedType],
queryFn: () => api.get<TimelineResponse>(`/proceeding-types/${selectedType}/timeline`),
enabled: !!selectedType,
});
// Determine mutation
const determineMutation = useMutation({
mutationFn: (params: { proceeding_type: string; trigger_event_date: string; conditions: Record<string, boolean> }) =>
api.post<DetermineResponse>("/deadlines/determine", params),
});
// Cases for batch create
const { data: cases } = useQuery({
queryKey: ["cases"],
queryFn: () => api.get<Case[]>("/cases"),
enabled: showBatchPanel,
});
// Batch create mutation
const batchMutation = useMutation({
mutationFn: (params: { caseId: string; deadlines: { title: string; due_date: string; rule_code?: string }[] }) =>
api.post(`/cases/${params.caseId}/deadlines/batch`, { deadlines: params.deadlines }),
onSuccess: () => {
toast.success("Alle Fristen wurden auf die Akte uebernommen");
queryClient.invalidateQueries({ queryKey: ["deadlines"] });
setShowBatchPanel(false);
},
onError: () => {
toast.error("Fehler beim Erstellen der Fristen");
},
});
// Spawns from timeline structure (for condition toggles)
const spawns = useMemo(() => {
if (!timelineData?.timeline) return [];
return extractSpawns(timelineData.timeline);
}, [timelineData]);
// Calculate on type/date/condition change
const calculate = useCallback(() => {
if (!selectedType || !triggerDate) return;
determineMutation.mutate({
proceeding_type: selectedType,
trigger_event_date: triggerDate,
conditions,
});
}, [selectedType, triggerDate, conditions, determineMutation]);
// Auto-calculate when date or conditions change
const handleDateChange = (date: string) => {
setTriggerDate(date);
if (selectedType && date) {
determineMutation.mutate({
proceeding_type: selectedType,
trigger_event_date: date,
conditions,
});
}
};
const handleConditionToggle = (spawnId: string) => {
const next = { ...conditions, [spawnId]: !conditions[spawnId] };
setConditions(next);
if (selectedType && triggerDate) {
determineMutation.mutate({
proceeding_type: selectedType,
trigger_event_date: triggerDate,
conditions: next,
});
}
};
const handleTypeSelect = (code: string) => {
setSelectedType(code);
setConditions({});
if (triggerDate) {
// Will recalculate once timeline loads
determineMutation.reset();
}
};
const handleReset = () => {
setSelectedType("");
setTriggerDate("");
setConditions({});
setShowBatchPanel(false);
determineMutation.reset();
};
// Collect calculated deadlines for batch create
const collectDeadlines = (events: TimelineEvent[]): { title: string; due_date: string; rule_code?: string }[] => {
const result: { title: string; due_date: string; rule_code?: string }[] = [];
for (const ev of events) {
if (ev.date && ev.duration_value > 0) {
result.push({ title: ev.name, due_date: ev.date, rule_code: ev.rule_code || undefined });
}
if (ev.children) result.push(...collectDeadlines(ev.children));
}
return result;
};
const results = determineMutation.data;
const selectedPT = proceedingTypes?.find((pt) => pt.code === selectedType);
return (
<div className="space-y-5">
{/* Step 1: Proceeding Type Selection */}
<div className="rounded-lg border border-neutral-200 bg-white p-5">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-sm font-medium text-neutral-900">
<Scale className="h-4 w-4" />
Verfahrensart waehlen
</div>
{selectedType && (
<button
onClick={handleReset}
className="flex items-center gap-1 text-xs text-neutral-500 hover:text-neutral-700"
>
<RotateCcw className="h-3 w-3" />
Zuruecksetzen
</button>
)}
</div>
<div className="mt-4 grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-6">
{typesLoading ? (
<div className="col-span-full flex justify-center py-4">
<Loader2 className="h-5 w-5 animate-spin text-neutral-400" />
</div>
) : (
proceedingTypes?.map((pt) => (
<button
key={pt.id}
onClick={() => handleTypeSelect(pt.code)}
className={`rounded-lg border px-3 py-2.5 text-left transition-all ${
selectedType === pt.code
? "border-neutral-900 bg-neutral-900 text-white shadow-sm"
: "border-neutral-200 bg-white text-neutral-700 hover:border-neutral-400 hover:bg-neutral-50"
}`}
>
<div className="flex items-center gap-1.5">
<div
className="h-2 w-2 rounded-full"
style={{ backgroundColor: pt.default_color }}
/>
<span className="text-xs font-semibold">{pt.code}</span>
</div>
<div className="mt-1 text-xs leading-tight opacity-80">{pt.name}</div>
</button>
))
)}
</div>
</div>
{/* Step 2: Date + Conditions */}
{selectedType && (
<div className="animate-fade-in rounded-lg border border-neutral-200 bg-white p-5">
<div className="flex items-center gap-2 text-sm font-medium text-neutral-900">
<Calendar className="h-4 w-4" />
Ausloesendes Ereignis
</div>
<div className="mt-4 flex flex-col gap-4 sm:flex-row sm:items-end">
<div className="flex-1">
<label className="mb-1 block text-xs font-medium text-neutral-500">
Datum des {selectedPT?.name || selectedType} (z.B. Klagezustellung)
</label>
<input
type="date"
value={triggerDate}
onChange={(e) => handleDateChange(e.target.value)}
className="w-full rounded-md border border-neutral-200 bg-white px-3 py-2 text-sm text-neutral-900 outline-none transition-colors focus:border-neutral-400 focus:ring-1 focus:ring-neutral-400"
/>
</div>
{/* Condition toggles */}
{spawns.length > 0 && (
<div className="flex flex-wrap gap-2">
{spawns.map((spawn) => (
<button
key={spawn.id}
onClick={() => handleConditionToggle(spawn.id)}
className={`flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-medium transition-all ${
conditions[spawn.id]
? "bg-neutral-900 text-white"
: "border border-neutral-300 bg-white text-neutral-600 hover:bg-neutral-50"
}`}
>
<GitBranch className="h-3 w-3" />
{spawn.spawn_label || spawn.name}
{conditions[spawn.id] && <Check className="h-3 w-3" />}
</button>
))}
</div>
)}
</div>
</div>
)}
{/* Error */}
{determineMutation.isError && (
<div className="flex items-center gap-2 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
<AlertTriangle className="h-4 w-4 shrink-0" />
Fehler bei der Berechnung. Bitte Eingaben pruefen.
</div>
)}
{/* Step 3: Calculated Timeline */}
{results && results.timeline && (
<div className="animate-fade-in space-y-3">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h3 className="text-sm font-medium text-neutral-900">
Verfahrens-Timeline: {results.proceeding_name}
</h3>
<p className="mt-0.5 text-xs text-neutral-500">
{results.total_deadlines} Ereignisse ab{" "}
{format(parseISO(results.trigger_event_date), "dd. MMMM yyyy", { locale: de })}
</p>
</div>
<button
onClick={() => setShowBatchPanel(!showBatchPanel)}
className="flex items-center gap-1.5 rounded-md bg-neutral-900 px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-neutral-800"
>
<CheckCircle2 className="h-3.5 w-3.5" />
Alle uebernehmen
</button>
</div>
{/* Timeline visualization */}
<div className="rounded-lg border border-neutral-200 bg-white">
<TimelineTree events={results.timeline} conditions={conditions} depth={0} />
</div>
{/* Batch create panel */}
{showBatchPanel && (
<div className="animate-fade-in rounded-lg border border-neutral-200 bg-neutral-50 p-4">
<div className="flex items-center gap-2 text-sm font-medium text-neutral-900">
<FolderOpen className="h-4 w-4" />
Fristen auf Akte uebernehmen
</div>
<div className="mt-3 flex gap-3">
<select
value={selectedCaseId}
onChange={(e) => setSelectedCaseId(e.target.value)}
className="flex-1 rounded-md border border-neutral-200 bg-white px-3 py-2 text-sm text-neutral-900 outline-none focus:border-neutral-400"
>
<option value="">Akte waehlen...</option>
{cases
?.filter((c) => c.status !== "closed")
.map((c) => (
<option key={c.id} value={c.id}>
{c.case_number} {c.title}
</option>
))}
</select>
<button
disabled={!selectedCaseId || batchMutation.isPending}
onClick={() => {
const deadlines = collectDeadlines(results.timeline);
if (deadlines.length === 0) return;
batchMutation.mutate({ caseId: selectedCaseId, deadlines });
}}
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:cursor-not-allowed disabled:opacity-50"
>
{batchMutation.isPending ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<ArrowRight className="h-3.5 w-3.5" />
)}
{batchMutation.isPending ? "Erstelle..." : `${collectDeadlines(results.timeline).length} Fristen erstellen`}
</button>
</div>
</div>
)}
</div>
)}
{/* Empty state */}
{!results && !determineMutation.isPending && selectedType && triggerDate && (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-5 w-5 animate-spin text-neutral-400" />
</div>
)}
{!selectedType && (
<div className="flex flex-col items-center rounded-lg border border-dashed border-neutral-300 bg-white px-6 py-12 text-center">
<div className="rounded-xl bg-neutral-100 p-3">
<Scale className="h-6 w-6 text-neutral-400" />
</div>
<p className="mt-3 text-sm font-medium text-neutral-700">
UPC-Fristenbestimmung
</p>
<p className="mt-1 max-w-sm text-xs text-neutral-500">
Waehlen Sie die Verfahrensart und geben Sie das Datum des ausloesenden Ereignisses ein.
Alle Fristen des Verfahrens werden automatisch berechnet.
</p>
</div>
)}
</div>
);
}
// --- Timeline Tree Component ---
function TimelineTree({
events,
conditions,
depth,
}: {
events: TimelineEvent[];
conditions: Record<string, boolean>;
depth: number;
}) {
return (
<>
{events.map((ev, i) => (
<TimelineNode
key={ev.id}
event={ev}
conditions={conditions}
depth={depth}
isLast={i === events.length - 1}
/>
))}
</>
);
}
function TimelineNode({
event: ev,
conditions,
depth,
isLast,
}: {
event: TimelineEvent;
conditions: Record<string, boolean>;
depth: number;
isLast: boolean;
}) {
const [expanded, setExpanded] = useState(true);
// Skip inactive spawns
if (ev.is_spawn && !conditions[ev.id]) return null;
const hasChildren = ev.children && ev.children.length > 0;
const visibleChildren = ev.children?.filter(
(c) => !c.is_spawn || conditions[c.id]
);
const hasVisibleChildren = visibleChildren && visibleChildren.length > 0;
const urgency = getUrgency(ev.date);
const styles = urgencyStyles[urgency];
const duration = formatDuration(ev.duration_value, ev.duration_unit);
const isConditional = ev.has_condition && ev.condition_rule_id;
return (
<>
<div
className={`group relative flex gap-3 px-4 py-3 transition-colors hover:bg-neutral-50 ${
!isLast && depth === 0 ? "border-b border-neutral-100" : ""
}`}
style={{ paddingLeft: `${16 + depth * 24}px` }}
>
{/* Timeline connector */}
<div className="flex flex-col items-center pt-1">
<div className={`h-3 w-3 shrink-0 rounded-full border-2 border-white shadow-sm ${styles.dot}`} />
{!isLast && <div className="mt-1 w-px flex-1 bg-neutral-200" />}
</div>
{/* Content */}
<div className="min-w-0 flex-1">
<div className="flex flex-col gap-1 sm:flex-row sm:items-start sm:justify-between sm:gap-2">
<div className="flex items-center gap-1.5">
{hasVisibleChildren && (
<button
onClick={() => setExpanded(!expanded)}
className="text-neutral-400 hover:text-neutral-600"
>
{expanded ? (
<ChevronDown className="h-3.5 w-3.5" />
) : (
<ChevronRight className="h-3.5 w-3.5" />
)}
</button>
)}
{ev.is_spawn && (
<GitBranch className="h-3.5 w-3.5 text-violet-500" />
)}
<span className="text-sm font-medium text-neutral-900">{ev.name}</span>
{!ev.is_mandatory && (
<span className="rounded bg-neutral-100 px-1 py-0.5 text-[10px] text-neutral-500">
optional
</span>
)}
</div>
{/* Date */}
{ev.date && (
<div className="flex items-center gap-1.5 shrink-0">
{ev.was_adjusted && (
<span className="text-[10px] text-amber-600" title={`Original: ${ev.original_date}`}>
angepasst
</span>
)}
<span className={`text-sm font-medium tabular-nums ${styles.text}`}>
{format(parseISO(ev.date), "dd.MM.yyyy")}
</span>
</div>
)}
</div>
{/* Meta row */}
<div className="mt-0.5 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-xs text-neutral-500">
{ev.primary_party && (
<span className="flex items-center gap-0.5">
{getPartyIcon(ev.primary_party)}
{getPartyLabel(ev.primary_party)}
</span>
)}
{ev.event_type && (
<>
<span className="text-neutral-300">·</span>
<span>{getEventTypeLabel(ev.event_type)}</span>
</>
)}
{duration && (
<>
<span className="text-neutral-300">·</span>
<span className="flex items-center gap-0.5">
<Clock className="h-3 w-3" />
{duration}
</span>
</>
)}
{ev.rule_code && (
<>
<span className="text-neutral-300">·</span>
<span className="rounded bg-neutral-100 px-1 py-0.5 font-mono text-[10px]">
{ev.rule_code}
</span>
</>
)}
{isConditional && (
<>
<span className="text-neutral-300">·</span>
<span className="text-violet-600">
bedingt{ev.alt_rule_code ? ` (${ev.alt_rule_code})` : ""}
</span>
</>
)}
</div>
{/* Notes */}
{ev.deadline_notes && (
<p className="mt-1 text-xs text-neutral-400 italic">{ev.deadline_notes}</p>
)}
</div>
</div>
{/* Children */}
{expanded && hasVisibleChildren && (
<TimelineTree events={visibleChildren!} conditions={conditions} depth={depth + 1} />
)}
</>
);
}

View File

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

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

@@ -120,12 +120,76 @@ export interface DeadlineRule {
rule_code?: string;
deadline_notes?: string;
sequence_order: number;
condition_rule_id?: string;
alt_duration_value?: number;
alt_duration_unit?: string;
alt_rule_code?: string;
is_spawn?: boolean;
spawn_label?: string;
}
export interface RuleTreeNode extends DeadlineRule {
children?: RuleTreeNode[];
}
// Timeline determination types
export interface TimelineEvent {
id: string;
code?: string;
name: string;
description?: string;
primary_party?: string;
event_type?: string;
is_mandatory: boolean;
duration_value: number;
duration_unit: string;
rule_code?: string;
deadline_notes?: string;
is_spawn: boolean;
spawn_label?: string;
has_condition: boolean;
condition_rule_id?: string;
alt_rule_code?: string;
alt_duration_value?: number;
alt_duration_unit?: string;
date?: string;
original_date?: string;
was_adjusted: boolean;
children?: TimelineEvent[];
}
export interface TimelineResponse {
proceeding_type: ProceedingType;
timeline: TimelineEvent[];
}
export interface DetermineRequest {
proceeding_type: string;
trigger_event_date: string;
conditions: Record<string, boolean>;
}
export interface DetermineResponse {
proceeding_type: string;
proceeding_name: string;
proceeding_color: string;
trigger_event_date: string;
timeline: TimelineEvent[];
total_deadlines: number;
}
export interface BatchCreateRequest {
deadlines: {
title: string;
due_date: string;
original_due_date?: string;
rule_id?: string;
rule_code?: string;
notes?: string;
}[];
}
export interface ProceedingType {
id: number;
code: string;