Files
aura-crm/production/backend/internal/customfields/customfields.go
T

154 lines
5.2 KiB
Go

// Package customfields lets an owner add business-specific questions to the
// order intake form (warranty seal intact? came with a charger? PIN code?)
// without a code change — the alternative to the fixed device_type/brand/
// model/serial_number/problem_description columns on orders, which cover
// the common case but can't anticipate every shop's own checklist.
//
// Field definitions live in order_field_definitions (see
// migrations/009_custom_fields.sql); values live in one JSONB column on
// orders (field_key -> value), not a separate EAV table — nothing in this
// app ever needs to query orders BY a custom field's value, so a real table
// would only add join complexity for no benefit.
//
// A field is archived (is_active=false), never deleted — deleting it would
// either orphan already-stored values on old orders or force silently
// dropping them, and an owner archiving a field they stopped using has no
// reason to also erase what past orders recorded with it.
package customfields
import (
"strings"
"unicode/utf8"
)
// FieldDefinition mirrors order_field_definitions. Options is only
// meaningful for FieldType "select" — and even then only when
// CatalogTypeID is nil; a catalog-linked select instead resolves its
// options live from gencatalog's catalog_entries (see
// ResolveCatalogOptions), so Options stays empty on that def as fetched
// from the DB.
type FieldDefinition struct {
ID string `json:"id"`
FieldKey string `json:"field_key"`
Label string `json:"label"`
FieldType string `json:"field_type"`
Options []string `json:"options,omitempty"`
CatalogTypeID *string `json:"catalog_type_id,omitempty"`
Required bool `json:"required"`
Position int `json:"position"`
IsActive bool `json:"is_active"`
}
var ValidTypes = map[string]bool{"text": true, "number": true, "select": true, "checkbox": true}
// Text values render into PDFs (internal/pdfgen) same as order.problem_description
// and friends — capped for the same resource-exhaustion reason those fields are.
const MaxTextValueLen = 1000
// ValidateValues checks a caller-supplied set of custom-field values against
// the current field definitions (active or not — an archived def is simply
// never a valid write target, whether or not the caller already filtered
// its list, so this is the one place that rule is enforced). Used
// identically by order.Create (enforceRequired=true: every required active
// field must be present) and order.Update (enforceRequired=false: a partial
// patch only needs to be internally consistent, not complete — whatever key
// isn't mentioned here is left untouched by the caller's own
// UPDATE...COALESCE, so an archived field's historical value is never
// silently dropped just because a later edit didn't re-send it).
//
// A pure function (no DB) so it's directly unit-testable — see
// customfields_test.go.
func ValidateValues(defs []FieldDefinition, raw map[string]any, enforceRequired bool) (map[string]any, error) {
byKey := make(map[string]FieldDefinition, len(defs))
for _, d := range defs {
if d.IsActive {
byKey[d.FieldKey] = d
}
}
out := make(map[string]any, len(raw))
for key, val := range raw {
def, ok := byKey[key]
if !ok {
return nil, &ValidationError{Message: "unknown custom field: " + key}
}
normalized, err := validateOne(def, val)
if err != nil {
return nil, err
}
out[key] = normalized
}
if enforceRequired {
for _, d := range defs {
if !d.Required {
continue
}
v, present := out[d.FieldKey]
if !present || isEmptyValue(v) {
return nil, &ValidationError{Message: d.Label + " is required"}
}
}
}
return out, nil
}
// ValidationError distinguishes a caller-input problem (→ 400) from an
// unexpected failure (→ 500) at the handler call site, without the
// customfields package importing fiber.
type ValidationError struct{ Message string }
func (e *ValidationError) Error() string { return e.Message }
func validateOne(def FieldDefinition, val any) (any, error) {
switch def.FieldType {
case "text":
s, ok := val.(string)
if !ok {
return nil, &ValidationError{Message: def.Label + " must be text"}
}
if utf8.RuneCountInString(s) > MaxTextValueLen {
return nil, &ValidationError{Message: def.Label + " is too long"}
}
return s, nil
case "number":
n, ok := val.(float64)
if !ok {
return nil, &ValidationError{Message: def.Label + " must be a number"}
}
return n, nil
case "checkbox":
b, ok := val.(bool)
if !ok {
return nil, &ValidationError{Message: def.Label + " must be true or false"}
}
return b, nil
case "select":
s, ok := val.(string)
if ok {
for _, opt := range def.Options {
if opt == s {
return s, nil
}
}
}
return nil, &ValidationError{Message: def.Label + " must be one of its options"}
default:
// Unreachable via the API — Create/Update reject unknown field_type
// values before a definition with one can ever be stored.
return nil, &ValidationError{Message: "unsupported field type: " + def.FieldType}
}
}
func isEmptyValue(v any) bool {
switch x := v.(type) {
case string:
return strings.TrimSpace(x) == ""
case nil:
return true
default:
return false
}
}