Compare commits

..

1 Commits

Author SHA1 Message Date
m
642877ae54 feat: document templates with auto-fill from case data (P1)
- Database: kanzlai.document_templates table with RLS policies
- Seed: 4 system templates (Klageerwiderung UPC, Berufungsschrift,
  Mandatsbestätigung, Kostenrechnung)
- Backend: TemplateService (CRUD + render), TemplateHandler with
  endpoints: GET/POST /api/templates, GET/PUT/DELETE /api/templates/{id},
  POST /api/templates/{id}/render?case_id=X
- Template variables: case.*, party.*, tenant.*, user.*, date.*, deadline.*
- Frontend: /vorlagen page with category filters, template detail/editor,
  render flow (select case -> preview -> copy/download), variable toolbar
- Quick action: "Schriftsatz erstellen" button on case detail page
- Also: resolved merge conflicts between audit-trail and role-based branches,
  added missing Notification/AuditLog types to frontend
2026-03-30 11:26:25 +02:00
24 changed files with 1510 additions and 1673 deletions

View File

@@ -5,9 +5,6 @@ import (
"net/http"
"os"
"github.com/jmoiron/sqlx"
_ "github.com/lib/pq"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/auth"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/config"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/db"
@@ -34,21 +31,6 @@ func main() {
authMW := auth.NewMiddleware(cfg.SupabaseJWTSecret, database)
// Optional: connect to youpc.org database for similar case finder
var youpcDB *sqlx.DB
if cfg.YouPCDatabaseURL != "" {
youpcDB, err = sqlx.Connect("postgres", cfg.YouPCDatabaseURL)
if err != nil {
slog.Warn("failed to connect to youpc.org database — similar case finder disabled", "error", err)
youpcDB = nil
} else {
youpcDB.SetMaxOpenConns(5)
youpcDB.SetMaxIdleConns(2)
defer youpcDB.Close()
slog.Info("connected to youpc.org database for similar case finder")
}
}
// Start CalDAV sync service
calDAVSvc := services.NewCalDAVService(database)
calDAVSvc.Start()
@@ -59,7 +41,7 @@ func main() {
notifSvc.Start()
defer notifSvc.Stop()
handler := router.New(database, authMW, cfg, calDAVSvc, notifSvc, youpcDB)
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

@@ -11,9 +11,9 @@ type contextKey string
const (
userIDKey contextKey = "user_id"
tenantIDKey contextKey = "tenant_id"
userRoleKey contextKey = "user_role"
ipKey contextKey = "ip_address"
userAgentKey contextKey = "user_agent"
userRoleKey contextKey = "user_role"
)
func ContextWithUserID(ctx context.Context, userID uuid.UUID) context.Context {
@@ -34,15 +34,6 @@ func TenantFromContext(ctx context.Context) (uuid.UUID, bool) {
return id, ok
}
func ContextWithUserRole(ctx context.Context, role string) context.Context {
return context.WithValue(ctx, userRoleKey, role)
}
func UserRoleFromContext(ctx context.Context) string {
role, _ := ctx.Value(userRoleKey).(string)
return role
}
func ContextWithRequestInfo(ctx context.Context, ip, userAgent string) context.Context {
ctx = context.WithValue(ctx, ipKey, ip)
ctx = context.WithValue(ctx, userAgentKey, userAgent)
@@ -62,3 +53,12 @@ func UserAgentFromContext(ctx context.Context) *string {
}
return nil
}
func ContextWithUserRole(ctx context.Context, role string) context.Context {
return context.WithValue(ctx, userRoleKey, role)
}
func UserRoleFromContext(ctx context.Context) string {
role, _ := ctx.Value(userRoleKey).(string)
return role
}

View File

@@ -45,6 +45,7 @@ func (m *Middleware) RequireAuth(next http.Handler) http.Handler {
// Tenant resolution is handled by TenantResolver middleware for scoped routes.
// Tenant management routes handle their own access control.
next.ServeHTTP(w, r.WithContext(ctx))
})
}

View File

@@ -35,6 +35,7 @@ func (tr *TenantResolver) Resolve(next http.Handler) http.Handler {
}
var tenantID uuid.UUID
ctx := r.Context()
if header := r.Header.Get("X-Tenant-ID"); header != "" {
parsed, err := uuid.Parse(header)
@@ -56,9 +57,9 @@ func (tr *TenantResolver) Resolve(next http.Handler) http.Handler {
}
tenantID = parsed
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 {
slog.Error("failed to resolve default tenant", "error", err, "user_id", userID)
@@ -70,9 +71,18 @@ func (tr *TenantResolver) Resolve(next http.Handler) http.Handler {
return
}
tenantID = *first
// Get role for default tenant
role, err := tr.lookup.GetUserRole(r.Context(), userID, tenantID)
if err != nil {
slog.Error("failed to get user role", "error", err, "user_id", userID, "tenant_id", tenantID)
http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError)
return
}
ctx = ContextWithUserRole(ctx, role)
}
ctx := ContextWithTenantID(r.Context(), tenantID)
ctx = ContextWithTenantID(ctx, tenantID)
next.ServeHTTP(w, r.WithContext(ctx))
})
}

View File

