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