diff --git a/backend/internal/auth/context.go b/backend/internal/auth/context.go index c2aeaef..35bfa8c 100644 --- a/backend/internal/auth/context.go +++ b/backend/internal/auth/context.go @@ -9,19 +9,11 @@ import ( type contextKey string const ( -<<<<<<< HEAD userIDKey contextKey = "user_id" tenantIDKey contextKey = "tenant_id" ipKey contextKey = "ip_address" userAgentKey contextKey = "user_agent" -||||||| 82878df - userIDKey contextKey = "user_id" - tenantIDKey contextKey = "tenant_id" -======= - userIDKey contextKey = "user_id" - tenantIDKey contextKey = "tenant_id" - userRoleKey contextKey = "user_role" ->>>>>>> mai/pike/p0-role-based + userRoleKey contextKey = "user_role" ) func ContextWithUserID(ctx context.Context, userID uuid.UUID) context.Context { @@ -41,7 +33,6 @@ func TenantFromContext(ctx context.Context) (uuid.UUID, bool) { id, ok := ctx.Value(tenantIDKey).(uuid.UUID) return id, ok } -<<<<<<< HEAD func ContextWithRequestInfo(ctx context.Context, ip, userAgent string) context.Context { ctx = context.WithValue(ctx, ipKey, ip) @@ -62,8 +53,6 @@ func UserAgentFromContext(ctx context.Context) *string { } return nil } -||||||| 82878df -======= func ContextWithUserRole(ctx context.Context, role string) context.Context { return context.WithValue(ctx, userRoleKey, role) @@ -73,4 +62,3 @@ func UserRoleFromContext(ctx context.Context) string { role, _ := ctx.Value(userRoleKey).(string) return role } ->>>>>>> mai/pike/p0-role-based diff --git a/backend/internal/auth/middleware.go b/backend/internal/auth/middleware.go index 02428c6..fa25c98 100644 --- a/backend/internal/auth/middleware.go +++ b/backend/internal/auth/middleware.go @@ -35,36 +35,6 @@ func (m *Middleware) RequireAuth(next http.Handler) http.Handler { } ctx := ContextWithUserID(r.Context(), userID) -<<<<<<< HEAD - // Tenant resolution is handled by TenantResolver middleware for scoped routes. - // Tenant management routes handle their own access control. -||||||| 82878df - - // Resolve tenant and role from user_tenants - var membership struct { - TenantID uuid.UUID `db:"tenant_id"` - Role string `db:"role"` - } - err = m.db.GetContext(r.Context(), &membership, - "SELECT tenant_id, role FROM user_tenants WHERE user_id = $1 LIMIT 1", userID) - if err != nil { - http.Error(w, "no tenant found for user", http.StatusForbidden) - return - } - ctx = ContextWithTenantID(ctx, membership.TenantID) - ctx = ContextWithUserRole(ctx, membership.Role) - -======= - - // Resolve tenant from user_tenants - var tenantID uuid.UUID - err = m.db.GetContext(r.Context(), &tenantID, - "SELECT tenant_id FROM user_tenants WHERE user_id = $1 LIMIT 1", userID) - if err != nil { - http.Error(w, "no tenant found for user", http.StatusForbidden) - return - } - ctx = ContextWithTenantID(ctx, tenantID) // Capture IP and user-agent for audit logging ip := r.Header.Get("X-Forwarded-For") @@ -73,7 +43,9 @@ func (m *Middleware) RequireAuth(next http.Handler) http.Handler { } ctx = ContextWithRequestInfo(ctx, ip, r.UserAgent()) ->>>>>>> mai/knuth/p0-audit-trail-append + // Tenant resolution is handled by TenantResolver middleware for scoped routes. + // Tenant management routes handle their own access control. + next.ServeHTTP(w, r.WithContext(ctx)) }) } diff --git a/backend/internal/auth/tenant_resolver.go b/backend/internal/auth/tenant_resolver.go index 0dab57f..9d97d6e 100644 --- a/backend/internal/auth/tenant_resolver.go +++ b/backend/internal/auth/tenant_resolver.go @@ -12,12 +12,8 @@ import ( // Defined as an interface to avoid circular dependency with services. type TenantLookup interface { FirstTenantForUser(ctx context.Context, userID uuid.UUID) (*uuid.UUID, error) -<<<<<<< HEAD VerifyAccess(ctx context.Context, userID, tenantID uuid.UUID) (bool, error) -||||||| 82878df -======= GetUserRole(ctx context.Context, userID, tenantID uuid.UUID) (string, error) ->>>>>>> mai/pike/p0-role-based } // TenantResolver is middleware that resolves the tenant from X-Tenant-ID header @@ -39,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) @@ -46,38 +43,23 @@ func (tr *TenantResolver) Resolve(next http.Handler) http.Handler { http.Error(w, `{"error":"invalid X-Tenant-ID"}`, http.StatusBadRequest) return } -<<<<<<< HEAD - // Verify user has access to this tenant - hasAccess, err := tr.lookup.VerifyAccess(r.Context(), userID, parsed) + // Verify user has access and get their role + role, err := tr.lookup.GetUserRole(r.Context(), userID, parsed) if err != nil { slog.Error("tenant access check failed", "error", err, "user_id", userID, "tenant_id", parsed) http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError) return } - if !hasAccess { + if role == "" { http.Error(w, `{"error":"no access to tenant"}`, http.StatusForbidden) return } -||||||| 82878df -======= - // Verify user has access and get their role - role, err := tr.lookup.GetUserRole(r.Context(), userID, parsed) - if err != nil { - http.Error(w, "error checking tenant access", http.StatusInternalServerError) - return - } - if role == "" { - http.Error(w, "no access to this tenant", http.StatusForbidden) - return - } ->>>>>>> mai/pike/p0-role-based tenantID = parsed - // Override the role from middleware with the correct one for this tenant - r = r.WithContext(ContextWithUserRole(r.Context(), role)) + ctx = ContextWithUserRole(ctx, role) } else { - // Default to user's first tenant (role already set by middleware) + // Default to user's first tenant first, err := tr.lookup.FirstTenantForUser(r.Context(), userID) if err != nil { slog.Error("failed to resolve default tenant", "error", err, "user_id", userID) @@ -89,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)) }) } diff --git a/backend/internal/auth/tenant_resolver_test.go b/backend/internal/auth/tenant_resolver_test.go index d0300c2..ce95676 100644 --- a/backend/internal/auth/tenant_resolver_test.go +++ b/backend/internal/auth/tenant_resolver_test.go @@ -10,49 +10,34 @@ import ( ) type mockTenantLookup struct { -<<<<<<< HEAD tenantID *uuid.UUID err error hasAccess bool accessErr error -||||||| 82878df - tenantID *uuid.UUID - err error -======= - tenantID *uuid.UUID - role string - err error ->>>>>>> mai/pike/p0-role-based + role string } func (m *mockTenantLookup) FirstTenantForUser(ctx context.Context, userID uuid.UUID) (*uuid.UUID, error) { return m.tenantID, m.err } -<<<<<<< HEAD func (m *mockTenantLookup) VerifyAccess(ctx context.Context, userID, tenantID uuid.UUID) (bool, error) { return m.hasAccess, m.accessErr } -||||||| 82878df -======= func (m *mockTenantLookup) GetUserRole(ctx context.Context, userID, tenantID uuid.UUID) (string, error) { if m.role != "" { return m.role, m.err } - return "associate", m.err + if m.hasAccess { + return "associate", m.err + } + return "", m.err } ->>>>>>> mai/pike/p0-role-based func TestTenantResolver_FromHeader(t *testing.T) { tenantID := uuid.New() -<<<<<<< HEAD - tr := NewTenantResolver(&mockTenantLookup{hasAccess: true}) -||||||| 82878df - tr := NewTenantResolver(&mockTenantLookup{}) -======= - tr := NewTenantResolver(&mockTenantLookup{role: "partner"}) ->>>>>>> mai/pike/p0-role-based + tr := NewTenantResolver(&mockTenantLookup{hasAccess: true, role: "partner"}) var gotTenantID uuid.UUID next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -101,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) { diff --git a/backend/internal/handlers/deadlines.go b/backend/internal/handlers/deadlines.go index e1a2a39..1ff7629 100644 --- a/backend/internal/handlers/deadlines.go +++ b/backend/internal/handlers/deadlines.go @@ -198,18 +198,8 @@ func (h *DeadlineHandlers) Delete(w http.ResponseWriter, r *http.Request) { return } -<<<<<<< HEAD - if err := h.deadlines.Delete(tenantID, deadlineID); err != nil { + if err := h.deadlines.Delete(r.Context(), tenantID, deadlineID); err != nil { writeError(w, http.StatusNotFound, "deadline not found") -||||||| 82878df - err = h.deadlines.Delete(tenantID, deadlineID) - if err != nil { - writeError(w, http.StatusNotFound, err.Error()) -======= - err = h.deadlines.Delete(r.Context(), tenantID, deadlineID) - if err != nil { - writeError(w, http.StatusNotFound, err.Error()) ->>>>>>> mai/knuth/p0-audit-trail-append return } diff --git a/backend/internal/handlers/templates.go b/backend/internal/handlers/templates.go new file mode 100644 index 0000000..3be0062 --- /dev/null +++ b/backend/internal/handlers/templates.go @@ -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, + }) +} diff --git a/backend/internal/models/document_template.go b/backend/internal/models/document_template.go new file mode 100644 index 0000000..d80809d --- /dev/null +++ b/backend/internal/models/document_template.go @@ -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"` +} diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 84b7f8e..256ac58 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -29,14 +29,9 @@ func New(db *sqlx.DB, authMW *auth.Middleware, cfg *config.Config, calDAVSvc *se deadlineRuleSvc := services.NewDeadlineRuleService(db) calculator := services.NewDeadlineCalculator(holidaySvc) storageCli := services.NewStorageClient(cfg.SupabaseURL, cfg.SupabaseServiceKey) -<<<<<<< HEAD documentSvc := services.NewDocumentService(db, storageCli, auditSvc) -||||||| 82878df - documentSvc := services.NewDocumentService(db, storageCli) -======= - documentSvc := services.NewDocumentService(db, storageCli) assignmentSvc := services.NewCaseAssignmentService(db) ->>>>>>> mai/pike/p0-role-based + templateSvc := services.NewTemplateService(db, auditSvc) // AI service (optional — only if API key is configured) var aiH *handlers.AIHandler @@ -71,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)) @@ -112,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 @@ -138,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)) @@ -162,21 +158,23 @@ func New(db *sqlx.DB, authMW *auth.Middleware, cfg *config.Config, calDAVSvc *se // Dashboard — all can view scoped.HandleFunc("GET /api/dashboard", dashboardH.Get) -<<<<<<< HEAD // Audit log scoped.HandleFunc("GET /api/audit-log", auditH.List) - // Documents -||||||| 82878df - // Documents -======= - // Documents — all can upload, delete checked in handler (own vs all) ->>>>>>> mai/pike/p0-role-based + // 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 { @@ -185,7 +183,6 @@ func New(db *sqlx.DB, authMW *auth.Middleware, cfg *config.Config, calDAVSvc *se scoped.HandleFunc("POST /api/ai/summarize-case", perm(auth.PermAIExtraction, aiLimiter.LimitFunc(aiH.SummarizeCase))) } -<<<<<<< HEAD // Notifications if notifH != nil { scoped.HandleFunc("GET /api/notifications", notifH.List) @@ -197,11 +194,6 @@ func New(db *sqlx.DB, authMW *auth.Middleware, cfg *config.Config, calDAVSvc *se } // CalDAV sync endpoints -||||||| 82878df - // CalDAV sync endpoints -======= - // CalDAV sync endpoints — settings permission required ->>>>>>> mai/pike/p0-role-based if calDAVSvc != nil { calDAVH := handlers.NewCalDAVHandler(calDAVSvc) scoped.HandleFunc("POST /api/caldav/sync", perm(auth.PermManageSettings, calDAVH.TriggerSync)) diff --git a/backend/internal/services/template_service.go b/backend/internal/services/template_service.go new file mode 100644 index 0000000..c66b86a --- /dev/null +++ b/backend/internal/services/template_service.go @@ -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 "" +} diff --git a/frontend/src/app/(app)/cases/[id]/layout.tsx b/frontend/src/app/(app)/cases/[id]/layout.tsx index 857f570..1c816a1 100644 --- a/frontend/src/app/(app)/cases/[id]/layout.tsx +++ b/frontend/src/app/(app)/cases/[id]/layout.tsx @@ -17,6 +17,7 @@ import { StickyNote, AlertTriangle, ScrollText, + FilePlus, } from "lucide-react"; import { format } from "date-fns"; import { de } from "date-fns/locale"; @@ -171,19 +172,28 @@ export default function CaseDetailLayout({ {caseDetail.court_ref && ({caseDetail.court_ref})} -
- Erstellt:{" "} - {format(new Date(caseDetail.created_at), "d. MMM yyyy", { - locale: de, - })} -
-- Aktualisiert:{" "} - {format(new Date(caseDetail.updated_at), "d. MMM yyyy", { - locale: de, - })} -
++ Erstellt:{" "} + {format(new Date(caseDetail.created_at), "d. MMM yyyy", { + locale: de, + })} +
++ Aktualisiert:{" "} + {format(new Date(caseDetail.updated_at), "d. MMM yyyy", { + locale: de, + })} +
+
+ {`{{${v}}}`}
+
+ ))}
+ + Vorlage “{template.name}” mit Falldaten befüllen +
+ + {/* Step 1: Select case */} ++ Dokumentvorlagen mit automatischer Befüllung +
+Keine Vorlagen gefunden
++ {t.description} +
+ )} +