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,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
|
||||
}
|
||||
Reference in New Issue
Block a user