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() }