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 }