331 lines
11 KiB
Go
331 lines
11 KiB
Go
package customfields
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"strings"
|
|
"unicode/utf8"
|
|
|
|
"production/internal/dbutil"
|
|
|
|
"github.com/gofiber/fiber/v2"
|
|
"github.com/google/uuid"
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
)
|
|
|
|
const maxLabelLen = 255
|
|
const maxOptionLen = 255
|
|
const maxOptions = 50
|
|
|
|
type Handler struct {
|
|
db *pgxpool.Pool
|
|
}
|
|
|
|
func NewHandler(db *pgxpool.Pool) *Handler {
|
|
return &Handler{db: db}
|
|
}
|
|
|
|
// Fetch returns field definitions ordered for display. activeOnly=true is
|
|
// what order.Create/Update validate against and what the intake form
|
|
// renders; activeOnly=false is for the management page, which needs to
|
|
// show (and let an owner re-activate) archived fields too.
|
|
func Fetch(ctx context.Context, db *pgxpool.Pool, activeOnly bool) ([]FieldDefinition, error) {
|
|
query := `SELECT id, field_key, label, field_type, options, catalog_type_id, required, position, is_active
|
|
FROM order_field_definitions`
|
|
if activeOnly {
|
|
query += ` WHERE is_active = true`
|
|
}
|
|
query += ` ORDER BY position, created_at`
|
|
|
|
rows, err := db.Query(ctx, query)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
out := []FieldDefinition{}
|
|
for rows.Next() {
|
|
var d FieldDefinition
|
|
var optionsRaw []byte
|
|
if err := rows.Scan(&d.ID, &d.FieldKey, &d.Label, &d.FieldType, &optionsRaw, &d.CatalogTypeID, &d.Required, &d.Position, &d.IsActive); err != nil {
|
|
return nil, err
|
|
}
|
|
if len(optionsRaw) > 0 {
|
|
if err := json.Unmarshal(optionsRaw, &d.Options); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
out = append(out, d)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// ResolveCatalogOptions populates Options on every catalog-linked select
|
|
// def from gencatalog's live catalog_entries — order.Create/Update call
|
|
// this right after Fetch, before ValidateValues, so ValidateValues itself
|
|
// stays a pure, DB-free function (see its own doc comment) while still
|
|
// validating against the catalog's current contents, not a stale snapshot.
|
|
// One query for every referenced catalog, not one per def.
|
|
func ResolveCatalogOptions(ctx context.Context, db *pgxpool.Pool, defs []FieldDefinition) ([]FieldDefinition, error) {
|
|
typeIDs := make([]string, 0)
|
|
seen := make(map[string]bool)
|
|
for _, d := range defs {
|
|
if d.CatalogTypeID != nil && !seen[*d.CatalogTypeID] {
|
|
seen[*d.CatalogTypeID] = true
|
|
typeIDs = append(typeIDs, *d.CatalogTypeID)
|
|
}
|
|
}
|
|
if len(typeIDs) == 0 {
|
|
return defs, nil
|
|
}
|
|
|
|
rows, err := db.Query(ctx,
|
|
`SELECT catalog_type_id, name FROM catalog_entries WHERE catalog_type_id = ANY($1::uuid[]) ORDER BY sort_order, name`,
|
|
typeIDs)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
byType := make(map[string][]string)
|
|
for rows.Next() {
|
|
var typeID, name string
|
|
if err := rows.Scan(&typeID, &name); err != nil {
|
|
return nil, err
|
|
}
|
|
byType[typeID] = append(byType[typeID], name)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
out := make([]FieldDefinition, len(defs))
|
|
copy(out, defs)
|
|
for i := range out {
|
|
if out[i].CatalogTypeID != nil {
|
|
out[i].Options = byType[*out[i].CatalogTypeID]
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// List returns active field definitions with catalog-linked selects'
|
|
// Options resolved live — every staff role needs this to render the intake
|
|
// form (with real, current dropdown choices), not just the owner who
|
|
// manages them.
|
|
func (h *Handler) List(c *fiber.Ctx) error {
|
|
defs, err := Fetch(context.Background(), h.db, true)
|
|
if err != nil {
|
|
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
|
|
}
|
|
defs, err = ResolveCatalogOptions(context.Background(), h.db, defs)
|
|
if err != nil {
|
|
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
|
|
}
|
|
return c.JSON(defs)
|
|
}
|
|
|
|
// ListAll includes archived fields — owner-only, for the management page.
|
|
func (h *Handler) ListAll(c *fiber.Ctx) error {
|
|
defs, err := Fetch(context.Background(), h.db, false)
|
|
if err != nil {
|
|
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
|
|
}
|
|
return c.JSON(defs)
|
|
}
|
|
|
|
type createInput struct {
|
|
Label string `json:"label"`
|
|
FieldType string `json:"field_type"`
|
|
Options []string `json:"options"`
|
|
CatalogTypeID string `json:"catalog_type_id"`
|
|
Required bool `json:"required"`
|
|
}
|
|
|
|
func (body createInput) validate() string {
|
|
if body.Label == "" || utf8.RuneCountInString(body.Label) > maxLabelLen {
|
|
return "label is required and must be under 255 characters"
|
|
}
|
|
if !ValidTypes[body.FieldType] {
|
|
return "field_type must be one of: text, number, select, checkbox"
|
|
}
|
|
if body.CatalogTypeID != "" && body.FieldType != "select" {
|
|
return "catalog_type_id is only valid for select fields"
|
|
}
|
|
// A catalog-linked select needs no static options — ResolveCatalogOptions
|
|
// (see handler's ListEntries-backed lookup) resolves them live from
|
|
// gencatalog at validation time instead.
|
|
if body.FieldType == "select" && body.CatalogTypeID == "" {
|
|
if len(body.Options) == 0 {
|
|
return "select field requires at least one option, or a catalog_type_id"
|
|
}
|
|
if len(body.Options) > maxOptions {
|
|
return "too many options"
|
|
}
|
|
for _, opt := range body.Options {
|
|
if opt == "" || utf8.RuneCountInString(opt) > maxOptionLen {
|
|
return "each option must be non-empty and under 255 characters"
|
|
}
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// Create adds a new field definition. field_key is generated here, never
|
|
// derived from the label (see package doc) and never accepted from the
|
|
// caller — nothing about it is meant to be human-chosen.
|
|
func (h *Handler) Create(c *fiber.Ctx) error {
|
|
var body createInput
|
|
if err := c.BodyParser(&body); err != nil {
|
|
return c.Status(400).JSON(fiber.Map{"error": "invalid request body"})
|
|
}
|
|
if msg := body.validate(); msg != "" {
|
|
return c.Status(400).JSON(fiber.Map{"error": msg})
|
|
}
|
|
// select-only, but stored as-is either way — Update's own validate()
|
|
// enforces the same rule so a later edit can't leave a non-select field
|
|
// with stale options. A catalog-linked field stores no static options at
|
|
// all, even if the caller sent some — they'd never be read.
|
|
options := body.Options
|
|
if body.FieldType != "select" || body.CatalogTypeID != "" {
|
|
options = nil
|
|
}
|
|
optionsJSON, err := json.Marshal(options)
|
|
if err != nil {
|
|
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
|
|
}
|
|
|
|
fieldKey := "f_" + strings.ReplaceAll(uuid.New().String(), "-", "")[:12]
|
|
ctx := context.Background()
|
|
|
|
var position int
|
|
if err := h.db.QueryRow(ctx, `SELECT COALESCE(MAX(position), -1) + 1 FROM order_field_definitions`).Scan(&position); err != nil {
|
|
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
|
|
}
|
|
|
|
var id string
|
|
err = h.db.QueryRow(ctx,
|
|
`INSERT INTO order_field_definitions (field_key, label, field_type, options, catalog_type_id, required, position)
|
|
VALUES ($1, $2, $3, $4::jsonb, $5::uuid, $6, $7) RETURNING id`,
|
|
fieldKey, body.Label, body.FieldType, optionsJSON, dbutil.NullIfEmpty(body.CatalogTypeID), body.Required, position,
|
|
).Scan(&id)
|
|
if err != nil {
|
|
if dbutil.IsFKViolation(err) {
|
|
return c.Status(404).JSON(fiber.Map{"error": "catalog type not found"})
|
|
}
|
|
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
|
|
}
|
|
|
|
var catalogTypeIDPtr *string
|
|
if body.CatalogTypeID != "" {
|
|
catalogTypeIDPtr = &body.CatalogTypeID
|
|
}
|
|
return c.Status(201).JSON(FieldDefinition{
|
|
ID: id, FieldKey: fieldKey, Label: body.Label, FieldType: body.FieldType,
|
|
Options: options, CatalogTypeID: catalogTypeIDPtr, Required: body.Required, Position: position, IsActive: true,
|
|
})
|
|
}
|
|
|
|
type updateInput struct {
|
|
Label *string `json:"label"`
|
|
Options *[]string `json:"options"`
|
|
CatalogTypeID *string `json:"catalog_type_id"`
|
|
Required *bool `json:"required"`
|
|
Position *int `json:"position"`
|
|
IsActive *bool `json:"is_active"`
|
|
}
|
|
|
|
// Update edits label/options/catalog_type_id/required/position/is_active.
|
|
// field_type and field_key are permanently fixed at creation — changing a
|
|
// field's type after values of the old type are already stored would make
|
|
// those values meaningless (a "select" value with no matching option once
|
|
// "text" options disappear, a "text" string once it's a "number"), and the
|
|
// key is the only thing tying stored JSONB values back to their
|
|
// definition, so it can never move.
|
|
//
|
|
// catalog_type_id follows the same COALESCE-plus-empty-string-clears
|
|
// convention as production/internal/settings.Update: the key omitted
|
|
// entirely (Go nil) leaves the link untouched; sent as "" detaches it
|
|
// (back to static Options); sent as a UUID re-points it — but only onto a
|
|
// field whose field_type is already "select", checked against the stored
|
|
// row since createInput/Create is the only place field_type is ever set.
|
|
func (h *Handler) Update(c *fiber.Ctx) error {
|
|
id := c.Params("id")
|
|
var body updateInput
|
|
if err := c.BodyParser(&body); err != nil {
|
|
return c.Status(400).JSON(fiber.Map{"error": "invalid request body"})
|
|
}
|
|
if body.Label != nil && (*body.Label == "" || utf8.RuneCountInString(*body.Label) > maxLabelLen) {
|
|
return c.Status(400).JSON(fiber.Map{"error": "label must be non-empty and under 255 characters"})
|
|
}
|
|
if body.Options != nil {
|
|
if len(*body.Options) > maxOptions {
|
|
return c.Status(400).JSON(fiber.Map{"error": "too many options"})
|
|
}
|
|
for _, opt := range *body.Options {
|
|
if opt == "" || utf8.RuneCountInString(opt) > maxOptionLen {
|
|
return c.Status(400).JSON(fiber.Map{"error": "each option must be non-empty and under 255 characters"})
|
|
}
|
|
}
|
|
}
|
|
|
|
ctx := context.Background()
|
|
|
|
if body.CatalogTypeID != nil && *body.CatalogTypeID != "" {
|
|
var fieldType string
|
|
err := h.db.QueryRow(ctx, `SELECT field_type FROM order_field_definitions WHERE id = $1::uuid`, id).Scan(&fieldType)
|
|
if err == pgx.ErrNoRows {
|
|
return c.Status(404).JSON(fiber.Map{"error": "field not found"})
|
|
}
|
|
if err != nil {
|
|
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
|
|
}
|
|
if fieldType != "select" {
|
|
return c.Status(400).JSON(fiber.Map{"error": "catalog_type_id is only valid for select fields"})
|
|
}
|
|
}
|
|
|
|
var optionsJSON []byte
|
|
var err error
|
|
if body.Options != nil {
|
|
optionsJSON, err = json.Marshal(*body.Options)
|
|
if err != nil {
|
|
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
|
|
}
|
|
}
|
|
|
|
tag, err := h.db.Exec(ctx,
|
|
`UPDATE order_field_definitions SET
|
|
label = COALESCE($1, label),
|
|
options = COALESCE($2::jsonb, options),
|
|
catalog_type_id = CASE WHEN $3::text IS NULL THEN catalog_type_id
|
|
WHEN $3::text = '' THEN NULL
|
|
ELSE $3::uuid END,
|
|
required = COALESCE($4, required),
|
|
position = COALESCE($5, position),
|
|
is_active = COALESCE($6, is_active)
|
|
WHERE id = $7::uuid`,
|
|
body.Label, nullIfNil(body.Options, optionsJSON), body.CatalogTypeID, body.Required, body.Position, body.IsActive, id)
|
|
if err != nil {
|
|
if dbutil.IsFKViolation(err) {
|
|
return c.Status(404).JSON(fiber.Map{"error": "catalog type not found"})
|
|
}
|
|
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
|
|
}
|
|
if tag.RowsAffected() == 0 {
|
|
return c.Status(404).JSON(fiber.Map{"error": "field not found"})
|
|
}
|
|
return c.JSON(fiber.Map{"ok": true})
|
|
}
|
|
|
|
// nullIfNil keeps optionsJSON's Postgres NULL (→ COALESCE keeps the
|
|
// existing value) when the caller didn't send an options key at all,
|
|
// distinct from sending an empty array.
|
|
func nullIfNil(options *[]string, marshaled []byte) []byte {
|
|
if options == nil {
|
|
return nil
|
|
}
|
|
return marshaled
|
|
}
|