Files
archivdms/internal/storage/classification_templates_apply.go
T
patrick 9a24ea29e1 FDN-01: repository & projektgerüst
Git-Repository für bestehenden archivdms-Code initialisiert, Branch-/Commit-Konvention (feature/<ticket>-<slug>-Branches, Ticket-Prefix in Commit-Nachricht) etabliert.
2026-08-11 21:27:53 +02:00

307 lines
11 KiB
Go

package storage
import (
"context"
"errors"
"fmt"
"strconv"
"time"
"github.com/jackc/pgx/v5"
)
// ApplyTemplateResult is the outcome (or dry-run preview) of applying a
// classification template to a document. It is produced identically by the
// preview and the commit path so there is no logic duplication between them.
type ApplyTemplateResult struct {
TemplateID int64 `json:"template_id"`
DryRun bool `json:"dry_run"`
TagsToAdd []TaxonomyEntity `json:"tags_to_add"`
TagsAlreadySet []TaxonomyEntity `json:"tags_already_set"`
FieldsToSet []FieldDefaultChange `json:"fields_to_set"`
FieldsSkipped []FieldDefaultChange `json:"fields_skipped"`
FieldsOverwritten []FieldDefaultChange `json:"fields_overwritten"`
RetainUntilBefore *time.Time `json:"retain_until_before,omitempty"`
RetainUntilAfter *time.Time `json:"retain_until_after,omitempty"`
RetainUntilBlocked bool `json:"retain_until_blocked"`
Applied bool `json:"applied"`
}
// FieldDefaultChange describes a single custom-field default's effect on a
// document (used in the to-set / skipped / overwritten buckets).
type FieldDefaultChange struct {
FieldID int64 `json:"field_id"`
Name string `json:"name"`
OldValue string `json:"old_value,omitempty"`
NewValue string `json:"new_value"`
}
// computeRetainUntil returns createdAt + retainYears years, or nil if
// retainYears is nil (template does not set a retention period).
func computeRetainUntil(createdAt time.Time, retainYears *int) *time.Time {
if retainYears == nil {
return nil
}
t := createdAt.AddDate(*retainYears, 0, 0)
return &t
}
// formatTemplateDefault renders a template field default's value as a string
// for FieldDefaultChange.NewValue.
func formatTemplateDefault(d TemplateFieldDefault) string {
return formatFieldValueParts(d.ValueText, d.ValueNumber, d.ValueDate, d.ValueBool)
}
// formatExistingValue renders a document's current field value as a string for
// FieldDefaultChange.OldValue.
func formatExistingValue(v DocumentFieldValue) string {
return formatFieldValueParts(v.ValueText, v.ValueNumber, v.ValueDate, v.ValueBool)
}
func formatFieldValueParts(text *string, number *float64, date *time.Time, bl *bool) string {
switch {
case text != nil:
return *text
case number != nil:
return strconv.FormatFloat(*number, 'f', -1, 64)
case date != nil:
return date.Format("2006-01-02")
case bl != nil:
return strconv.FormatBool(*bl)
default:
return ""
}
}
// PreviewApplyTemplate builds the full ApplyTemplateResult WITHOUT writing
// anything. It is both the dry-run response and the basis the commit path
// (ApplyTemplate) reuses. Field-default classification is done per-field on the
// template default's own `overwrite` flag: an already-set value goes to
// FieldsOverwritten when overwrite=true, otherwise to FieldsSkipped. The commit
// path additionally requires an explicit confirm before actually overwriting.
func (s *Store) PreviewApplyTemplate(ctx context.Context, documentID, templateID, tenantID int64) (*ApplyTemplateResult, error) {
// GetDocument wraps a missing/foreign-tenant row as pgx.ErrNoRows (it does
// not map to a sentinel itself) — translate it to ErrDocumentNotFound so the
// handler can return a clean 404.
doc, err := s.GetDocument(ctx, documentID, tenantID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrDocumentNotFound
}
return nil, err
}
tmpl, err := s.GetTemplate(ctx, templateID, tenantID)
if err != nil {
return nil, err
}
res := &ApplyTemplateResult{
TemplateID: templateID,
DryRun: true,
TagsToAdd: make([]TaxonomyEntity, 0),
TagsAlreadySet: make([]TaxonomyEntity, 0),
FieldsToSet: make([]FieldDefaultChange, 0),
FieldsSkipped: make([]FieldDefaultChange, 0),
FieldsOverwritten: make([]FieldDefaultChange, 0),
RetainUntilBefore: doc.RetainUntil,
}
// --- tags ---
existingTags, err := s.ListDocumentTags(ctx, documentID, tenantID)
if err != nil {
return nil, err
}
haveTag := make(map[int64]bool, len(existingTags))
for _, t := range existingTags {
haveTag[t.ID] = true
}
for _, t := range tmpl.Tags {
if haveTag[t.ID] {
res.TagsAlreadySet = append(res.TagsAlreadySet, t)
} else {
res.TagsToAdd = append(res.TagsToAdd, t)
}
}
// --- custom-field defaults ---
existingVals, err := s.ListDocumentFieldValues(ctx, documentID, tenantID)
if err != nil {
return nil, err
}
existingByID := make(map[int64]DocumentFieldValue, len(existingVals))
for _, v := range existingVals {
existingByID[v.FieldID] = v
}
for _, d := range tmpl.FieldDefaults {
change := FieldDefaultChange{FieldID: d.FieldID, Name: d.Name, NewValue: formatTemplateDefault(d)}
existing, has := existingByID[d.FieldID]
existingEmpty := !has || isEmptyResolved(existing.ValueText, existing.ValueNumber, existing.ValueDate, existing.ValueBool)
if existingEmpty {
res.FieldsToSet = append(res.FieldsToSet, change)
continue
}
change.OldValue = formatExistingValue(existing)
if d.Overwrite {
res.FieldsOverwritten = append(res.FieldsOverwritten, change)
} else {
res.FieldsSkipped = append(res.FieldsSkipped, change)
}
}
// --- retention (absolute rule: never shorten, not even with overwrite) ---
proposed := computeRetainUntil(doc.CreatedAt, tmpl.RetainYears)
if doc.RetainUntil != nil && (proposed == nil || proposed.Before(*doc.RetainUntil)) {
// A template must never shorten an existing retain_until.
res.RetainUntilBlocked = true
} else if proposed != nil {
res.RetainUntilAfter = proposed
}
return res, nil
}
// ApplyTemplate applies a classification template to a document. It first
// builds the plan via PreviewApplyTemplate, then commits it: attaches the
// missing tags, merges the field defaults into the document's existing values
// (never a full replace), and extends retain_until — but ONLY when it is not
// blocked (retention is never shortened, even with overwrite=true). The
// per-field overwrite of an already-set value additionally requires the global
// overwrite/confirm flag; otherwise those fields are demoted to FieldsSkipped.
// The returned result reflects what was actually done (Applied=true).
func (s *Store) ApplyTemplate(ctx context.Context, documentID, templateID, tenantID int64, overwrite bool) (*ApplyTemplateResult, error) {
res, err := s.PreviewApplyTemplate(ctx, documentID, templateID, tenantID)
if err != nil {
return nil, err
}
res.DryRun = false
// Without an explicit confirm, overwriting an already-set field is not
// performed — demote those to skipped so the caller sees they were kept.
if !overwrite && len(res.FieldsOverwritten) > 0 {
res.FieldsSkipped = append(res.FieldsSkipped, res.FieldsOverwritten...)
res.FieldsOverwritten = res.FieldsOverwritten[:0]
}
// --- attach missing tags (AttachTag runs RecomputeVisibility itself) ---
for _, t := range res.TagsToAdd {
if err := s.AttachTag(ctx, documentID, t.ID); err != nil {
return nil, err
}
}
// --- field defaults: merge with existing values, then set once ---
// The set of field_ids the template actually writes (to-set + confirmed
// overwrites). SetDocumentFieldValues is full-replace, so we start from the
// document's current values and overlay only the template's writes.
writeFields := make(map[int64]bool)
for _, c := range res.FieldsToSet {
writeFields[c.FieldID] = true
}
for _, c := range res.FieldsOverwritten {
writeFields[c.FieldID] = true
}
if len(writeFields) > 0 {
if err := s.applyTemplateFieldValues(ctx, documentID, templateID, tenantID, writeFields); err != nil {
return nil, err
}
}
// --- retention: extend only, never shorten ---
if !res.RetainUntilBlocked && res.RetainUntilAfter != nil {
if _, err := s.db.Exec(ctx, `UPDATE documents SET retain_until = $1, updated_at = now() WHERE id = $2 AND tenant_id = $3`,
*res.RetainUntilAfter, documentID, tenantID); err != nil {
return nil, fmt.Errorf("storage: apply template retain_until: %w", err)
}
}
// --- title: template-own title_template, else tenant default, else keep.
// Runs AFTER tags/fields so tag-based patterns see the freshly attached
// tags. Only touches non-manually-renamed documents; keeps
// title_manually_set = false. Identical for the manual endpoint and the
// workflow trigger (both call ApplyTemplate). ---
tmpl, err := s.GetTemplate(ctx, templateID, tenantID)
if err != nil {
return nil, err
}
if err := s.applyTemplateTitle(ctx, documentID, tenantID, tmpl); err != nil {
return nil, err
}
res.Applied = true
return res, nil
}
// applyTemplateFieldValues merges the template's writeFields defaults into the
// document's current custom-field values and persists the union via
// SetDocumentFieldValues (which is full-replace, hence the merge).
func (s *Store) applyTemplateFieldValues(ctx context.Context, documentID, templateID, tenantID int64, writeFields map[int64]bool) error {
existing, err := s.ListDocumentFieldValues(ctx, documentID, tenantID)
if err != nil {
return err
}
defaults, err := s.listTemplateFieldDefaults(ctx, templateID, tenantID)
if err != nil {
return err
}
defByID := make(map[int64]TemplateFieldDefault, len(defaults))
for _, d := range defaults {
defByID[d.FieldID] = d
}
inputs := make([]DocumentFieldValueInput, 0, len(existing)+len(writeFields))
seen := make(map[int64]bool)
// Keep existing values, overlaying template writes where applicable.
for _, v := range existing {
seen[v.FieldID] = true
if writeFields[v.FieldID] {
if d, ok := defByID[v.FieldID]; ok {
inputs = append(inputs, templateDefaultToInput(d))
continue
}
}
inputs = append(inputs, existingValueToInput(v))
}
// Template writes for fields the document did not have yet.
for fid := range writeFields {
if seen[fid] {
continue
}
if d, ok := defByID[fid]; ok {
inputs = append(inputs, templateDefaultToInput(d))
}
}
if _, err := s.SetDocumentFieldValues(ctx, documentID, tenantID, inputs); err != nil {
return err
}
return nil
}
func templateDefaultToInput(d TemplateFieldDefault) DocumentFieldValueInput {
in := DocumentFieldValueInput{
FieldID: d.FieldID,
ValueText: d.ValueText,
ValueNumber: d.ValueNumber,
ValueBool: d.ValueBool,
}
if d.ValueDate != nil {
s := d.ValueDate.Format("2006-01-02")
in.ValueDate = &s
}
return in
}
func existingValueToInput(v DocumentFieldValue) DocumentFieldValueInput {
in := DocumentFieldValueInput{
FieldID: v.FieldID,
ValueText: v.ValueText,
ValueNumber: v.ValueNumber,
ValueBool: v.ValueBool,
}
if v.ValueDate != nil {
s := v.ValueDate.Format("2006-01-02")
in.ValueDate = &s
}
return in
}