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 "" }