154 lines
5.2 KiB
Go
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
|
|
}
|
|
}
|