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:
2026-08-11 21:27:53 +02:00
parent 40ed80da71
commit 9a24ea29e1
274 changed files with 53708 additions and 0 deletions
@@ -0,0 +1,453 @@
package storage
import (
"context"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
)
// ErrClassificationTemplateNotFound is returned when a classification-template
// lookup, update or delete does not match any row owned by the caller's tenant.
var ErrClassificationTemplateNotFound = errors.New("storage: classification template not found or not owned by tenant")
// ErrDuplicateTemplateName is returned when a tenant already has a
// classification template with the same name (UNIQUE(tenant_id, name)).
var ErrDuplicateTemplateName = errors.New("storage: classification template with this name already exists for tenant")
// ClassificationTemplate is a tenant-scoped "Klassifizierungsvorlage": a named
// bundle of a document type, tags, custom-field default values and a retention
// period that can be applied to a document in one action. Deliberately NOT
// persistently coupled to any document (no template_id column on documents) so
// a later template edit can never retroactively change past documents — the
// application is recorded only via an audit-log entry (GoBD-Nachvollziehbarkeit).
type ClassificationTemplate struct {
ID int64 `json:"id"`
TenantID int64 `json:"tenant_id"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
DocTypeID *int64 `json:"doc_type_id,omitempty"`
RetainYears *int `json:"retain_years,omitempty"`
Active bool `json:"active"`
CreatedBy *int64 `json:"created_by,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Tags []TaxonomyEntity `json:"tags"`
FieldDefaults []TemplateFieldDefault `json:"field_defaults"`
// TitleTemplate is an optional Go text/template pattern used to derive the
// document title when this template is applied (see
// classification_templates_title.go). NULL/empty means "no template title"
// — the tenant-wide default_title_template is tried next, and if that is
// also empty the document's existing title is kept.
TitleTemplate *string `json:"title_template,omitempty"`
}
// TemplateFieldDefault is one resolved custom-field default of a template,
// joined to its field definition. Exactly one value column is expected to be
// populated (matching the field's type). overwrite carries the template's
// intent to overwrite an already-set document value (only honoured on an
// explicit confirmed apply — see ApplyTemplate).
type TemplateFieldDefault struct {
FieldID int64 `json:"field_id"`
Name string `json:"name"`
Label string `json:"label"`
FieldType string `json:"field_type"`
ValueText *string `json:"value_text,omitempty"`
ValueNumber *float64 `json:"value_number,omitempty"`
ValueDate *time.Time `json:"value_date,omitempty"`
ValueBool *bool `json:"value_bool,omitempty"`
Overwrite bool `json:"overwrite"`
}
// TemplateFieldDefaultInput is one supplied default in a bulk PUT. Exactly one
// of the value pointers is expected to be populated (matching the field type).
type TemplateFieldDefaultInput struct {
FieldID int64 `json:"field_id"`
ValueText *string `json:"value_text,omitempty"`
ValueNumber *float64 `json:"value_number,omitempty"`
ValueDate *string `json:"value_date,omitempty"` // ISO date "2006-01-02"
ValueBool *bool `json:"value_bool,omitempty"`
Overwrite bool `json:"overwrite"`
}
// CreateTemplateRequest holds create parameters for a classification template.
type CreateTemplateRequest struct {
Name string
Description string
DocTypeID *int64
RetainYears *int
Active bool
CreatedBy *int64
TitleTemplate *string
}
// UpdateTemplateRequest holds update parameters for a classification template.
type UpdateTemplateRequest struct {
Name string
Description string
DocTypeID *int64
RetainYears *int
Active bool
TitleTemplate *string
}
// initClassificationTemplatesSchema creates the classification_templates /
// classification_template_tags / classification_template_field_defaults tables.
// Idempotent, called from (*Store).initSchema AFTER initTaxonomySchema and
// initCustomFieldsSchema (FK dependency on document_types / custom_field_defs).
// Documented (not executed) in migrations/011_classification_templates.sql.
func (s *Store) initClassificationTemplatesSchema(ctx context.Context) error {
_, err := s.db.Exec(ctx, `
CREATE TABLE IF NOT EXISTS classification_templates (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL,
name TEXT NOT NULL,
description TEXT,
doc_type_id BIGINT REFERENCES document_types(id) ON DELETE SET NULL,
retain_years INT,
active BOOLEAN NOT NULL DEFAULT true,
created_by BIGINT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE(tenant_id, name)
);
ALTER TABLE classification_templates ADD COLUMN IF NOT EXISTS title_template TEXT;
CREATE INDEX IF NOT EXISTS idx_classification_templates_tenant ON classification_templates(tenant_id);
CREATE INDEX IF NOT EXISTS idx_classification_templates_doc_type ON classification_templates(doc_type_id);
CREATE TABLE IF NOT EXISTS classification_template_tags (
template_id BIGINT NOT NULL REFERENCES classification_templates(id) ON DELETE CASCADE,
tag_id BIGINT NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
PRIMARY KEY (template_id, tag_id)
);
CREATE TABLE IF NOT EXISTS classification_template_field_defaults (
template_id BIGINT NOT NULL REFERENCES classification_templates(id) ON DELETE CASCADE,
field_id BIGINT NOT NULL REFERENCES custom_field_defs(id) ON DELETE CASCADE,
value_text TEXT,
value_number NUMERIC,
value_date DATE,
value_bool BOOLEAN,
overwrite BOOLEAN NOT NULL DEFAULT false,
PRIMARY KEY (template_id, field_id)
);
`)
if err != nil {
return fmt.Errorf("storage: create classification templates tables: %w", err)
}
return nil
}
func scanClassificationTemplate(row interface {
Scan(dest ...any) error
}) (*ClassificationTemplate, error) {
var t ClassificationTemplate
if err := row.Scan(&t.ID, &t.TenantID, &t.Name, &t.Description, &t.DocTypeID, &t.RetainYears,
&t.Active, &t.CreatedBy, &t.CreatedAt, &t.UpdatedAt, &t.TitleTemplate); err != nil {
return nil, err
}
t.Tags = make([]TaxonomyEntity, 0)
t.FieldDefaults = make([]TemplateFieldDefault, 0)
return &t, nil
}
const classificationTemplateCols = `id, tenant_id, name, COALESCE(description, ''), doc_type_id, retain_years, active, created_by, created_at, updated_at, title_template`
// CreateTemplate inserts a new classification template (without tags / field
// defaults — those are set via SetTemplateTags / SetTemplateFieldDefaults).
func (s *Store) CreateTemplate(ctx context.Context, tenantID int64, req CreateTemplateRequest) (*ClassificationTemplate, error) {
row := s.db.QueryRow(ctx, `
INSERT INTO classification_templates (tenant_id, name, description, doc_type_id, retain_years, active, created_by, title_template)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING `+classificationTemplateCols,
tenantID, req.Name, nullIfEmpty(req.Description), req.DocTypeID, req.RetainYears, req.Active, req.CreatedBy, nullIfEmptyPtr(req.TitleTemplate))
t, err := scanClassificationTemplate(row)
if err != nil {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
return nil, ErrDuplicateTemplateName
}
return nil, fmt.Errorf("storage: create classification template: %w", err)
}
return t, nil
}
// ListTemplates returns all classification templates for a tenant, optionally
// filtered by document type. Returns a non-nil (possibly empty) slice.
func (s *Store) ListTemplates(ctx context.Context, tenantID int64, docTypeID *int64) ([]ClassificationTemplate, error) {
query := `SELECT ` + classificationTemplateCols + ` FROM classification_templates WHERE tenant_id = $1`
args := []any{tenantID}
if docTypeID != nil {
query += ` AND doc_type_id = $2`
args = append(args, *docTypeID)
}
query += ` ORDER BY name ASC`
rows, err := s.db.Query(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("storage: list classification templates: %w", err)
}
defer rows.Close()
out := make([]ClassificationTemplate, 0)
for rows.Next() {
t, err := scanClassificationTemplate(rows)
if err != nil {
return nil, fmt.Errorf("storage: scan classification template: %w", err)
}
out = append(out, *t)
}
return out, rows.Err()
}
// GetTemplate returns one classification template resolved with its tags and
// custom-field defaults, scoped to tenant ownership.
func (s *Store) GetTemplate(ctx context.Context, id, tenantID int64) (*ClassificationTemplate, error) {
row := s.db.QueryRow(ctx, `SELECT `+classificationTemplateCols+`
FROM classification_templates WHERE id = $1 AND tenant_id = $2`, id, tenantID)
t, err := scanClassificationTemplate(row)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrClassificationTemplateNotFound
}
return nil, fmt.Errorf("storage: get classification template: %w", err)
}
tags, err := s.listTemplateTags(ctx, id, tenantID)
if err != nil {
return nil, err
}
t.Tags = tags
defaults, err := s.listTemplateFieldDefaults(ctx, id, tenantID)
if err != nil {
return nil, err
}
t.FieldDefaults = defaults
return t, nil
}
// listTemplateTags returns the tags attached to a template, scoped to tenant.
func (s *Store) listTemplateTags(ctx context.Context, templateID, tenantID int64) ([]TaxonomyEntity, error) {
rows, err := s.db.Query(ctx, `
SELECT t.id, t.tenant_id, t.name, COALESCE(t.color, ''), t.match_algorithm, COALESCE(t.match_pattern, ''), t.case_sensitive, COALESCE(t.barcode_value, ''), t.created_at
FROM tags t
JOIN classification_template_tags ctt ON ctt.tag_id = t.id
WHERE ctt.template_id = $1 AND t.tenant_id = $2
ORDER BY t.name ASC
`, templateID, tenantID)
if err != nil {
return nil, fmt.Errorf("storage: list template tags: %w", err)
}
defer rows.Close()
out := make([]TaxonomyEntity, 0)
for rows.Next() {
e, err := scanTaxonomyEntity(rows)
if err != nil {
return nil, fmt.Errorf("storage: scan template tag: %w", err)
}
out = append(out, *e)
}
return out, rows.Err()
}
// listTemplateFieldDefaults returns the custom-field defaults of a template,
// joined to their definitions, scoped to tenant.
func (s *Store) listTemplateFieldDefaults(ctx context.Context, templateID, tenantID int64) ([]TemplateFieldDefault, error) {
rows, err := s.db.Query(ctx, `
SELECT d.field_id, f.name, f.label, f.field_type,
d.value_text, d.value_number, d.value_date, d.value_bool, d.overwrite
FROM classification_template_field_defaults d
JOIN custom_field_defs f ON f.id = d.field_id
WHERE d.template_id = $1 AND f.tenant_id = $2
ORDER BY f.name ASC
`, templateID, tenantID)
if err != nil {
return nil, fmt.Errorf("storage: list template field defaults: %w", err)
}
defer rows.Close()
out := make([]TemplateFieldDefault, 0)
for rows.Next() {
var d TemplateFieldDefault
if err := rows.Scan(&d.FieldID, &d.Name, &d.Label, &d.FieldType,
&d.ValueText, &d.ValueNumber, &d.ValueDate, &d.ValueBool, &d.Overwrite); err != nil {
return nil, fmt.Errorf("storage: scan template field default: %w", err)
}
out = append(out, d)
}
return out, rows.Err()
}
// UpdateTemplate updates a classification template's core attributes (not its
// tags / field defaults), scoped to tenant ownership.
func (s *Store) UpdateTemplate(ctx context.Context, id, tenantID int64, req UpdateTemplateRequest) error {
tag, err := s.db.Exec(ctx, `
UPDATE classification_templates
SET name = $1, description = $2, doc_type_id = $3, retain_years = $4, active = $5, title_template = $6, updated_at = now()
WHERE id = $7 AND tenant_id = $8
`, req.Name, nullIfEmpty(req.Description), req.DocTypeID, req.RetainYears, req.Active, nullIfEmptyPtr(req.TitleTemplate), id, tenantID)
if err != nil {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
return ErrDuplicateTemplateName
}
return fmt.Errorf("storage: update classification template: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrClassificationTemplateNotFound
}
return nil
}
// DeleteTemplate deletes a classification template (cascades to its tags /
// field defaults), scoped to tenant ownership.
func (s *Store) DeleteTemplate(ctx context.Context, id, tenantID int64) error {
tag, err := s.db.Exec(ctx, `DELETE FROM classification_templates WHERE id = $1 AND tenant_id = $2`, id, tenantID)
if err != nil {
return fmt.Errorf("storage: delete classification template: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrClassificationTemplateNotFound
}
return nil
}
// templateOwned returns true if the template belongs to the tenant.
func (s *Store) templateOwned(ctx context.Context, templateID, tenantID int64) (bool, error) {
var ok bool
err := s.db.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM classification_templates WHERE id = $1 AND tenant_id = $2)`, templateID, tenantID).Scan(&ok)
if err != nil {
return false, fmt.Errorf("storage: check template ownership: %w", err)
}
return ok, nil
}
// SetTemplateTags replaces the complete set of tags on a template (bulk PUT).
// All referenced tags must belong to the tenant. Scoped to tenant ownership of
// the template. Delete-all + insert in one transaction (SetDocumentTypeFields
// pattern).
func (s *Store) SetTemplateTags(ctx context.Context, templateID, tenantID int64, tagIDs []int64) error {
owns, err := s.templateOwned(ctx, templateID, tenantID)
if err != nil {
return err
}
if !owns {
return ErrClassificationTemplateNotFound
}
tx, err := s.db.Begin(ctx)
if err != nil {
return fmt.Errorf("storage: begin set template tags: %w", err)
}
defer tx.Rollback(ctx)
if _, err := tx.Exec(ctx, `DELETE FROM classification_template_tags WHERE template_id = $1`, templateID); err != nil {
return fmt.Errorf("storage: clear template tags: %w", err)
}
for _, tagID := range tagIDs {
var ok bool
if err := tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM tags WHERE id = $1 AND tenant_id = $2)`, tagID, tenantID).Scan(&ok); err != nil {
return fmt.Errorf("storage: check tag ownership: %w", err)
}
if !ok {
return fmt.Errorf("%w: tag_id %d", ErrTaxonomyNotFound, tagID)
}
if _, err := tx.Exec(ctx, `
INSERT INTO classification_template_tags (template_id, tag_id) VALUES ($1, $2)
ON CONFLICT (template_id, tag_id) DO NOTHING
`, templateID, tagID); err != nil {
return fmt.Errorf("storage: insert template tag: %w", err)
}
}
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("storage: commit set template tags: %w", err)
}
return nil
}
// SetTemplateFieldDefaults replaces the complete set of custom-field defaults
// on a template (bulk PUT). All referenced fields must belong to the tenant and
// their supplied value is validated against the field type. Scoped to tenant
// ownership of the template. Delete-all + insert in one transaction.
func (s *Store) SetTemplateFieldDefaults(ctx context.Context, templateID, tenantID int64, defaults []TemplateFieldDefaultInput) error {
owns, err := s.templateOwned(ctx, templateID, tenantID)
if err != nil {
return err
}
if !owns {
return ErrClassificationTemplateNotFound
}
// Load field definitions for type resolution / validation.
defs, err := s.ListCustomFieldDefs(ctx, tenantID)
if err != nil {
return err
}
defByID := make(map[int64]CustomFieldDef, len(defs))
for _, d := range defs {
defByID[d.ID] = d
}
tx, err := s.db.Begin(ctx)
if err != nil {
return fmt.Errorf("storage: begin set template field defaults: %w", err)
}
defer tx.Rollback(ctx)
if _, err := tx.Exec(ctx, `DELETE FROM classification_template_field_defaults WHERE template_id = $1`, templateID); err != nil {
return fmt.Errorf("storage: clear template field defaults: %w", err)
}
for _, in := range defaults {
def, ok := defByID[in.FieldID]
if !ok {
return fmt.Errorf("%w: field_id %d", ErrCustomFieldNotFound, in.FieldID)
}
var (
text *string
number *float64
date *time.Time
bl *bool
)
switch def.FieldType {
case "text":
text = in.ValueText
case "enum":
if in.ValueText != nil && *in.ValueText != "" {
if len(def.EnumOptions) > 0 && !containsString(def.EnumOptions, *in.ValueText) {
return fmt.Errorf("storage: value %q not in enum options for field %q", *in.ValueText, def.Name)
}
}
text = in.ValueText
case "number", "monetary":
number = in.ValueNumber
case "date":
if in.ValueDate != nil && *in.ValueDate != "" {
parsed, err := time.Parse("2006-01-02", *in.ValueDate)
if err != nil {
return fmt.Errorf("storage: invalid date %q for field %q: %w", *in.ValueDate, def.Name, err)
}
date = &parsed
}
case "boolean":
bl = in.ValueBool
}
if _, err := tx.Exec(ctx, `
INSERT INTO classification_template_field_defaults (template_id, field_id, value_text, value_number, value_date, value_bool, overwrite)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (template_id, field_id) DO UPDATE
SET value_text = EXCLUDED.value_text, value_number = EXCLUDED.value_number,
value_date = EXCLUDED.value_date, value_bool = EXCLUDED.value_bool, overwrite = EXCLUDED.overwrite
`, templateID, in.FieldID, text, number, date, bl, in.Overwrite); err != nil {
return fmt.Errorf("storage: insert template field default: %w", err)
}
}
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("storage: commit set template field defaults: %w", err)
}
return nil
}