@@ -15,7 +15,6 @@ type mockTenantLookup struct {
hasAccess bool
accessErr error
role string
noAccess bool // when true, GetUserRole returns ""
}
func (m *mockTenantLookup) FirstTenantForUser(ctx context.Context, userID uuid.UUID) (*uuid.UUID, error) {
@@ -27,18 +26,18 @@ func (m *mockTenantLookup) VerifyAccess(ctx context.Context, userID, tenantID uu
}
func (m *mockTenantLookup) GetUserRole(ctx context.Context, userID, tenantID uuid.UUID) (string, error) {
if m.noAccess {
return "", m.err
}
if m.role != "" {
return m.role, m.err
}
if m.hasAccess {
return "associate", m.err
}
return "", m.err
}
func TestTenantResolver_FromHeader(t *testing.T) {
tenantID := uuid.New()
tr := NewTenantResolver(&mockTenantLookup{role: "partner", hasAccess: true})
tr := NewTenantResolver(&mockTenantLookup{hasAccess: true, role: "partner"})
var gotTenantID uuid.UUID
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -67,7 +66,7 @@ func TestTenantResolver_FromHeader(t *testing.T) {
func TestTenantResolver_FromHeader_NoAccess(t *testing.T) {
tenantID := uuid.New()
tr := NewTenantResolver(&mockTenantLookup{noAccess: true})
tr := NewTenantResolver(&mockTenantLookup{hasAccess: false})
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatal("next should not be called")
@@ -87,7 +86,7 @@ func TestTenantResolver_FromHeader_NoAccess(t *testing.T) {
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

@@ -14,7 +14,6 @@ type Config struct {
SupabaseJWTSecret string
AnthropicAPIKey string
FrontendOrigin string
YouPCDatabaseURL string // read-only connection to youpc.org Supabase for similar case finder
}
func Load() (*Config, error) {
@@ -27,7 +26,6 @@ func Load() (*Config, error) {
SupabaseJWTSecret: os.Getenv("SUPABASE_JWT_SECRET"),
AnthropicAPIKey: os.Getenv("ANTHROPIC_API_KEY"),
FrontendOrigin: getEnv("FRONTEND_ORIGIN", "https://kanzlai.msbls.de"),
YouPCDatabaseURL: os.Getenv("YOUPC_DATABASE_URL"),
}
if cfg.DatabaseURL == "" {

View File

@@ -5,8 +5,6 @@ import (
"io"
"net/http"
"github.com/google/uuid"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/auth"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/services"
)
@@ -117,139 +115,3 @@ func (h *AIHandler) SummarizeCase(w http.ResponseWriter, r *http.Request) {
"summary": summary,
})
}
// DraftDocument handles POST /api/ai/draft-document
// Accepts JSON {"case_id": "uuid", "template_type": "string", "instructions": "string", "language": "de|en|fr"}.
func (h *AIHandler) DraftDocument(w http.ResponseWriter, r *http.Request) {
tenantID, ok := auth.TenantFromContext(r.Context())
if !ok {
writeError(w, http.StatusForbidden, "missing tenant")
return
}
var body struct {
CaseID string `json:"case_id"`
TemplateType string `json:"template_type"`
Instructions string `json:"instructions"`
Language string `json:"language"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if body.CaseID == "" {
writeError(w, http.StatusBadRequest, "case_id is required")
return
}
if body.TemplateType == "" {
writeError(w, http.StatusBadRequest, "template_type is required")
return
}
caseID, err := parseUUID(body.CaseID)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid case_id")
return
}
if len(body.Instructions) > maxDescriptionLen {
writeError(w, http.StatusBadRequest, "instructions exceeds maximum length")
return
}
draft, err := h.ai.DraftDocument(r.Context(), tenantID, caseID, body.TemplateType, body.Instructions, body.Language)
if err != nil {
internalError(w, "AI document drafting failed", err)
return
}
writeJSON(w, http.StatusOK, draft)
}
// CaseStrategy handles POST /api/ai/case-strategy
// Accepts JSON {"case_id": "uuid"}.
func (h *AIHandler) CaseStrategy(w http.ResponseWriter, r *http.Request) {
tenantID, ok := auth.TenantFromContext(r.Context())
if !ok {
writeError(w, http.StatusForbidden, "missing tenant")
return
}
var body struct {
CaseID string `json:"case_id"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if body.CaseID == "" {
writeError(w, http.StatusBadRequest, "case_id is required")
return
}
caseID, err := parseUUID(body.CaseID)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid case_id")
return
}
strategy, err := h.ai.CaseStrategy(r.Context(), tenantID, caseID)
if err != nil {
internalError(w, "AI case strategy analysis failed", err)
return
}
writeJSON(w, http.StatusOK, strategy)
}
// SimilarCases handles POST /api/ai/similar-cases
// Accepts JSON {"case_id": "uuid", "description": "string"}.
func (h *AIHandler) SimilarCases(w http.ResponseWriter, r *http.Request) {
tenantID, ok := auth.TenantFromContext(r.Context())
if !ok {
writeError(w, http.StatusForbidden, "missing tenant")
return
}
var body struct {
CaseID string `json:"case_id"`
Description string `json:"description"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if body.CaseID == "" && body.Description == "" {
writeError(w, http.StatusBadRequest, "either case_id or description is required")
return
}
if len(body.Description) > maxDescriptionLen {
writeError(w, http.StatusBadRequest, "description exceeds maximum length")
return
}
var caseID uuid.UUID
if body.CaseID != "" {
var err error
caseID, err = parseUUID(body.CaseID)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid case_id")
return
}
}
cases, err := h.ai.FindSimilarCases(r.Context(), tenantID, caseID, body.Description)
if err != nil {
internalError(w, "AI similar case search failed", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{
"cases": cases,
"count": len(cases),
})
}

View File

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

View File

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

View File

@@ -15,7 +15,7 @@ import (
"mgit.msbls.de/m/KanzlAI-mGMT/internal/services"
)
func New(db *sqlx.DB, authMW *auth.Middleware, cfg *config.Config, calDAVSvc *services.CalDAVService, notifSvc *services.NotificationService, youpcDB ...*sqlx.DB) 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
@@ -31,15 +31,12 @@ func New(db *sqlx.DB, authMW *auth.Middleware, cfg *config.Config, calDAVSvc *se
storageCli := services.NewStorageClient(cfg.SupabaseURL, cfg.SupabaseServiceKey)
documentSvc := services.NewDocumentService(db, storageCli, auditSvc)
assignmentSvc := services.NewCaseAssignmentService(db)
templateSvc := services.NewTemplateService(db, auditSvc)
// AI service (optional — only if API key is configured)
var aiH *handlers.AIHandler
if cfg.AnthropicAPIKey != "" {
var ydb *sqlx.DB
if len(youpcDB) > 0 {
ydb = youpcDB[0]
}
aiSvc := services.NewAIService(cfg.AnthropicAPIKey, db, ydb)
aiSvc := services.NewAIService(cfg.AnthropicAPIKey, db)
aiH = handlers.NewAIHandler(aiSvc)
}
@@ -69,6 +66,7 @@ func New(db *sqlx.DB, authMW *auth.Middleware, cfg *config.Config, calDAVSvc *se
eventH := handlers.NewCaseEventHandler(db)
docH := handlers.NewDocumentHandler(documentSvc)
assignmentH := handlers.NewCaseAssignmentHandler(assignmentSvc)
templateH := handlers.NewTemplateHandler(templateSvc, caseSvc, partySvc, deadlineSvc, tenantSvc)
// Public routes
mux.HandleFunc("GET /health", handleHealth(db))
@@ -110,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
@@ -136,7 +134,7 @@ 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)
// Appointments — all can manage (PermManageAppointments granted to all)
// Appointments — all can manage
scoped.HandleFunc("GET /api/appointments/{id}", apptH.Get)
scoped.HandleFunc("GET /api/appointments", apptH.List)
scoped.HandleFunc("POST /api/appointments", perm(auth.PermManageAppointments, apptH.Create))
@@ -163,21 +161,26 @@ func New(db *sqlx.DB, authMW *auth.Middleware, cfg *config.Config, calDAVSvc *se
// Audit log
scoped.HandleFunc("GET /api/audit-log", auditH.List)
// Documents — all can upload, delete checked in handler (own vs all)
// Documents — all can upload, delete checked in handler
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)
// Document templates — all can view, create/edit needs PermCreateCase
scoped.HandleFunc("GET /api/templates", templateH.List)
scoped.HandleFunc("GET /api/templates/{id}", templateH.Get)
scoped.HandleFunc("POST /api/templates", perm(auth.PermCreateCase, templateH.Create))
scoped.HandleFunc("PUT /api/templates/{id}", perm(auth.PermCreateCase, templateH.Update))
scoped.HandleFunc("DELETE /api/templates/{id}", perm(auth.PermCreateCase, templateH.Delete))
scoped.HandleFunc("POST /api/templates/{id}/render", templateH.Render)
// AI endpoints (rate limited: 5 req/min burst 10 per IP)
if aiH != nil {
aiLimiter := middleware.NewTokenBucket(5.0/60.0, 10)
scoped.HandleFunc("POST /api/ai/extract-deadlines", perm(auth.PermAIExtraction, aiLimiter.LimitFunc(aiH.ExtractDeadlines)))
scoped.HandleFunc("POST /api/ai/summarize-case", perm(auth.PermAIExtraction, aiLimiter.LimitFunc(aiH.SummarizeCase)))
scoped.HandleFunc("POST /api/ai/draft-document", perm(auth.PermAIExtraction, aiLimiter.LimitFunc(aiH.DraftDocument)))
scoped.HandleFunc("POST /api/ai/case-strategy", perm(auth.PermAIExtraction, aiLimiter.LimitFunc(aiH.CaseStrategy)))
scoped.HandleFunc("POST /api/ai/similar-cases", perm(auth.PermAIExtraction, aiLimiter.LimitFunc(aiH.SimilarCases)))
}
// Notifications
@@ -190,7 +193,7 @@ func New(db *sqlx.DB, authMW *auth.Middleware, cfg *config.Config, calDAVSvc *se
scoped.HandleFunc("PUT /api/notification-preferences", notifH.UpdatePreferences)
}
// CalDAV sync endpoints — settings permission required
// CalDAV sync endpoints
if calDAVSvc != nil {
calDAVH := handlers.NewCalDAVHandler(calDAVSvc)
scoped.HandleFunc("POST /api/caldav/sync", perm(auth.PermManageSettings, calDAVH.TriggerSync))

View File

@@ -5,7 +5,6 @@ import (
"encoding/base64"
"encoding/json"
"fmt"
"strings"
"time"
"github.com/anthropics/anthropic-sdk-go"
@@ -19,12 +18,11 @@ import (
type AIService struct {
client anthropic.Client
db *sqlx.DB
youpcDB *sqlx.DB // read-only connection to youpc.org for similar case finder (may be nil)
}
func NewAIService(apiKey string, db *sqlx.DB, youpcDB *sqlx.DB) *AIService {
func NewAIService(apiKey string, db *sqlx.DB) *AIService {
client := anthropic.NewClient(option.WithAPIKey(apiKey))
return &AIService{client: client, db: db, youpcDB: youpcDB}
return &AIService{client: client, db: db}
}
// ExtractedDeadline represents a deadline extracted by AI from a document.
@@ -283,726 +281,3 @@ func (s *AIService) SummarizeCase(ctx context.Context, tenantID, caseID uuid.UUI
return summary, nil
}
// --- Document Drafting ---
// DocumentDraft represents an AI-generated document draft.
type DocumentDraft struct {
Title string `json:"title"`
Content string `json:"content"`
Language string `json:"language"`
}
// templateDescriptions maps template type IDs to descriptions for Claude.
var templateDescriptions = map[string]string{
"klageschrift": "Klageschrift (Statement of Claim) — formal complaint initiating legal proceedings",
"klageerwiderung": "Klageerwiderung (Statement of Defence) — formal response to a statement of claim",
"abmahnung": "Abmahnung (Cease and Desist Letter) — formal warning letter demanding cessation of an activity",
"schriftsatz": "Schriftsatz (Legal Brief) — formal legal submission to the court",
"berufung": "Berufungsschrift (Appeal Brief) — formal appeal against a court decision",
"antrag": "Antrag (Motion/Application) — formal application or motion to the court",
"stellungnahme": "Stellungnahme (Statement/Position Paper) — formal response or position paper",
"gutachten": "Gutachten (Legal Opinion/Expert Report) — detailed legal analysis or opinion",
"vertrag": "Vertrag (Contract/Agreement) — legal contract or agreement between parties",
"vollmacht": "Vollmacht (Power of Attorney) — formal authorization document",
"upc_claim": "UPC Statement of Claim — claim filed at the Unified Patent Court",
"upc_defence": "UPC Statement of Defence — defence filed at the Unified Patent Court",
"upc_counterclaim": "UPC Counterclaim for Revocation — counterclaim for patent revocation at the UPC",
"upc_injunction": "UPC Application for Provisional Measures — application for injunctive relief at the UPC",
}
const draftDocumentSystemPrompt = `You are an expert legal document drafter for German and UPC (Unified Patent Court) patent litigation.
You draft professional legal documents in the requested language, following proper legal formatting conventions.
Guidelines:
- Use proper legal structure with numbered sections and paragraphs
- Include standard legal formalities (headers, salutations, signatures block)
- Reference relevant legal provisions (BGB, ZPO, UPC Rules of Procedure, etc.)
- Use precise legal terminology appropriate for the jurisdiction
- Include placeholders in [BRACKETS] for information that needs to be filled in
- Base the content on the provided case data and instructions
- Output the document as clean text with proper formatting`
// DraftDocument generates an AI-drafted legal document based on case data and a template type.
func (s *AIService) DraftDocument(ctx context.Context, tenantID, caseID uuid.UUID, templateType, instructions, language string) (*DocumentDraft, error) {
if language == "" {
language = "de"
}
langLabel := "German"
if language == "en" {
langLabel = "English"
} else if language == "fr" {
langLabel = "French"
}
// Load case data
var c models.Case
if err := s.db.GetContext(ctx, &c,
"SELECT * FROM cases WHERE id = $1 AND tenant_id = $2", caseID, tenantID); err != nil {
return nil, fmt.Errorf("loading case: %w", err)
}
// Load parties
var parties []models.Party
_ = s.db.SelectContext(ctx, &parties,
"SELECT * FROM parties WHERE case_id = $1 AND tenant_id = $2", caseID, tenantID)
// Load recent events
var events []models.CaseEvent
_ = s.db.SelectContext(ctx, &events,
"SELECT * FROM case_events WHERE case_id = $1 AND tenant_id = $2 ORDER BY created_at DESC LIMIT 15",
caseID, tenantID)
// Load active deadlines
var deadlines []models.Deadline
_ = s.db.SelectContext(ctx, &deadlines,
"SELECT * FROM deadlines WHERE case_id = $1 AND tenant_id = $2 AND status = 'active' ORDER BY due_date ASC LIMIT 10",
caseID, tenantID)
// Load documents metadata for context
var documents []models.Document
_ = s.db.SelectContext(ctx, &documents,
"SELECT id, title, doc_type, created_at FROM documents WHERE case_id = $1 AND tenant_id = $2 ORDER BY created_at DESC LIMIT 10",
caseID, tenantID)
// Build context
var b strings.Builder
b.WriteString(fmt.Sprintf("Case: %s — %s\nStatus: %s\n", c.CaseNumber, c.Title, c.Status))
if c.Court != nil {
b.WriteString(fmt.Sprintf("Court: %s\n", *c.Court))
}
if c.CourtRef != nil {
b.WriteString(fmt.Sprintf("Court Reference: %s\n", *c.CourtRef))
}
if c.CaseType != nil {
b.WriteString(fmt.Sprintf("Type: %s\n", *c.CaseType))
}
if len(parties) > 0 {
b.WriteString("\nParties:\n")
for _, p := range parties {
role := "unknown role"
if p.Role != nil {
role = *p.Role
}
b.WriteString(fmt.Sprintf("- %s (%s)", p.Name, role))
if p.Representative != nil {
b.WriteString(fmt.Sprintf(" — represented by %s", *p.Representative))
}
b.WriteString("\n")
}
}
if len(events) > 0 {
b.WriteString("\nRecent Events:\n")
for _, e := range events {
b.WriteString(fmt.Sprintf("- [%s] %s", e.CreatedAt.Format("2006-01-02"), e.Title))
if e.Description != nil {
b.WriteString(fmt.Sprintf(": %s", *e.Description))
}
b.WriteString("\n")
}
}
if len(deadlines) > 0 {
b.WriteString("\nUpcoming Deadlines:\n")
for _, d := range deadlines {
b.WriteString(fmt.Sprintf("- %s: due %s\n", d.Title, d.DueDate))
}
}
templateDesc, ok := templateDescriptions[templateType]
if !ok {
templateDesc = templateType
}
prompt := fmt.Sprintf(`Draft a %s for this case in %s.
Document type: %s
Case context:
%s
Additional instructions from the lawyer:
%s
Generate the complete document now.`, templateDesc, langLabel, templateDesc, b.String(), instructions)
msg, err := s.client.Messages.New(ctx, anthropic.MessageNewParams{
Model: anthropic.ModelClaudeSonnet4_20250514,
MaxTokens: 8192,
System: []anthropic.TextBlockParam{
{Text: draftDocumentSystemPrompt},
},
Messages: []anthropic.MessageParam{
anthropic.NewUserMessage(anthropic.NewTextBlock(prompt)),
},
})
if err != nil {
return nil, fmt.Errorf("claude API call: %w", err)
}
var content string
for _, block := range msg.Content {
if block.Type == "text" {
content += block.Text
}
}
if content == "" {
return nil, fmt.Errorf("empty response from Claude")
}
title := fmt.Sprintf("%s — %s", templateDesc, c.CaseNumber)
return &DocumentDraft{
Title: title,
Content: content,
Language: language,
}, nil
}
// --- Case Strategy ---
// StrategyRecommendation represents an AI-generated case strategy analysis.
type StrategyRecommendation struct {
Summary string `json:"summary"`
NextSteps []StrategyStep `json:"next_steps"`
RiskAssessment []RiskItem `json:"risk_assessment"`
Timeline []TimelineItem `json:"timeline"`
}
type StrategyStep struct {
Priority string `json:"priority"` // high, medium, low
Action string `json:"action"`
Reasoning string `json:"reasoning"`
Deadline string `json:"deadline,omitempty"`
}
type RiskItem struct {
Level string `json:"level"` // high, medium, low
Risk string `json:"risk"`
Mitigation string `json:"mitigation"`
}
type TimelineItem struct {
Date string `json:"date"`
Event string `json:"event"`
Importance string `json:"importance"` // critical, important, routine
}
type strategyToolInput struct {
Summary string `json:"summary"`
NextSteps []StrategyStep `json:"next_steps"`
RiskAssessment []RiskItem `json:"risk_assessment"`
Timeline []TimelineItem `json:"timeline"`
}
var caseStrategyTool = anthropic.ToolParam{
Name: "case_strategy",
Description: anthropic.String("Provide strategic case analysis with next steps, risk assessment, and timeline optimization."),
InputSchema: anthropic.ToolInputSchemaParam{
Properties: map[string]any{
"summary": map[string]any{
"type": "string",
"description": "Executive summary of the case situation and strategic outlook (2-4 sentences)",
},
"next_steps": map[string]any{
"type": "array",
"description": "Recommended next actions in priority order",
"items": map[string]any{
"type": "object",
"properties": map[string]any{
"priority": map[string]any{
"type": "string",
"enum": []string{"high", "medium", "low"},
},
"action": map[string]any{
"type": "string",
"description": "Specific recommended action",
},
"reasoning": map[string]any{
"type": "string",
"description": "Why this action is recommended",
},
"deadline": map[string]any{
"type": "string",
"description": "Suggested deadline in YYYY-MM-DD format, if applicable",
},
},
"required": []string{"priority", "action", "reasoning"},
},
},
"risk_assessment": map[string]any{
"type": "array",
"description": "Key risks and mitigation strategies",
"items": map[string]any{
"type": "object",
"properties": map[string]any{
"level": map[string]any{
"type": "string",
"enum": []string{"high", "medium", "low"},
},
"risk": map[string]any{
"type": "string",
"description": "Description of the risk",
},
"mitigation": map[string]any{
"type": "string",
"description": "Recommended mitigation strategy",
},
},
"required": []string{"level", "risk", "mitigation"},
},
},
"timeline": map[string]any{
"type": "array",
"description": "Optimized timeline of upcoming milestones and events",
"items": map[string]any{
"type": "object",
"properties": map[string]any{
"date": map[string]any{
"type": "string",
"description": "Date in YYYY-MM-DD format",
},
"event": map[string]any{
"type": "string",
"description": "Description of the milestone or event",
},
"importance": map[string]any{
"type": "string",
"enum": []string{"critical", "important", "routine"},
},
},
"required": []string{"date", "event", "importance"},
},
},
},
Required: []string{"summary", "next_steps", "risk_assessment", "timeline"},
},
}
const caseStrategySystemPrompt = `You are a senior litigation strategist specializing in German law and UPC (Unified Patent Court) patent proceedings.
Analyze the case thoroughly and provide:
1. An executive summary of the current strategic position
2. Prioritized next steps with clear reasoning
3. Risk assessment with mitigation strategies
4. An optimized timeline of upcoming milestones
Consider:
- Procedural deadlines and their implications
- Strength of the parties' positions based on available information
- Potential settlement opportunities
- Cost-efficiency of different strategic approaches
- UPC-specific procedural peculiarities if applicable (bifurcation, preliminary injunctions, etc.)
Be practical and actionable. Avoid generic advice — tailor recommendations to the specific case data provided.`
// CaseStrategy analyzes a case and returns strategic recommendations.
func (s *AIService) CaseStrategy(ctx context.Context, tenantID, caseID uuid.UUID) (*StrategyRecommendation, error) {
// Load case
var c models.Case
if err := s.db.GetContext(ctx, &c,
"SELECT * FROM cases WHERE id = $1 AND tenant_id = $2", caseID, tenantID); err != nil {
return nil, fmt.Errorf("loading case: %w", err)
}
// Load parties
var parties []models.Party
_ = s.db.SelectContext(ctx, &parties,
"SELECT * FROM parties WHERE case_id = $1 AND tenant_id = $2", caseID, tenantID)
// Load all events
var events []models.CaseEvent
_ = s.db.SelectContext(ctx, &events,
"SELECT * FROM case_events WHERE case_id = $1 AND tenant_id = $2 ORDER BY created_at DESC LIMIT 25",
caseID, tenantID)
// Load all deadlines (active + completed for context)
var deadlines []models.Deadline
_ = s.db.SelectContext(ctx, &deadlines,
"SELECT * FROM deadlines WHERE case_id = $1 AND tenant_id = $2 ORDER BY due_date ASC LIMIT 20",
caseID, tenantID)
// Load documents metadata
var documents []models.Document
_ = s.db.SelectContext(ctx, &documents,
"SELECT id, title, doc_type, created_at FROM documents WHERE case_id = $1 AND tenant_id = $2 ORDER BY created_at DESC LIMIT 15",
caseID, tenantID)
// Build comprehensive context
var b strings.Builder
b.WriteString(fmt.Sprintf("Case: %s — %s\nStatus: %s\n", c.CaseNumber, c.Title, c.Status))
if c.Court != nil {
b.WriteString(fmt.Sprintf("Court: %s\n", *c.Court))
}
if c.CourtRef != nil {
b.WriteString(fmt.Sprintf("Court Reference: %s\n", *c.CourtRef))
}
if c.CaseType != nil {
b.WriteString(fmt.Sprintf("Type: %s\n", *c.CaseType))
}
if len(parties) > 0 {
b.WriteString("\nParties:\n")
for _, p := range parties {
role := "unknown"
if p.Role != nil {
role = *p.Role
}
b.WriteString(fmt.Sprintf("- %s (%s)", p.Name, role))
if p.Representative != nil {
b.WriteString(fmt.Sprintf(" — represented by %s", *p.Representative))
}
b.WriteString("\n")
}
}
if len(events) > 0 {
b.WriteString("\nCase Events (chronological):\n")
for _, e := range events {
b.WriteString(fmt.Sprintf("- [%s] %s", e.CreatedAt.Format("2006-01-02"), e.Title))
if e.Description != nil {
b.WriteString(fmt.Sprintf(": %s", *e.Description))
}
b.WriteString("\n")
}
}
if len(deadlines) > 0 {
b.WriteString("\nDeadlines:\n")
for _, d := range deadlines {
b.WriteString(fmt.Sprintf("- %s: due %s (status: %s)\n", d.Title, d.DueDate, d.Status))
}
}
if len(documents) > 0 {
b.WriteString("\nDocuments on file:\n")
for _, d := range documents {
docType := ""
if d.DocType != nil {
docType = fmt.Sprintf(" [%s]", *d.DocType)
}
b.WriteString(fmt.Sprintf("- %s%s (%s)\n", d.Title, docType, d.CreatedAt.Format("2006-01-02")))
}
}
msg, err := s.client.Messages.New(ctx, anthropic.MessageNewParams{
Model: anthropic.ModelClaudeOpus4_6,
MaxTokens: 4096,
System: []anthropic.TextBlockParam{
{Text: caseStrategySystemPrompt},
},
Messages: []anthropic.MessageParam{
anthropic.NewUserMessage(anthropic.NewTextBlock("Analyze this case and provide strategic recommendations:\n\n" + b.String())),
},
Tools: []anthropic.ToolUnionParam{
{OfTool: &caseStrategyTool},
},
ToolChoice: anthropic.ToolChoiceParamOfTool("case_strategy"),
})
if err != nil {
return nil, fmt.Errorf("claude API call: %w", err)
}
for _, block := range msg.Content {
if block.Type == "tool_use" && block.Name == "case_strategy" {
var input strategyToolInput
if err := json.Unmarshal(block.Input, &input); err != nil {
return nil, fmt.Errorf("parsing strategy output: %w", err)
}
result := &StrategyRecommendation{
Summary: input.Summary,
NextSteps: input.NextSteps,
RiskAssessment: input.RiskAssessment,
Timeline: input.Timeline,
}
// Cache in database
strategyJSON, _ := json.Marshal(result)
_, _ = s.db.ExecContext(ctx,
"UPDATE cases SET ai_summary = $1, updated_at = $2 WHERE id = $3 AND tenant_id = $4",
string(strategyJSON), time.Now(), caseID, tenantID)
return result, nil
}
}
return nil, fmt.Errorf("no tool_use block in response")
}
// --- Similar Case Finder ---
// SimilarCase represents a UPC case found to be similar.
type SimilarCase struct {
CaseNumber string `json:"case_number"`
Title string `json:"title"`
Court string `json:"court"`
Date string `json:"date"`
Relevance float64 `json:"relevance"` // 0.0-1.0
Explanation string `json:"explanation"` // why this case is similar
KeyHoldings string `json:"key_holdings"` // relevant holdings
URL string `json:"url,omitempty"` // link to youpc.org
}
// youpcCase represents a case from the youpc.org database.
type youpcCase struct {
ID string `db:"id" json:"id"`
CaseNumber *string `db:"case_number" json:"case_number"`
Title *string `db:"title" json:"title"`
Court *string `db:"court" json:"court"`
DecisionDate *string `db:"decision_date" json:"decision_date"`
CaseType *string `db:"case_type" json:"case_type"`
Outcome *string `db:"outcome" json:"outcome"`
PatentNumbers *string `db:"patent_numbers" json:"patent_numbers"`
Summary *string `db:"summary" json:"summary"`
Claimant *string `db:"claimant" json:"claimant"`
Defendant *string `db:"defendant" json:"defendant"`
}
type similarCaseToolInput struct {
Cases []struct {
CaseID string `json:"case_id"`
Relevance float64 `json:"relevance"`
Explanation string `json:"explanation"`
KeyHoldings string `json:"key_holdings"`
} `json:"cases"`
}
var similarCaseTool = anthropic.ToolParam{
Name: "rank_similar_cases",
Description: anthropic.String("Rank the provided UPC cases by relevance to the query case and explain why each is similar."),
InputSchema: anthropic.ToolInputSchemaParam{
Properties: map[string]any{
"cases": map[string]any{
"type": "array",
"description": "UPC cases ranked by relevance (most relevant first)",
"items": map[string]any{
"type": "object",
"properties": map[string]any{
"case_id": map[string]any{
"type": "string",
"description": "The ID of the UPC case from the provided list",
},
"relevance": map[string]any{
"type": "number",
"minimum": 0,
"maximum": 1,
"description": "Relevance score from 0.0 to 1.0",
},
"explanation": map[string]any{
"type": "string",
"description": "Why this case is relevant — what legal issues, parties, patents, or procedural aspects are similar",
},
"key_holdings": map[string]any{
"type": "string",
"description": "Key holdings or legal principles from this case that are relevant",
},
},
"required": []string{"case_id", "relevance", "explanation", "key_holdings"},
},
},
},
Required: []string{"cases"},
},
}
const similarCaseSystemPrompt = `You are a UPC (Unified Patent Court) case law expert.
Given a case description and a list of UPC cases from the database, rank the cases by relevance and explain why each one is similar or relevant.
Consider:
- Similar patents or technology areas
- Same parties or representatives
- Similar legal issues (infringement, validity, injunctions, etc.)
- Similar procedural situations
- Relevant legal principles that could apply
Only include cases that are genuinely relevant (relevance > 0.3). Order by relevance descending.`
// FindSimilarCases searches the youpc.org database for similar UPC cases.
func (s *AIService) FindSimilarCases(ctx context.Context, tenantID, caseID uuid.UUID, description string) ([]SimilarCase, error) {
if s.youpcDB == nil {
return nil, fmt.Errorf("youpc.org database not configured")
}
// Build query context from the case (if provided) or description
var queryText string
if caseID != uuid.Nil {
var c models.Case
if err := s.db.GetContext(ctx, &c,
"SELECT * FROM cases WHERE id = $1 AND tenant_id = $2", caseID, tenantID); err != nil {
return nil, fmt.Errorf("loading case: %w", err)
}
var parties []models.Party
_ = s.db.SelectContext(ctx, &parties,
"SELECT * FROM parties WHERE case_id = $1 AND tenant_id = $2", caseID, tenantID)
var b strings.Builder
b.WriteString(fmt.Sprintf("Case: %s — %s\n", c.CaseNumber, c.Title))
if c.CaseType != nil {
b.WriteString(fmt.Sprintf("Type: %s\n", *c.CaseType))
}
if c.Court != nil {
b.WriteString(fmt.Sprintf("Court: %s\n", *c.Court))
}
for _, p := range parties {
role := ""
if p.Role != nil {
role = *p.Role
}
b.WriteString(fmt.Sprintf("Party: %s (%s)\n", p.Name, role))
}
if description != "" {
b.WriteString(fmt.Sprintf("\nAdditional context: %s\n", description))
}
queryText = b.String()
} else if description != "" {
queryText = description
} else {
return nil, fmt.Errorf("either case_id or description must be provided")
}
// Query youpc.org database for candidate cases
// Search by text similarity across case titles, summaries, party names
var candidates []youpcCase
err := s.youpcDB.SelectContext(ctx, &candidates, `
SELECT
id,
case_number,
title,
court,
decision_date,
case_type,
outcome,
patent_numbers,
summary,
claimant,
defendant
FROM mlex.cases
ORDER BY decision_date DESC NULLS LAST
LIMIT 50
`)
if err != nil {
return nil, fmt.Errorf("querying youpc.org cases: %w", err)
}
if len(candidates) == 0 {
return []SimilarCase{}, nil
}
// Build candidate list for Claude
var candidateText strings.Builder
for _, c := range candidates {
candidateText.WriteString(fmt.Sprintf("ID: %s\n", c.ID))
if c.CaseNumber != nil {
candidateText.WriteString(fmt.Sprintf("Case Number: %s\n", *c.CaseNumber))
}
if c.Title != nil {
candidateText.WriteString(fmt.Sprintf("Title: %s\n", *c.Title))
}
if c.Court != nil {
candidateText.WriteString(fmt.Sprintf("Court: %s\n", *c.Court))
}
if c.DecisionDate != nil {
candidateText.WriteString(fmt.Sprintf("Decision Date: %s\n", *c.DecisionDate))
}
if c.CaseType != nil {
candidateText.WriteString(fmt.Sprintf("Type: %s\n", *c.CaseType))
}
if c.Outcome != nil {
candidateText.WriteString(fmt.Sprintf("Outcome: %s\n", *c.Outcome))
}
if c.PatentNumbers != nil {
candidateText.WriteString(fmt.Sprintf("Patents: %s\n", *c.PatentNumbers))
}
if c.Claimant != nil {
candidateText.WriteString(fmt.Sprintf("Claimant: %s\n", *c.Claimant))
}
if c.Defendant != nil {
candidateText.WriteString(fmt.Sprintf("Defendant: %s\n", *c.Defendant))
}
if c.Summary != nil {
candidateText.WriteString(fmt.Sprintf("Summary: %s\n", *c.Summary))
}
candidateText.WriteString("---\n")
}
prompt := fmt.Sprintf(`Find UPC cases relevant to this matter:
%s
Here are the UPC cases from the database to evaluate:
%s
Rank only the genuinely relevant cases by similarity.`, queryText, candidateText.String())
msg, err := s.client.Messages.New(ctx, anthropic.MessageNewParams{
Model: anthropic.ModelClaudeSonnet4_20250514,
MaxTokens: 4096,
System: []anthropic.TextBlockParam{
{Text: similarCaseSystemPrompt},
},
Messages: []anthropic.MessageParam{
anthropic.NewUserMessage(anthropic.NewTextBlock(prompt)),
},
Tools: []anthropic.ToolUnionParam{
{OfTool: &similarCaseTool},
},
ToolChoice: anthropic.ToolChoiceParamOfTool("rank_similar_cases"),
})
if err != nil {
return nil, fmt.Errorf("claude API call: %w", err)
}
for _, block := range msg.Content {
if block.Type == "tool_use" && block.Name == "rank_similar_cases" {
var input similarCaseToolInput
if err := json.Unmarshal(block.Input, &input); err != nil {
return nil, fmt.Errorf("parsing similar cases output: %w", err)
}
// Build lookup map for candidate data
candidateMap := make(map[string]youpcCase)
for _, c := range candidates {
candidateMap[c.ID] = c
}
var results []SimilarCase
for _, ranked := range input.Cases {
if ranked.Relevance < 0.3 {
continue
}
c, ok := candidateMap[ranked.CaseID]
if !ok {
continue
}
sc := SimilarCase{
Relevance: ranked.Relevance,
Explanation: ranked.Explanation,
KeyHoldings: ranked.KeyHoldings,
}
if c.CaseNumber != nil {
sc.CaseNumber = *c.CaseNumber
}
if c.Title != nil {
sc.Title = *c.Title
}
if c.Court != nil {
sc.Court = *c.Court
}
if c.DecisionDate != nil {
sc.Date = *c.DecisionDate
}
if c.CaseNumber != nil {
sc.URL = fmt.Sprintf("https://youpc.org/cases/%s", *c.CaseNumber)
}
results = append(results, sc)
}
return results, nil
}
}
return nil, fmt.Errorf("no tool_use block in response")
}

View File

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

View File

@@ -1,51 +0,0 @@
"use client";
import { useState } from "react";
import { useParams } from "next/navigation";
import { Brain, FileText, Search } from "lucide-react";
import { CaseStrategy } from "@/components/ai/CaseStrategy";
import { DocumentDrafter } from "@/components/ai/DocumentDrafter";
import { SimilarCaseFinder } from "@/components/ai/SimilarCaseFinder";
type AITab = "strategy" | "draft" | "similar";
const TABS: { id: AITab; label: string; icon: typeof Brain }[] = [
{ id: "strategy", label: "KI-Strategie", icon: Brain },
{ id: "draft", label: "KI-Entwurf", icon: FileText },
{ id: "similar", label: "Aehnliche Faelle", icon: Search },
];
export default function CaseAIPage() {
const { id } = useParams<{ id: string }>();
const [activeTab, setActiveTab] = useState<AITab>("strategy");
return (
<div>
{/* Sub-tabs */}
<div className="mb-6 flex gap-1 rounded-lg border border-neutral-200 bg-neutral-50 p-1">
{TABS.map((tab) => {
const isActive = activeTab === tab.id;
return (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`inline-flex flex-1 items-center justify-center gap-1.5 rounded-md px-3 py-2 text-sm font-medium transition-colors ${
isActive
? "bg-white text-neutral-900 shadow-sm"
: "text-neutral-500 hover:text-neutral-700"
}`}
>
<tab.icon className="h-4 w-4" />
{tab.label}
</button>
);
})}
</div>
{/* Content */}
{activeTab === "strategy" && <CaseStrategy caseId={id} />}
{activeTab === "draft" && <DocumentDrafter caseId={id} />}
{activeTab === "similar" && <SimilarCaseFinder caseId={id} />}
</div>
);
}

View File

@@ -17,7 +17,7 @@ import {
StickyNote,
AlertTriangle,
ScrollText,
Brain,
FilePlus,
} from "lucide-react";
import { format } from "date-fns";
import { de } from "date-fns/locale";
@@ -49,7 +49,6 @@ const TABS = [
{ segment: "mitarbeiter", label: "Mitarbeiter", icon: UserCheck },
{ segment: "notizen", label: "Notizen", icon: StickyNote },
{ segment: "protokoll", label: "Protokoll", icon: ScrollText },
{ segment: "ki", label: "KI", icon: Brain },
] as const;
const TAB_LABELS: Record<string, string> = {
@@ -60,7 +59,6 @@ const TAB_LABELS: Record<string, string> = {
mitarbeiter: "Mitarbeiter",
notizen: "Notizen",
protokoll: "Protokoll",
ki: "KI",
};
function CaseDetailSkeleton() {
@@ -174,6 +172,14 @@ export default function CaseDetailLayout({
{caseDetail.court_ref && <span>({caseDetail.court_ref})</span>}
</div>
</div>
<div className="flex flex-col items-end gap-2">
<Link
href={`/vorlagen?case_id=${id}`}
className="flex items-center gap-1.5 rounded-md bg-neutral-900 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-neutral-800"
>
<FilePlus className="h-3.5 w-3.5" />
Schriftsatz erstellen
</Link>
<div className="text-right text-xs text-neutral-400">
<p>
Erstellt:{" "}
@@ -189,6 +195,7 @@ export default function CaseDetailLayout({
</p>
</div>
</div>
</div>
{caseDetail.ai_summary && (
<div className="mt-4 rounded-md border border-blue-100 bg-blue-50 px-4 py-3 text-sm text-blue-800">

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,226 +0,0 @@
"use client";
import { useMutation } from "@tanstack/react-query";
import { api } from "@/lib/api";
import type { StrategyRecommendation } from "@/lib/types";
import {
Loader2,
Brain,
AlertTriangle,
ArrowRight,
Shield,
Calendar,
RefreshCw,
} from "lucide-react";
interface CaseStrategyProps {
caseId: string;
}
const PRIORITY_STYLES = {
high: "bg-red-50 text-red-700 border-red-200",
medium: "bg-amber-50 text-amber-700 border-amber-200",
low: "bg-emerald-50 text-emerald-700 border-emerald-200",
} as const;
const IMPORTANCE_STYLES = {
critical: "border-l-red-500",
important: "border-l-amber-500",
routine: "border-l-neutral-300",
} as const;
export function CaseStrategy({ caseId }: CaseStrategyProps) {
const mutation = useMutation({
mutationFn: () =>
api.post<StrategyRecommendation>("/ai/case-strategy", {
case_id: caseId,
}),
});
if (!mutation.data && !mutation.isPending && !mutation.isError) {
return (
<div className="flex flex-col items-center gap-3 py-8 text-center">
<div className="rounded-xl bg-neutral-100 p-3">
<Brain className="h-6 w-6 text-neutral-400" />
</div>
<div>
<p className="text-sm font-medium text-neutral-900">
KI-Strategieanalyse
</p>
<p className="mt-1 text-sm text-neutral-500">
Claude analysiert die Akte und gibt strategische Empfehlungen.
</p>
</div>
<button
onClick={() => mutation.mutate()}
className="inline-flex items-center gap-2 rounded-md bg-neutral-900 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-neutral-800"
>
<Brain className="h-4 w-4" />
Strategie analysieren
</button>
</div>
);
}
if (mutation.isPending) {
return (
<div className="flex flex-col items-center gap-3 py-12 text-center">
<Loader2 className="h-6 w-6 animate-spin text-neutral-400" />
<p className="text-sm text-neutral-500">
Claude analysiert die Akte...
</p>
<p className="text-xs text-neutral-400">
Dies kann bis zu 30 Sekunden dauern.
</p>
</div>
);
}
if (mutation.isError) {
return (
<div className="flex flex-col items-center gap-3 py-8 text-center">
<div className="rounded-xl bg-red-50 p-3">
<AlertTriangle className="h-6 w-6 text-red-500" />
</div>
<p className="text-sm text-neutral-900">Analyse fehlgeschlagen</p>
<button
onClick={() => mutation.mutate()}
className="inline-flex items-center gap-1 text-sm text-neutral-500 transition-colors hover:text-neutral-700"
>
<RefreshCw className="h-3.5 w-3.5" />
Erneut versuchen
</button>
</div>
);
}
const data = mutation.data!;
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-neutral-900">
KI-Strategieanalyse
</h3>
<button
onClick={() => mutation.mutate()}
className="inline-flex items-center gap-1 rounded-md border border-neutral-200 px-2.5 py-1.5 text-xs font-medium text-neutral-600 transition-colors hover:bg-neutral-50"
>
<RefreshCw className="h-3.5 w-3.5" />
Aktualisieren
</button>
</div>
{/* Summary */}
<div className="rounded-md border border-blue-100 bg-blue-50 px-4 py-3 text-sm text-blue-800">
{data.summary}
</div>
{/* Next Steps */}
{data.next_steps?.length > 0 && (
<div>
<h4 className="mb-2 flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide text-neutral-500">
<ArrowRight className="h-3.5 w-3.5" />
Naechste Schritte
</h4>
<div className="space-y-2">
{data.next_steps.map((step, i) => (
<div
key={i}
className="rounded-md border border-neutral-200 bg-white px-4 py-3"
>
<div className="flex items-start gap-3">
<span
className={`mt-0.5 inline-block shrink-0 rounded-full border px-2 py-0.5 text-xs font-medium ${PRIORITY_STYLES[step.priority]}`}
>
{step.priority === "high"
? "Hoch"
: step.priority === "medium"
? "Mittel"
: "Niedrig"}
</span>
<div className="min-w-0">
<p className="text-sm font-medium text-neutral-900">
{step.action}
</p>
<p className="mt-1 text-sm text-neutral-500">
{step.reasoning}
</p>
{step.deadline && (
<p className="mt-1 text-xs text-neutral-400">
Frist: {step.deadline}
</p>
)}
</div>
</div>
</div>
))}
</div>
</div>
)}
{/* Risk Assessment */}
{data.risk_assessment?.length > 0 && (
<div>
<h4 className="mb-2 flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide text-neutral-500">
<Shield className="h-3.5 w-3.5" />
Risikobewertung
</h4>
<div className="space-y-2">
{data.risk_assessment.map((risk, i) => (
<div
key={i}
className="rounded-md border border-neutral-200 bg-white px-4 py-3"
>
<div className="flex items-start gap-3">
<span
className={`mt-0.5 inline-block shrink-0 rounded-full border px-2 py-0.5 text-xs font-medium ${PRIORITY_STYLES[risk.level]}`}
>
{risk.level === "high"
? "Hoch"
: risk.level === "medium"
? "Mittel"
: "Niedrig"}
</span>
<div className="min-w-0">
<p className="text-sm font-medium text-neutral-900">
{risk.risk}
</p>
<p className="mt-1 text-sm text-neutral-500">
Massnahme: {risk.mitigation}
</p>
</div>
</div>
</div>
))}
</div>
</div>
)}
{/* Timeline */}
{data.timeline?.length > 0 && (
<div>
<h4 className="mb-2 flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide text-neutral-500">
<Calendar className="h-3.5 w-3.5" />
Zeitplan
</h4>
<div className="space-y-1">
{data.timeline.map((item, i) => (
<div
key={i}
className={`border-l-2 py-2 pl-4 ${IMPORTANCE_STYLES[item.importance]}`}
>
<div className="flex items-baseline gap-2">
<span className="shrink-0 text-xs font-medium text-neutral-400">
{item.date}
</span>
<span className="text-sm text-neutral-900">{item.event}</span>
</div>
</div>
))}
</div>
</div>
)}
</div>
);
}

View File

@@ -1,198 +0,0 @@
"use client";
import { useState } from "react";
import { useMutation } from "@tanstack/react-query";
import { api } from "@/lib/api";
import type { DocumentDraft, DraftDocumentRequest } from "@/lib/types";
import { FileText, Loader2, Copy, Check, Download } from "lucide-react";
const TEMPLATES = {
klageschrift: "Klageschrift",
klageerwiderung: "Klageerwiderung",
abmahnung: "Abmahnung",
schriftsatz: "Schriftsatz",
berufung: "Berufungsschrift",
antrag: "Antrag",
stellungnahme: "Stellungnahme",
gutachten: "Gutachten",
vertrag: "Vertrag",
vollmacht: "Vollmacht",
upc_claim: "UPC Statement of Claim",
upc_defence: "UPC Statement of Defence",
upc_counterclaim: "UPC Counterclaim for Revocation",
upc_injunction: "UPC Provisional Measures",
} as const;
const LANGUAGES = [
{ value: "de", label: "Deutsch" },
{ value: "en", label: "English" },
{ value: "fr", label: "Francais" },
] as const;
const inputClass =
"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";
interface DocumentDrafterProps {
caseId: string;
}
export function DocumentDrafter({ caseId }: DocumentDrafterProps) {
const [templateType, setTemplateType] = useState("");
const [instructions, setInstructions] = useState("");
const [language, setLanguage] = useState("de");
const [copied, setCopied] = useState(false);
const mutation = useMutation({
mutationFn: (req: DraftDocumentRequest) =>
api.post<DocumentDraft>("/ai/draft-document", req),
});
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!templateType) return;
mutation.mutate({
case_id: caseId,
template_type: templateType,
instructions,
language,
});
}
function handleCopy() {
if (mutation.data?.content) {
navigator.clipboard.writeText(mutation.data.content);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
}
function handleDownload() {
if (!mutation.data?.content) return;
const blob = new Blob([mutation.data.content], { type: "text/plain;charset=utf-8" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${templateType}_entwurf.txt`;
a.click();
URL.revokeObjectURL(url);
}
return (
<div className="space-y-4">
<form onSubmit={handleSubmit} className="space-y-3">
<div>
<label className="mb-1 block text-xs font-medium text-neutral-500">
Dokumenttyp
</label>
<select
value={templateType}
onChange={(e) => setTemplateType(e.target.value)}
className={inputClass}
disabled={mutation.isPending}
>
<option value="">Dokumenttyp waehlen...</option>
{Object.entries(TEMPLATES).map(([key, label]) => (
<option key={key} value={key}>
{label}
</option>
))}
</select>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-neutral-500">
Sprache
</label>
<select
value={language}
onChange={(e) => setLanguage(e.target.value)}
className={inputClass}
disabled={mutation.isPending}
>
{LANGUAGES.map((lang) => (
<option key={lang.value} value={lang.value}>
{lang.label}
</option>
))}
</select>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-neutral-500">
Anweisungen (optional)
</label>
<textarea
value={instructions}
onChange={(e) => setInstructions(e.target.value)}
placeholder="z.B. 'Fokus auf Patentanspruch 1, besonders die technischen Merkmale...'"
rows={3}
className={inputClass}
disabled={mutation.isPending}
/>
</div>
<button
type="submit"
disabled={!templateType || mutation.isPending}
className="inline-flex items-center gap-2 rounded-md bg-neutral-900 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-neutral-800 disabled:opacity-50"
>
{mutation.isPending ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Dokument wird erstellt...
</>
) : (
<>
<FileText className="h-4 w-4" />
KI-Entwurf erstellen
</>
)}
</button>
</form>
{mutation.isError && (
<div className="rounded-md border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
Fehler beim Erstellen des Entwurfs. Bitte versuchen Sie es erneut.
</div>
)}
{mutation.data && (
<div className="space-y-3">
<div className="flex items-center justify-between">
<h4 className="text-sm font-medium text-neutral-900">
{mutation.data.title}
</h4>
<div className="flex items-center gap-2">
<button
onClick={handleCopy}
className="inline-flex items-center gap-1 rounded-md border border-neutral-200 px-2.5 py-1.5 text-xs font-medium text-neutral-600 transition-colors hover:bg-neutral-50"
>
{copied ? (
<>
<Check className="h-3.5 w-3.5 text-emerald-500" />
Kopiert
</>
) : (
<>
<Copy className="h-3.5 w-3.5" />
Kopieren
</>
)}
</button>
<button
onClick={handleDownload}
className="inline-flex items-center gap-1 rounded-md border border-neutral-200 px-2.5 py-1.5 text-xs font-medium text-neutral-600 transition-colors hover:bg-neutral-50"
>
<Download className="h-3.5 w-3.5" />
Download
</button>
</div>
</div>
<pre className="max-h-[600px] overflow-auto whitespace-pre-wrap rounded-md border border-neutral-200 bg-neutral-50 p-4 text-sm text-neutral-800">
{mutation.data.content}
</pre>
</div>
)}
</div>
);
}

View File

@@ -1,183 +0,0 @@
"use client";
import { useState } from "react";
import { useMutation } from "@tanstack/react-query";
import { api } from "@/lib/api";
import type { SimilarCasesResponse } from "@/lib/types";
import {
Loader2,
Search,
ExternalLink,
AlertTriangle,
Scale,
RefreshCw,
} from "lucide-react";
interface SimilarCaseFinderProps {
caseId: string;
}
const inputClass =
"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";
function RelevanceBadge({ score }: { score: number }) {
const pct = Math.round(score * 100);
let color = "bg-neutral-100 text-neutral-600";
if (pct >= 80) color = "bg-emerald-50 text-emerald-700";
else if (pct >= 60) color = "bg-blue-50 text-blue-700";
else if (pct >= 40) color = "bg-amber-50 text-amber-700";
return (
<span
className={`inline-block shrink-0 rounded-full px-2 py-0.5 text-xs font-medium ${color}`}
>
{pct}%
</span>
);
}
export function SimilarCaseFinder({ caseId }: SimilarCaseFinderProps) {
const [description, setDescription] = useState("");
const mutation = useMutation({
mutationFn: (req: { case_id: string; description: string }) =>
api.post<SimilarCasesResponse>("/ai/similar-cases", req),
});
function handleSearch(e?: React.FormEvent) {
e?.preventDefault();
mutation.mutate({ case_id: caseId, description });
}
return (
<div className="space-y-4">
<form onSubmit={handleSearch} className="space-y-3">
<div>
<label className="mb-1 block text-xs font-medium text-neutral-500">
Zusaetzliche Beschreibung (optional)
</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="z.B. 'SEP-Lizenzierung im Mobilfunkbereich, FRAND-Verteidigung...'"
rows={2}
className={inputClass}
disabled={mutation.isPending}
/>
</div>
<button
type="submit"
disabled={mutation.isPending}
className="inline-flex items-center gap-2 rounded-md bg-neutral-900 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-neutral-800 disabled:opacity-50"
>
{mutation.isPending ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Suche laeuft...
</>
) : (
<>
<Search className="h-4 w-4" />
Aehnliche Faelle suchen
</>
)}
</button>
</form>
{mutation.isError && (
<div className="flex flex-col items-center gap-3 py-6 text-center">
<div className="rounded-xl bg-red-50 p-3">
<AlertTriangle className="h-6 w-6 text-red-500" />
</div>
<p className="text-sm text-neutral-900">Suche fehlgeschlagen</p>
<p className="text-xs text-neutral-500">
Die youpc.org-Datenbank ist moeglicherweise nicht verfuegbar.
</p>
<button
onClick={() => handleSearch()}
className="inline-flex items-center gap-1 text-sm text-neutral-500 transition-colors hover:text-neutral-700"
>
<RefreshCw className="h-3.5 w-3.5" />
Erneut versuchen
</button>
</div>
)}
{mutation.data && (
<div className="space-y-3">
<div className="flex items-center justify-between">
<p className="text-xs text-neutral-500">
{mutation.data.count} aehnliche{" "}
{mutation.data.count === 1 ? "Fall" : "Faelle"} gefunden
</p>
<button
onClick={() => handleSearch()}
disabled={mutation.isPending}
className="inline-flex items-center gap-1 rounded-md border border-neutral-200 px-2.5 py-1.5 text-xs font-medium text-neutral-600 transition-colors hover:bg-neutral-50"
>
<RefreshCw className="h-3.5 w-3.5" />
Aktualisieren
</button>
</div>
{mutation.data.cases?.length === 0 && (
<div className="flex flex-col items-center gap-2 py-6 text-center">
<Scale className="h-6 w-6 text-neutral-300" />
<p className="text-sm text-neutral-500">
Keine aehnlichen UPC-Faelle gefunden.
</p>
</div>
)}
{mutation.data.cases?.map((c, i) => (
<div
key={i}
className="rounded-md border border-neutral-200 bg-white px-4 py-3"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<RelevanceBadge score={c.relevance} />
<span className="text-xs font-medium text-neutral-400">
{c.case_number}
</span>
{c.url && (
<a
href={c.url}
target="_blank"
rel="noopener noreferrer"
className="text-neutral-400 transition-colors hover:text-neutral-600"
>
<ExternalLink className="h-3.5 w-3.5" />
</a>
)}
</div>
<p className="mt-1 text-sm font-medium text-neutral-900">
{c.title}
</p>
<div className="mt-1 flex flex-wrap gap-x-3 text-xs text-neutral-400">
{c.court && <span>{c.court}</span>}
{c.date && <span>{c.date}</span>}
</div>
</div>
</div>
<p className="mt-2 text-sm text-neutral-600">{c.explanation}</p>
{c.key_holdings && (
<div className="mt-2 rounded border border-neutral-100 bg-neutral-50 px-3 py-2">
<p className="text-xs font-medium text-neutral-500">
Relevante Entscheidungsgruende
</p>
<p className="mt-0.5 text-xs text-neutral-600">
{c.key_holdings}
</p>
</div>
)}
</div>
))}
</div>
)}
</div>
);
}

View File

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

View File

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

View File

@@ -223,6 +223,82 @@ export const CASE_ASSIGNMENT_ROLE_LABELS: Record<CaseAssignmentRole, string> = {
viewer: "Einsicht",
};
// Document Templates
export interface DocumentTemplate {
id: string;
tenant_id?: string;
name: string;
description?: string;
category: "schriftsatz" | "vertrag" | "korrespondenz" | "intern";
content: string;
variables: string[];
is_system: boolean;
created_at: string;
updated_at: string;
}
export const TEMPLATE_CATEGORY_LABELS: Record<string, string> = {
schriftsatz: "Schriftsatz",
vertrag: "Vertrag",
korrespondenz: "Korrespondenz",
intern: "Intern",
};
export interface RenderResponse {
content: string;
template_id: string;
name: string;
}
// Notifications
export interface Notification {
id: string;
tenant_id: string;
user_id: string;
type: string;
title: string;
message: string;
body?: string;
entity_type?: string;
entity_id?: string;
read: boolean;
read_at?: string;
created_at: string;
}
export interface NotificationListResponse {
data: Notification[];
notifications: Notification[];
total: number;
unread_count: number;
}
export interface NotificationPreferences {
deadline_reminder_days: number[];
email_enabled: boolean;
daily_digest: boolean;
}
// Audit Log
export interface AuditLogEntry {
id: string;
tenant_id: string;
user_id?: string;
action: string;
entity_type: string;
entity_id?: string;
old_values?: Record<string, unknown>;
new_values?: Record<string, unknown>;
ip_address?: string;
user_agent?: string;
created_at: string;
}
export interface AuditLogResponse {
entries: AuditLogEntry[];
total: number;
}
export interface ApiError {
error: string;
status: number;
@@ -329,81 +405,3 @@ export interface ExtractionResponse {
deadlines: ExtractedDeadline[];
count: number;
}
// AI Document Drafting
export interface DocumentDraft {
title: string;
content: string;
language: string;
}
export interface DraftDocumentRequest {
case_id: string;
template_type: string;
instructions: string;
language: string;
}
export const TEMPLATE_TYPES: Record<string, string> = {
klageschrift: "Klageschrift",
klageerwiderung: "Klageerwiderung",
abmahnung: "Abmahnung",
schriftsatz: "Schriftsatz",
berufung: "Berufungsschrift",
antrag: "Antrag",
stellungnahme: "Stellungnahme",
gutachten: "Gutachten",
vertrag: "Vertrag",
vollmacht: "Vollmacht",
upc_claim: "UPC Statement of Claim",
upc_defence: "UPC Statement of Defence",
upc_counterclaim: "UPC Counterclaim for Revocation",
upc_injunction: "UPC Provisional Measures",
};
// AI Case Strategy
export interface StrategyStep {
priority: "high" | "medium" | "low";
action: string;
reasoning: string;
deadline?: string;
}
export interface RiskItem {
level: "high" | "medium" | "low";
risk: string;
mitigation: string;
}
export interface TimelineItem {
date: string;
event: string;
importance: "critical" | "important" | "routine";
}
export interface StrategyRecommendation {
summary: string;
next_steps: StrategyStep[];
risk_assessment: RiskItem[];
timeline: TimelineItem[];
}
// AI Similar Case Finder
export interface SimilarCase {
case_number: string;
title: string;
court: string;
date: string;
relevance: number;
explanation: string;
key_holdings: string;
url?: string;
}
export interface SimilarCasesResponse {
cases: SimilarCase[];
count: number;
}