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

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
}