- 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
329 lines
8.0 KiB
Go
329 lines
8.0 KiB
Go
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,
|
|
})
|
|
}
|