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.
This commit is contained in:
@@ -0,0 +1,306 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user