fix(collections): the create modal's seed POST 400'd — echo cannot bind a form string into an any-typed field

The first real-browser create of an Episodes-scope collection failed
with "invalid request": the modal's hx-post submits
application/x-www-form-urlencoded, and the form value for filter_tree
had to bind into CreateCollectionRequest.FilterTree (any) — echo's
form binder errors on any-typed fields instead of setting the string,
so every form carrying a tree 400'd before validation even ran. JSON
bodies (the save-filter bridge, the editor's Apply) were unaffected,
which is why no API-level or Go test caught it: only a browser posting
the real form shape exercised the transport.

Fix: the form variant binds a dedicated string field
(FilterTreeForm, form:"filter_tree") on both request structs while
FilterTree keeps the JSON tag; resolveTreePayload picks whichever
transport arrived and both ride the same parseRequestFilterTree
validation. Pinned by TestEpisodeCollectionsFormSeedCreate (form-encoded
seed → 201 → scope round-trips).
This commit is contained in:
John O'Keefe committed 2026-10-31 13:52:40 -04:00
1 parent cc0e9d7f91
commit 536fb4794c
2 files changed
+56 -6

No files matched your search

+27 -6
View File
@@ -46,8 +46,16 @@ type CreateCollectionRequest struct {
// smart membership runs through the qf engine. INVALID trees are a
// 400 (ParseFilterTree at the handler); a tree supersedes
// auto_assign_rules. Persisted as-is (any). RawMessage for
// round-trip safety.
FilterTree any `form:"filter_tree" json:"filter_tree"`
// round-trip safety. JSON bodies bind FilterTree; FORM bodies
// (the create modal's urlencoded hx-post) bind FilterTreeForm —
// echo's binder cannot set an any-typed field from a form string
// (the create-modal seed POST 400'd "invalid request" on it,
// 2026-10-31).
FilterTree any `json:"filter_tree"`
// FilterTreeForm mirrors filter_tree for application/x-www-form-
// urlencoded submissions: the raw JSON as a string, validated by
// the same parseRequestFilterTree path.
FilterTreeForm string `form:"filter_tree"`
}
// treeFilterJSON returns the request's filter tree as JSON storage
@@ -85,6 +93,16 @@ func parseRequestFilterTree(payload any) ([]byte, error) {
return b, nil
}
// resolveTreePayload — the tree payload from either transport: JSON
// bodies carry the object in FilterTree, form bodies (the create
// modal's urlencoded hx-post) the raw JSON string in FilterTreeForm.
func resolveTreePayload(jsonTree any, formTree string) any {
if formTree != "" {
return formTree
}
return jsonTree
}
type UpdateCollectionRequest struct {
Name string `form:"name" json:"name" validate:"required"`
// Smart-collection match semantics (item 2.4): "any" (OR) | "all"
@@ -93,8 +111,11 @@ type UpdateCollectionRequest struct {
// The unified filter tree (item 3.2): when present the collection's
// smart membership runs through the qf engine. INVALID trees are a
// 400 (ParseFilterTree at the handler); a tree supersedes
// auto_assign_rules.
FilterTree any `form:"filter_tree" json:"filter_tree"`
// auto_assign_rules. JSON bodies bind FilterTree; form bodies bind
// FilterTreeForm (echo's binder cannot set an any-typed field from
// a form string — see CreateCollectionRequest).
FilterTree any `json:"filter_tree"`
FilterTreeForm string `form:"filter_tree"`
Description string `form:"description" json:"description"`
Color string `form:"color" json:"color"`
Icon string `form:"icon" json:"icon"`
@@ -186,7 +207,7 @@ func (h *CollectionHandler) CreateCollection(c *echo.Context) error {
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
}
treeJSON, treeErr := parseRequestFilterTree(req.FilterTree)
treeJSON, treeErr := parseRequestFilterTree(resolveTreePayload(req.FilterTree, req.FilterTreeForm))
if treeErr != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": treeErr.Error()})
}
@@ -507,7 +528,7 @@ func (h *CollectionHandler) UpdateCollection(c *echo.Context) error {
req.ViewSettings["show_up_next"] = *v
}
treeJSON, treeErr := parseRequestFilterTree(req.FilterTree)
treeJSON, treeErr := parseRequestFilterTree(resolveTreePayload(req.FilterTree, req.FilterTreeForm))
if treeErr != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": treeErr.Error()})
}