Files
patrick 9a24ea29e1 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.
2026-08-11 21:27:53 +02:00

647 lines
22 KiB
Go

package storage
import (
"context"
"encoding/json"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
)
// ErrCustomFieldNotFound is returned when a custom-field definition lookup,
// update or delete does not match any row owned by the caller's tenant.
var ErrCustomFieldNotFound = errors.New("storage: custom field not found or not owned by tenant")
// ErrDuplicateCustomFieldName is returned when a tenant already has a custom
// field with the same name (UNIQUE(tenant_id, name)).
var ErrDuplicateCustomFieldName = errors.New("storage: custom field with this name already exists for tenant")
// ErrCustomFieldInUse is returned by DeleteCustomFieldDef when values still
// reference the field — the caller translates this into an HTTP 409.
var ErrCustomFieldInUse = errors.New("storage: custom field still has values and cannot be deleted")
// ErrRequiredFieldMissing is returned by SetDocumentFieldValues when a field
// marked required for the document's document_type has no value supplied.
var ErrRequiredFieldMissing = errors.New("storage: required custom field missing a value")
// validFieldTypes mirrors the CHECK constraint on custom_field_defs.field_type.
var validFieldTypes = map[string]bool{
"text": true, "number": true, "date": true,
"boolean": true, "enum": true, "monetary": true,
}
// CustomFieldDef is a tenant-scoped custom-field definition.
type CustomFieldDef struct {
ID int64 `json:"id"`
TenantID int64 `json:"tenant_id"`
Name string `json:"name"`
Label string `json:"label"`
FieldType string `json:"field_type"`
EnumOptions []string `json:"enum_options,omitempty"`
Currency string `json:"currency,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
// CustomFieldDefRequest holds create parameters for a custom-field definition.
type CustomFieldDefRequest struct {
Name string
Label string
FieldType string
EnumOptions []string
Currency string
}
// DocumentTypeField is a custom field assigned to a document type, carrying
// the assignment metadata (required/visible/sort_order) plus the resolved
// field definition.
type DocumentTypeField struct {
FieldID int64 `json:"field_id"`
Required bool `json:"required"`
Visible bool `json:"visible"`
SortOrder int `json:"sort_order"`
Field CustomFieldDef `json:"field"`
}
// DocumentTypeFieldAssignment is one entry of a bulk PUT replacing a document
// type's field assignments.
type DocumentTypeFieldAssignment struct {
FieldID int64
Required bool
Visible bool
SortOrder int
}
// DocumentFieldValue is a single custom-field value on a document, with the
// value carried in the type-appropriate column.
type DocumentFieldValue struct {
FieldID int64 `json:"field_id"`
Name string `json:"name"`
Label string `json:"label"`
FieldType string `json:"field_type"`
Currency string `json:"currency,omitempty"`
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"`
}
// DocumentFieldValueInput is one supplied value in a batch PUT. Exactly one of
// the value pointers is expected to be populated (matching the field's type).
type DocumentFieldValueInput 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"`
}
// initCustomFieldsSchema creates the custom_field_defs / document_type_fields /
// document_field_values tables. Idempotent, called from (*Store).initSchema.
// Documented (not executed) in migrations/006_custom_fields.sql.
func (s *Store) initCustomFieldsSchema(ctx context.Context) error {
_, err := s.db.Exec(ctx, `
CREATE TABLE IF NOT EXISTS custom_field_defs (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL,
name TEXT NOT NULL,
label TEXT NOT NULL,
field_type TEXT NOT NULL CHECK (field_type IN ('text','number','date','boolean','enum','monetary')),
enum_options JSONB,
currency TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE(tenant_id, name)
);
CREATE TABLE IF NOT EXISTS document_type_fields (
doc_type_id BIGINT NOT NULL REFERENCES document_types(id) ON DELETE CASCADE,
field_id BIGINT NOT NULL REFERENCES custom_field_defs(id) ON DELETE CASCADE,
required BOOLEAN NOT NULL DEFAULT false,
visible BOOLEAN NOT NULL DEFAULT true,
sort_order INT NOT NULL DEFAULT 0,
PRIMARY KEY (doc_type_id, field_id)
);
CREATE TABLE IF NOT EXISTS document_field_values (
document_id BIGINT NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
field_id BIGINT NOT NULL REFERENCES custom_field_defs(id) ON DELETE CASCADE,
tenant_id BIGINT NOT NULL,
value_text TEXT,
value_number NUMERIC,
value_date DATE,
value_bool BOOLEAN,
PRIMARY KEY (document_id, field_id)
);
CREATE INDEX IF NOT EXISTS idx_dfv_tenant_field ON document_field_values(tenant_id, field_id);
CREATE INDEX IF NOT EXISTS idx_dfv_field_text ON document_field_values(field_id, value_text);
CREATE INDEX IF NOT EXISTS idx_dfv_field_number ON document_field_values(field_id, value_number);
`)
if err != nil {
return fmt.Errorf("storage: create custom fields tables: %w", err)
}
return nil
}
func scanCustomFieldDef(row interface {
Scan(dest ...any) error
}) (*CustomFieldDef, error) {
var d CustomFieldDef
var enumRaw []byte
var currency *string
if err := row.Scan(&d.ID, &d.TenantID, &d.Name, &d.Label, &d.FieldType, &enumRaw, &currency, &d.CreatedAt); err != nil {
return nil, err
}
if len(enumRaw) > 0 {
if err := json.Unmarshal(enumRaw, &d.EnumOptions); err != nil {
return nil, fmt.Errorf("storage: unmarshal enum_options: %w", err)
}
}
if currency != nil {
d.Currency = *currency
}
return &d, nil
}
// ListCustomFieldDefs returns all custom-field definitions for a tenant.
func (s *Store) ListCustomFieldDefs(ctx context.Context, tenantID int64) ([]CustomFieldDef, error) {
rows, err := s.db.Query(ctx, `
SELECT id, tenant_id, name, label, field_type, enum_options, currency, created_at
FROM custom_field_defs WHERE tenant_id = $1 ORDER BY name ASC
`, tenantID)
if err != nil {
return nil, fmt.Errorf("storage: list custom fields: %w", err)
}
defer rows.Close()
out := make([]CustomFieldDef, 0)
for rows.Next() {
d, err := scanCustomFieldDef(rows)
if err != nil {
return nil, fmt.Errorf("storage: scan custom field: %w", err)
}
out = append(out, *d)
}
return out, rows.Err()
}
// GetCustomFieldDef returns one custom-field definition, scoped to tenant.
func (s *Store) GetCustomFieldDef(ctx context.Context, id, tenantID int64) (*CustomFieldDef, error) {
row := s.db.QueryRow(ctx, `
SELECT id, tenant_id, name, label, field_type, enum_options, currency, created_at
FROM custom_field_defs WHERE id = $1 AND tenant_id = $2
`, id, tenantID)
d, err := scanCustomFieldDef(row)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrCustomFieldNotFound
}
return nil, fmt.Errorf("storage: get custom field: %w", err)
}
return d, nil
}
// CreateCustomFieldDef inserts a new custom-field definition.
func (s *Store) CreateCustomFieldDef(ctx context.Context, tenantID int64, req CustomFieldDefRequest) (*CustomFieldDef, error) {
if !validFieldTypes[req.FieldType] {
return nil, fmt.Errorf("storage: invalid field_type %q", req.FieldType)
}
var enumRaw []byte
if len(req.EnumOptions) > 0 {
b, err := json.Marshal(req.EnumOptions)
if err != nil {
return nil, fmt.Errorf("storage: marshal enum_options: %w", err)
}
enumRaw = b
}
row := s.db.QueryRow(ctx, `
INSERT INTO custom_field_defs (tenant_id, name, label, field_type, enum_options, currency)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, tenant_id, name, label, field_type, enum_options, currency, created_at
`, tenantID, req.Name, req.Label, req.FieldType, enumRaw, nullIfEmpty(req.Currency))
d, err := scanCustomFieldDef(row)
if err != nil {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
return nil, ErrDuplicateCustomFieldName
}
return nil, fmt.Errorf("storage: create custom field: %w", err)
}
return d, nil
}
// UpdateCustomFieldDef updates the label, enum_options and currency of a
// custom-field definition. Name and field_type are immutable (they anchor
// stored values), matching the API contract. Scoped to tenant ownership.
func (s *Store) UpdateCustomFieldDef(ctx context.Context, id, tenantID int64, label string, enumOptions []string, currency string) (*CustomFieldDef, error) {
var enumRaw []byte
if len(enumOptions) > 0 {
b, err := json.Marshal(enumOptions)
if err != nil {
return nil, fmt.Errorf("storage: marshal enum_options: %w", err)
}
enumRaw = b
}
row := s.db.QueryRow(ctx, `
UPDATE custom_field_defs SET label = $1, enum_options = $2, currency = $3
WHERE id = $4 AND tenant_id = $5
RETURNING id, tenant_id, name, label, field_type, enum_options, currency, created_at
`, label, enumRaw, nullIfEmpty(currency), id, tenantID)
d, err := scanCustomFieldDef(row)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrCustomFieldNotFound
}
return nil, fmt.Errorf("storage: update custom field: %w", err)
}
return d, nil
}
// DeleteCustomFieldDef deletes a custom-field definition, but only if no
// document_field_values reference it. Returns ErrCustomFieldInUse otherwise.
// Scoped to tenant ownership.
func (s *Store) DeleteCustomFieldDef(ctx context.Context, id, tenantID int64) error {
// Ownership check first — distinguishes 404 from 409.
if _, err := s.GetCustomFieldDef(ctx, id, tenantID); err != nil {
return err
}
var inUse bool
if err := s.db.QueryRow(ctx, `
SELECT EXISTS(SELECT 1 FROM document_field_values WHERE field_id = $1 AND tenant_id = $2)
`, id, tenantID).Scan(&inUse); err != nil {
return fmt.Errorf("storage: check custom field usage: %w", err)
}
if inUse {
return ErrCustomFieldInUse
}
tag, err := s.db.Exec(ctx, `DELETE FROM custom_field_defs WHERE id = $1 AND tenant_id = $2`, id, tenantID)
if err != nil {
return fmt.Errorf("storage: delete custom field: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrCustomFieldNotFound
}
return nil
}
// ownsDocumentType returns true if the document type is owned by the tenant.
func (s *Store) ownsDocumentType(ctx context.Context, docTypeID, tenantID int64) (bool, error) {
var ok bool
err := s.db.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM document_types WHERE id = $1 AND tenant_id = $2)`, docTypeID, tenantID).Scan(&ok)
if err != nil {
return false, fmt.Errorf("storage: check document type ownership: %w", err)
}
return ok, nil
}
// ListDocumentTypeFields returns the custom fields assigned to a document type
// (with required/visible/sort_order), joined to their definitions. Scoped to
// tenant ownership of the document type.
func (s *Store) ListDocumentTypeFields(ctx context.Context, docTypeID, tenantID int64) ([]DocumentTypeField, error) {
owns, err := s.ownsDocumentType(ctx, docTypeID, tenantID)
if err != nil {
return nil, err
}
if !owns {
return nil, ErrTaxonomyNotFound
}
rows, err := s.db.Query(ctx, `
SELECT dtf.field_id, dtf.required, dtf.visible, dtf.sort_order,
f.id, f.tenant_id, f.name, f.label, f.field_type, f.enum_options, f.currency, f.created_at
FROM document_type_fields dtf
JOIN custom_field_defs f ON f.id = dtf.field_id
WHERE dtf.doc_type_id = $1 AND f.tenant_id = $2
ORDER BY dtf.sort_order ASC, f.name ASC
`, docTypeID, tenantID)
if err != nil {
return nil, fmt.Errorf("storage: list document type fields: %w", err)
}
defer rows.Close()
out := make([]DocumentTypeField, 0)
for rows.Next() {
var a DocumentTypeField
var f CustomFieldDef
var enumRaw []byte
var currency *string
if err := rows.Scan(&a.FieldID, &a.Required, &a.Visible, &a.SortOrder,
&f.ID, &f.TenantID, &f.Name, &f.Label, &f.FieldType, &enumRaw, &currency, &f.CreatedAt); err != nil {
return nil, fmt.Errorf("storage: scan document type field: %w", err)
}
if len(enumRaw) > 0 {
if err := json.Unmarshal(enumRaw, &f.EnumOptions); err != nil {
return nil, fmt.Errorf("storage: unmarshal enum_options: %w", err)
}
}
if currency != nil {
f.Currency = *currency
}
a.Field = f
out = append(out, a)
}
return out, rows.Err()
}
// SetDocumentTypeFields replaces the complete set of field assignments for a
// document type (bulk PUT). All referenced fields must belong to the tenant.
// Scoped to tenant ownership of the document type.
func (s *Store) SetDocumentTypeFields(ctx context.Context, docTypeID, tenantID int64, assignments []DocumentTypeFieldAssignment) error {
owns, err := s.ownsDocumentType(ctx, docTypeID, tenantID)
if err != nil {
return err
}
if !owns {
return ErrTaxonomyNotFound
}
tx, err := s.db.Begin(ctx)
if err != nil {
return fmt.Errorf("storage: begin set document type fields: %w", err)
}
defer tx.Rollback(ctx)
if _, err := tx.Exec(ctx, `DELETE FROM document_type_fields WHERE doc_type_id = $1`, docTypeID); err != nil {
return fmt.Errorf("storage: clear document type fields: %w", err)
}
for _, a := range assignments {
// Verify field ownership by tenant before linking.
var ok bool
if err := tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM custom_field_defs WHERE id = $1 AND tenant_id = $2)`, a.FieldID, tenantID).Scan(&ok); err != nil {
return fmt.Errorf("storage: check field ownership: %w", err)
}
if !ok {
return fmt.Errorf("%w: field_id %d", ErrCustomFieldNotFound, a.FieldID)
}
if _, err := tx.Exec(ctx, `
INSERT INTO document_type_fields (doc_type_id, field_id, required, visible, sort_order)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (doc_type_id, field_id) DO UPDATE
SET required = EXCLUDED.required, visible = EXCLUDED.visible, sort_order = EXCLUDED.sort_order
`, docTypeID, a.FieldID, a.Required, a.Visible, a.SortOrder); err != nil {
return fmt.Errorf("storage: insert document type field: %w", err)
}
}
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("storage: commit set document type fields: %w", err)
}
return nil
}
// ListDocumentFieldValues returns the custom-field values stored on a document
// (joined to their definitions), scoped to tenant. Ownership of the document
// must be verified by the caller.
func (s *Store) ListDocumentFieldValues(ctx context.Context, documentID, tenantID int64) ([]DocumentFieldValue, error) {
rows, err := s.db.Query(ctx, `
SELECT v.field_id, f.name, f.label, f.field_type, f.currency,
v.value_text, v.value_number, v.value_date, v.value_bool
FROM document_field_values v
JOIN custom_field_defs f ON f.id = v.field_id
WHERE v.document_id = $1 AND v.tenant_id = $2
ORDER BY f.name ASC
`, documentID, tenantID)
if err != nil {
return nil, fmt.Errorf("storage: list document field values: %w", err)
}
defer rows.Close()
out := make([]DocumentFieldValue, 0)
for rows.Next() {
var v DocumentFieldValue
var currency *string
if err := rows.Scan(&v.FieldID, &v.Name, &v.Label, &v.FieldType, &currency,
&v.ValueText, &v.ValueNumber, &v.ValueDate, &v.ValueBool); err != nil {
return nil, fmt.Errorf("storage: scan document field value: %w", err)
}
if currency != nil {
v.Currency = *currency
}
out = append(out, v)
}
return out, rows.Err()
}
// SetDocumentFieldValues sets (upserts) a batch of custom-field values on a
// document and deletes any values not present in the batch. It validates each
// field against its type and enforces required fields for the document's
// document_type server-side. Returns the names of fields whose value changed
// (for audit logging). Scoped to tenant. Ownership of the document must be
// verified by the caller.
func (s *Store) SetDocumentFieldValues(ctx context.Context, documentID, tenantID int64, inputs []DocumentFieldValueInput) ([]string, error) {
// Load the tenant's field definitions for type resolution.
defs, err := s.ListCustomFieldDefs(ctx, tenantID)
if err != nil {
return nil, err
}
defByID := make(map[int64]CustomFieldDef, len(defs))
for _, d := range defs {
defByID[d.ID] = d
}
// Resolve the document's document_type_id to know which fields are required.
var docTypeID *int64
if err := s.db.QueryRow(ctx, `SELECT doc_type_id FROM documents WHERE id = $1 AND tenant_id = $2`, documentID, tenantID).Scan(&docTypeID); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, fmt.Errorf("storage: document not found or not owned by tenant")
}
return nil, fmt.Errorf("storage: resolve document doc_type: %w", err)
}
// Build the set of supplied non-empty field values keyed by field_id.
type resolved struct {
text *string
number *float64
date *time.Time
bl *bool
}
supplied := make(map[int64]resolved, len(inputs))
for _, in := range inputs {
def, ok := defByID[in.FieldID]
if !ok {
return nil, fmt.Errorf("%w: field_id %d", ErrCustomFieldNotFound, in.FieldID)
}
var r resolved
switch def.FieldType {
case "text":
r.text = in.ValueText
case "enum":
if in.ValueText != nil && *in.ValueText != "" {
if len(def.EnumOptions) > 0 && !containsString(def.EnumOptions, *in.ValueText) {
return nil, fmt.Errorf("storage: value %q not in enum options for field %q", *in.ValueText, def.Name)
}
}
r.text = in.ValueText
case "number", "monetary":
r.number = in.ValueNumber
case "date":
if in.ValueDate != nil && *in.ValueDate != "" {
t, err := time.Parse("2006-01-02", *in.ValueDate)
if err != nil {
return nil, fmt.Errorf("storage: invalid date %q for field %q: %w", *in.ValueDate, def.Name, err)
}
r.date = &t
}
case "boolean":
r.bl = in.ValueBool
}
supplied[in.FieldID] = r
}
// Required-field validation against the document's type assignments.
if docTypeID != nil {
reqRows, err := s.db.Query(ctx, `
SELECT dtf.field_id FROM document_type_fields dtf
JOIN custom_field_defs f ON f.id = dtf.field_id
WHERE dtf.doc_type_id = $1 AND f.tenant_id = $2 AND dtf.required = true
`, *docTypeID, tenantID)
if err != nil {
return nil, fmt.Errorf("storage: load required fields: %w", err)
}
var requiredIDs []int64
for reqRows.Next() {
var fid int64
if err := reqRows.Scan(&fid); err != nil {
reqRows.Close()
return nil, fmt.Errorf("storage: scan required field: %w", err)
}
requiredIDs = append(requiredIDs, fid)
}
reqRows.Close()
if err := reqRows.Err(); err != nil {
return nil, err
}
for _, fid := range requiredIDs {
r, ok := supplied[fid]
if !ok || isEmptyResolved(r.text, r.number, r.date, r.bl) {
def := defByID[fid]
return nil, fmt.Errorf("%w: %s", ErrRequiredFieldMissing, def.Name)
}
}
}
// Determine current values to compute the changed-field set for audit.
existing, err := s.ListDocumentFieldValues(ctx, documentID, tenantID)
if err != nil {
return nil, err
}
existingByID := make(map[int64]DocumentFieldValue, len(existing))
for _, e := range existing {
existingByID[e.FieldID] = e
}
tx, err := s.db.Begin(ctx)
if err != nil {
return nil, fmt.Errorf("storage: begin set document field values: %w", err)
}
defer tx.Rollback(ctx)
var changed []string
keep := make(map[int64]bool, len(supplied))
for fid, r := range supplied {
def := defByID[fid]
// Empty value => treat as deletion (handled by the not-kept sweep).
if isEmptyResolved(r.text, r.number, r.date, r.bl) {
continue
}
keep[fid] = true
if _, err := tx.Exec(ctx, `
INSERT INTO document_field_values (document_id, field_id, tenant_id, value_text, value_number, value_date, value_bool)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (document_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
`, documentID, fid, tenantID, r.text, r.number, r.date, r.bl); err != nil {
return nil, fmt.Errorf("storage: upsert document field value: %w", err)
}
if changedValue(existingByID[fid], r.text, r.number, r.date, r.bl) {
changed = append(changed, def.Name)
}
}
// Delete values that were present but are no longer supplied (or were
// supplied empty). Only within this tenant/document.
for fid, e := range existingByID {
if keep[fid] {
continue
}
if _, err := tx.Exec(ctx, `DELETE FROM document_field_values WHERE document_id = $1 AND field_id = $2 AND tenant_id = $3`, documentID, fid, tenantID); err != nil {
return nil, fmt.Errorf("storage: delete document field value: %w", err)
}
changed = append(changed, e.Name)
}
if err := tx.Commit(ctx); err != nil {
return nil, fmt.Errorf("storage: commit set document field values: %w", err)
}
return changed, nil
}
func containsString(list []string, s string) bool {
for _, v := range list {
if v == s {
return true
}
}
return false
}
func isEmptyResolved(text *string, number *float64, date *time.Time, bl *bool) bool {
if text != nil && *text != "" {
return false
}
if number != nil {
return false
}
if date != nil {
return false
}
if bl != nil {
return false
}
return true
}
func changedValue(prev DocumentFieldValue, text *string, number *float64, date *time.Time, bl *bool) bool {
if !ptrEqStr(prev.ValueText, text) {
return true
}
if !ptrEqFloat(prev.ValueNumber, number) {
return true
}
if !ptrEqDate(prev.ValueDate, date) {
return true
}
if !ptrEqBool(prev.ValueBool, bl) {
return true
}
return false
}
func ptrEqStr(a, b *string) bool {
if a == nil || b == nil {
return a == nil && b == nil
}
return *a == *b
}
func ptrEqFloat(a, b *float64) bool {
if a == nil || b == nil {
return a == nil && b == nil
}
return *a == *b
}
func ptrEqBool(a, b *bool) bool {
if a == nil || b == nil {
return a == nil && b == nil
}
return *a == *b
}
func ptrEqDate(a, b *time.Time) bool {
if a == nil || b == nil {
return a == nil && b == nil
}
return a.Year() == b.Year() && a.Month() == b.Month() && a.Day() == b.Day()
}