package storage import ( "bytes" "context" "errors" "fmt" "strings" "text/template" "time" "github.com/jackc/pgx/v5" ) // titleTemplateData is the data model exposed to a classification template's // (or the tenant-wide default's) title Go text/template. Fields are kept simple // (plain strings / times) so patterns stay readable, e.g. // // {{.Correspondent}} {{.DocumentType}} {{dateFormat "02.01.2006" .Belegdatum}} // // Belegdatum and UploadDate are passed as time.Time (zero value when unknown); // use the dateFormat template func to render them, which yields "" for a zero // time instead of Go's "0001-01-01..." default. type titleTemplateData struct { Correspondent string DocumentType string Belegdatum time.Time UploadDate time.Time Tags string OCRTitle string } // titleTemplateFuncs provides the custom template functions available inside a // title template. dateFormat takes a Go reference layout ("02.01.2006") and a // time.Time, returning "" for a zero time so an unknown Belegdatum does not // leak a placeholder date into the title. var titleTemplateFuncs = template.FuncMap{ "dateFormat": func(layout string, t time.Time) string { if t.IsZero() { return "" } return t.Format(layout) }, } // ValidateTitleTemplate parses (but does not execute) a title template pattern // so the API layer can reject a syntactically invalid pattern up front. An // empty pattern is valid (means "no template title"). Exported for the // settings / template CRUD handlers. func ValidateTitleTemplate(pattern string) error { if strings.TrimSpace(pattern) == "" { return nil } _, err := template.New("title").Option("missingkey=zero").Funcs(titleTemplateFuncs).Parse(pattern) if err != nil { return fmt.Errorf("invalid title template: %w", err) } return nil } // renderTitleTemplate parses and executes a title template against data. The // result is whitespace-trimmed. missingkey=zero guards against crashes when a // pattern references a field that does not exist. A parse/execute error or an // empty result is signalled to the caller so it can fall back (never an empty // title). func renderTitleTemplate(pattern string, data titleTemplateData) (string, error) { tmpl, err := template.New("title").Option("missingkey=zero").Funcs(titleTemplateFuncs).Parse(pattern) if err != nil { return "", fmt.Errorf("storage: parse title template: %w", err) } var buf bytes.Buffer if err := tmpl.Execute(&buf, data); err != nil { return "", fmt.Errorf("storage: execute title template: %w", err) } return strings.TrimSpace(buf.String()), nil } // tenantDefaultTitleTemplate reads the tenant-wide fallback title template // straight from the tenants table (same DB pool). Returns "" when unset // (NULL) so callers can treat "no default" uniformly. func (s *Store) tenantDefaultTitleTemplate(ctx context.Context, tenantID int64) (string, error) { var v *string err := s.db.QueryRow(ctx, `SELECT default_title_template FROM tenants WHERE id = $1`, tenantID).Scan(&v) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return "", nil } return "", fmt.Errorf("storage: read tenant default_title_template: %w", err) } if v == nil { return "", nil } return strings.TrimSpace(*v), nil } // taxonomyNameByID resolves a taxonomy entity's display name (tenant-scoped). // table must be a fixed internal literal ("correspondents" / "document_types"), // never user input. Returns "" (not an error) when the row does not exist so a // dangling reference cannot break title generation. func (s *Store) taxonomyNameByID(ctx context.Context, table string, id, tenantID int64) (string, error) { var name string err := s.db.QueryRow(ctx, fmt.Sprintf(`SELECT name FROM %s WHERE id = $1 AND tenant_id = $2`, table), id, tenantID).Scan(&name) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return "", nil } return "", fmt.Errorf("storage: lookup %s name: %w", table, err) } return name, nil } // applyTemplateTitle derives and persists a document title from a classification // template's title_template (or, if that is empty, the tenant-wide // default_title_template). Rules (see feature spec): // // - Never touches a manually renamed document (title_manually_set = true). // - Chooses the template's own pattern first, then the tenant default; if both // are empty, does nothing (existing title kept). // - On an empty or errored render result, falls back to keeping the existing // title — never sets an empty string. // - Persists via UpdateDocumentTitleAuto so title_manually_set stays false // (a later correction + re-apply must still work). // // Called at the end of ApplyTemplate, so it runs identically for the manual // endpoint and the workflow trigger (both funnel through ApplyTemplate). func (s *Store) applyTemplateTitle(ctx context.Context, documentID, tenantID int64, tmpl *ClassificationTemplate) error { doc, err := s.GetDocument(ctx, documentID, tenantID) if err != nil { return err } if doc.TitleManuallySet { return nil } // Resolve the effective pattern: template-own first, then tenant default. pattern := "" if tmpl.TitleTemplate != nil { pattern = strings.TrimSpace(*tmpl.TitleTemplate) } if pattern == "" { def, err := s.tenantDefaultTitleTemplate(ctx, tenantID) if err != nil { return err } pattern = def } if pattern == "" { return nil // no template title configured at either level } // Build the render data. Name lookups are best-effort (missing rows -> ""). data := titleTemplateData{ OCRTitle: doc.Title, UploadDate: doc.CreatedAt, } if doc.DocumentDate != nil { data.Belegdatum = *doc.DocumentDate } if doc.CorrespondentID != nil { name, err := s.taxonomyNameByID(ctx, "correspondents", *doc.CorrespondentID, tenantID) if err != nil { return err } data.Correspondent = name } // Document type: prefer the template's target type (what is being applied), // falling back to the document's current type. docTypeID := tmpl.DocTypeID if docTypeID == nil { docTypeID = doc.DocTypeID } if docTypeID != nil { name, err := s.taxonomyNameByID(ctx, "document_types", *docTypeID, tenantID) if err != nil { return err } data.DocumentType = name } tags, err := s.ListDocumentTags(ctx, documentID, tenantID) if err != nil { return err } names := make([]string, 0, len(tags)) for _, t := range tags { names = append(names, t.Name) } data.Tags = strings.Join(names, ", ") rendered, err := renderTitleTemplate(pattern, data) if err != nil || rendered == "" { // Fallback: keep the existing (OCR-derived) title, never blank it. return nil //nolint:nilerr // intentional: a bad template must not fail the apply } if rendered == doc.Title { return nil } return s.UpdateDocumentTitleAuto(ctx, documentID, tenantID, rendered) }