diff --git a/.m/config.yaml b/.m/config.yaml index 3d11d2b..61c582f 100644 --- a/.m/config.yaml +++ b/.m/config.yaml @@ -52,7 +52,7 @@ head: infinity_mode: false capacity: global: - max_workers: 5 + max_workers: 6 max_heads: 3 per_worker: max_tasks_lifetime: 0 diff --git a/backend/internal/auth/context.go b/backend/internal/auth/context.go index 9553104..333190c 100644 --- a/backend/internal/auth/context.go +++ b/backend/internal/auth/context.go @@ -14,6 +14,19 @@ const ( userRoleKey contextKey = "user_role" ipKey contextKey = "ip_address" userAgentKey contextKey = "user_agent" +<<<<<<< HEAD +||||||| 8e65463 +||||||| 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" +>>>>>>> mai/ritchie/p1-document-templates ) func ContextWithUserID(ctx context.Context, userID uuid.UUID) context.Context { @@ -33,6 +46,7 @@ func TenantFromContext(ctx context.Context) (uuid.UUID, bool) { id, ok := ctx.Value(tenantIDKey).(uuid.UUID) return id, ok } +<<<<<<< HEAD func ContextWithUserRole(ctx context.Context, role string) context.Context { return context.WithValue(ctx, userRoleKey, role) @@ -42,6 +56,10 @@ func UserRoleFromContext(ctx context.Context) string { role, _ := ctx.Value(userRoleKey).(string) return role } +||||||| 8e65463 +<<<<<<< HEAD +======= +>>>>>>> mai/ritchie/p1-document-templates func ContextWithRequestInfo(ctx context.Context, ip, userAgent string) context.Context { ctx = context.WithValue(ctx, ipKey, ip) @@ -62,3 +80,28 @@ func UserAgentFromContext(ctx context.Context) *string { } return nil } +<<<<<<< HEAD +||||||| 8e65463 +||||||| 82878df +======= + +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 +} +>>>>>>> mai/pike/p0-role-based +======= + +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 +} +>>>>>>> mai/ritchie/p1-document-templates diff --git a/backend/internal/auth/tenant_resolver.go b/backend/internal/auth/tenant_resolver.go index 9cb1a54..f85eecd 100644 --- a/backend/internal/auth/tenant_resolver.go +++ b/backend/internal/auth/tenant_resolver.go @@ -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,7 +57,14 @@ func (tr *TenantResolver) Resolve(next http.Handler) http.Handler { } tenantID = parsed +<<<<<<< HEAD r = r.WithContext(ContextWithUserRole(r.Context(), role)) +||||||| 8e65463 + // Override the role from middleware with the correct one for this tenant + r = r.WithContext(ContextWithUserRole(r.Context(), role)) +======= + ctx = ContextWithUserRole(ctx, role) +>>>>>>> mai/ritchie/p1-document-templates } else { // Default to user's first tenant first, err := tr.lookup.FirstTenantForUser(r.Context(), userID) @@ -70,6 +78,7 @@ func (tr *TenantResolver) Resolve(next http.Handler) http.Handler { return } tenantID = *first +<<<<<<< HEAD // Also resolve role for default tenant role, err := tr.lookup.GetUserRole(r.Context(), userID, tenantID) @@ -79,9 +88,21 @@ func (tr *TenantResolver) Resolve(next http.Handler) http.Handler { return } r = r.WithContext(ContextWithUserRole(r.Context(), role)) +||||||| 8e65463 +======= + + // 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) +>>>>>>> mai/ritchie/p1-document-templates } - 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 2b09ac8..af7149c 100644 --- a/backend/internal/auth/tenant_resolver_test.go +++ b/backend/internal/auth/tenant_resolver_test.go @@ -86,7 +86,13 @@ func TestTenantResolver_FromHeader_NoAccess(t *testing.T) { func TestTenantResolver_DefaultsToFirst(t *testing.T) { tenantID := uuid.New() +<<<<<<< HEAD tr := NewTenantResolver(&mockTenantLookup{tenantID: &tenantID, role: "owner"}) +||||||| 8e65463 + tr := NewTenantResolver(&mockTenantLookup{tenantID: &tenantID}) +======= + tr := NewTenantResolver(&mockTenantLookup{tenantID: &tenantID, role: "associate"}) +>>>>>>> mai/ritchie/p1-document-templates 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 26bd5ae..63c48d4 100644 --- a/backend/internal/handlers/deadlines.go +++ b/backend/internal/handlers/deadlines.go @@ -198,9 +198,27 @@ func (h *DeadlineHandlers) Delete(w http.ResponseWriter, r *http.Request) { return } +<<<<<<< HEAD err = h.deadlines.Delete(r.Context(), tenantID, deadlineID) if err != nil { writeError(w, http.StatusNotFound, err.Error()) +||||||| 8e65463 +<<<<<<< HEAD + if err := h.deadlines.Delete(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 +======= + if err := h.deadlines.Delete(r.Context(), tenantID, deadlineID); err != nil { + writeError(w, http.StatusNotFound, "deadline not found") +>>>>>>> mai/ritchie/p1-document-templates 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 8273acd..250ef8d 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -31,9 +31,15 @@ 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) +<<<<<<< HEAD timeEntrySvc := services.NewTimeEntryService(db, auditSvc) billingRateSvc := services.NewBillingRateService(db, auditSvc) invoiceSvc := services.NewInvoiceService(db, auditSvc) +||||||| 8e65463 +>>>>>>> mai/pike/p0-role-based +======= + templateSvc := services.NewTemplateService(db, auditSvc) +>>>>>>> mai/ritchie/p1-document-templates // AI service (optional — only if API key is configured) var aiH *handlers.AIHandler @@ -68,9 +74,14 @@ 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) +<<<<<<< HEAD timeEntryH := handlers.NewTimeEntryHandler(timeEntrySvc) billingRateH := handlers.NewBillingRateHandler(billingRateSvc) invoiceH := handlers.NewInvoiceHandler(invoiceSvc) +||||||| 8e65463 +======= + templateH := handlers.NewTemplateHandler(templateSvc, caseSvc, partySvc, deadlineSvc, tenantSvc) +>>>>>>> mai/ritchie/p1-document-templates // Public routes mux.HandleFunc("GET /health", handleHealth(db)) @@ -138,7 +149,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,15 +173,49 @@ 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 — view requires PermViewAuditLog scoped.HandleFunc("GET /api/audit-log", perm(auth.PermViewAuditLog, auditH.List)) +||||||| 8e65463 +<<<<<<< HEAD + // Audit log + scoped.HandleFunc("GET /api/audit-log", auditH.List) +======= + // Audit log + scoped.HandleFunc("GET /api/audit-log", auditH.List) +>>>>>>> mai/ritchie/p1-document-templates +<<<<<<< HEAD // Documents — all can upload, delete checked in handler (own vs all) +||||||| 8e65463 + // 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 +>>>>>>> mai/ritchie/p1-document-templates 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) +<<<<<<< HEAD scoped.HandleFunc("DELETE /api/documents/{docId}", docH.Delete) +||||||| 8e65463 + 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) +>>>>>>> mai/ritchie/p1-document-templates // AI endpoints (rate limited: 5 req/min burst 10 per IP) if aiH != nil { @@ -189,7 +234,18 @@ func New(db *sqlx.DB, authMW *auth.Middleware, cfg *config.Config, calDAVSvc *se scoped.HandleFunc("PUT /api/notification-preferences", notifH.UpdatePreferences) } +<<<<<<< HEAD // CalDAV sync endpoints — settings permission required +||||||| 8e65463 + // CalDAV sync endpoints +||||||| 82878df + // CalDAV sync endpoints +======= + // CalDAV sync endpoints — settings permission required +>>>>>>> mai/pike/p0-role-based +======= + // CalDAV sync endpoints +>>>>>>> mai/ritchie/p1-document-templates 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 972c709..dfd5434 100644 --- a/frontend/src/app/(app)/cases/[id]/layout.tsx +++ b/frontend/src/app/(app)/cases/[id]/layout.tsx @@ -17,7 +17,12 @@ import { StickyNote, AlertTriangle, ScrollText, +<<<<<<< HEAD Timer, +||||||| 8e65463 +======= + FilePlus, +>>>>>>> mai/ritchie/p1-document-templates } from "lucide-react"; import { format } from "date-fns"; import { de } from "date-fns/locale"; @@ -174,19 +179,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} +
+ )} +