Git-Repository für bestehenden archivdms-Code initialisiert, Branch-/Commit-Konvention (feature/<ticket>-<slug>-Branches, Ticket-Prefix in Commit-Nachricht) etabliert.
307 lines
11 KiB
Go
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
|
|
}
|