feat: document templates with auto-fill (P1)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
328
backend/internal/handlers/templates.go
Normal file
328
backend/internal/handlers/templates.go
Normal 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,
|
||||
})
|
||||
}
|
||||
21
backend/internal/models/document_template.go
Normal file
21
backend/internal/models/document_template.go
Normal 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"`
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
330
backend/internal/services/template_service.go
Normal file
330
backend/internal/services/template_service.go
Normal 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 ""
|
||||
}
|
||||
Reference in New Issue
Block a user