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 }