Files
member-console/internal/server/operator_product_forms.go
cgalo5758 fc68a6b78e Stop offering "None" for the entitlement set of a product that is a tier
Fourth section of #153's change. The product edit reads the ladders the
product is a tier of. For a tier product the Entitlement set select no
longer offers "None"; it opens with the disabled placeholder "Choose an
entitlement set", and the field's one line reads "This product is a tier
of <ladders>, and a tier needs an entitlement set." in place of the
off-ladder sentence, which moves from the field's declared hint to the
same line slot (the create page keeps it). A submitted "None" is refused
with that sentence; so is a clear refused by section 2's trigger when
the product became a tier after the page was read, after the ladders are
read again. A failed read of the ladders is logged and saves nothing,
instead of reading as "on no ladder".

The edit's write now runs in its own READ COMMITTED transaction
(db.InTx with db.ReadCommitted, item 4.6), so a tier add that commits
first is seen by the clear whatever the server's default isolation; at
REPEATABLE READ the edit's snapshot predated the tier and the clear
committed. UpdateProduct is split into helpers, each answer and log line
kept.

Tests: TestTheEntitlementSetFieldFollowsTheProductsTiers,
TestTheCreatePageCarriesTheOffLadderLine, TestTierNeedsSetSentence,
TestProductEditOfATierProductOffersNoNone,
TestProductEditRefusesNoneForAProductThatBecameATier,
TestProductEditWithUnreadLaddersSavesNothing,
TestProductEditClearingASetIsRefusedWhenATierAddCommitsFirstAtAnyDefaultIsolation
(on a connection whose default is REPEATABLE READ). The forms golden
operator.product.html moves the hint to the line.
TestUpdateProductEntitlementSetSentinel builds its product on committed
rows, since the edit's own transaction cannot see an uncommitted
fixture; its assertions are unchanged. productTierOf takes no context:
it logs through the handler's logger and unparam refuses the unused
parameter.

Breaks: 20 of 20 caught. The parse's error left in place; None offered
whatever the tiers (T6, T6c, T6d); the placeholder left out (T6d, T6);
the ladders out of name order; the line kept beside the error; the
off-ladder line blanked beside an error; the create page's line lost;
the failed read treated as no tier; the save going ahead when the read
failed; the parse's field error left when the ladders are unread; L3 not
logged; the second-read refusal not logged; the database's refusal
unmapped; the refusal named over a failed second read; the join reduced
to the first name; the write at the server's default isolation; the
empty-value condition dropped.

Review: round 1, no blocker or major; four minor and three nits,
applied.

#153's sequence (item 5.4), app rebuilt from this tree, from the demo
snapshot, the whole walkthrough package three times without a reset: ok
each run. The plan-ladders walkthrough's ladder now has tiers with sets,
the org-types walkthrough committed a default change to it on runs 2
and 3 and put the default back, and the grant revoke passed on all three
runs; the app log has no "has no entitlement set". Item 5.4 is reworded
to what ran: the three-walkthrough filter it named finds no revoke
subject on the demo snapshot, which another walkthrough's grant
supplies.

Full suite on reset databases: 4731 pass, 14 skip. make screens: no
change beyond section 1's; the captured product is not a tier.
2026-10-26 19:02:13 -05:00

462 lines
18 KiB
Go

// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package server
import (
"html/template"
"maps"
"git.coopcloud.tech/wiki-cafe/member-console/internal/billing"
"git.coopcloud.tech/wiki-cafe/member-console/internal/forms"
"git.coopcloud.tech/wiki-cafe/member-console/internal/web"
)
// The product form (spec product-management "Operator product creation",
// "Operator product editing"; spec form-library; design D2, D5, D8, D14).
// One declaration serves the create page at /operator/products/new and the
// edit form on the product's record page, rendered in two modes, so the
// two cannot expose different field sets: the eleven differences the forms
// audit found between six create and edit forms (findings FA-1 to FA-5,
// FA-12) become one list with three declared sides. None of the three
// renders a notice: a field simply absent from the other side needs no
// sentence saying so (maintainer, 2026-09-03).
//
// This file is the form's home; the registry is only the index (design
// D1). It sits beside operator_products.go, which parses through the
// declaration and never through r.FormValue.
// productFormName is the declaration's name, its data-form id, and the id
// prefix of every one of its fields.
const productFormName = "operator.product"
// productSetOptionNew is the create side's first and default
// entitlement-set option: choosing it creates the set with the product, in
// one transaction, so a deployment with no set yet can still create its
// first product (design D14). It is not a set identifier and never reaches
// the database as one.
const productSetOptionNew = "new"
// productForm is the declaration, registered as this package is
// initialised rather than from the handler's constructor, so the registry
// is complete for the invariants test, the route test and the
// registered-forms table whether or not a handler has been built.
var productForm = forms.Register(forms.FormSpec{
Name: productFormName,
Kind: forms.KindCreate,
Family: forms.Stacked,
Method: "POST",
Path: "/partials/operator/products",
// The record side is a different route: the create page posts to the
// collection, the edit form puts to the record.
EditMethod: "PUT",
EditPath: "/partials/operator/products/{productID}",
// Both sides re-render the operator body, because saving a product
// changes its readiness panel and its prices view as well as the
// form, and a refusal must swap into the same target the success
// does (design D9, lesson L§17).
Target: "#operator-body",
Swap: "innerHTML",
Fields: []forms.Field{
productNameField(),
{
Name: "display_category",
Label: "Display category",
Control: forms.Text,
Optional: true,
MaxLen: 100,
Placeholder: "e.g. addon",
// A free-form grouping label with no behavioural weight, and
// no fixed domain: any string, blank included, is accepted
// (Doc 41 Decision 136). The explanation the detail page
// carries is the explanation the create page carries.
Help: forms.Help("Display category", "Grouping for the storefront and operator lists; never drives behavior. Blank is fine."),
Autocomplete: "off",
},
{
Name: "entitlement_set_id",
Label: "Entitlement set",
Control: forms.Select,
// The options are the deployment's active sets plus the
// side's own first option, so they are supplied per request
// and the same list is given to ParseWith.
RuntimeOptions: true,
// The field's one line is decided per record, so it travels
// through the slot (productSetLine) and not through a declared
// hint: a tier product's line states the prerequisite, a
// product on no ladder's line describes "None".
Slot: "line",
},
{
Name: "visibility",
Label: "Listed",
Control: forms.Checkbox,
Value: "public",
// One checkbox, not a pair of radios and not two switches:
// the model records only is_public, and an unlisted product is
// the one way to grant a set (Doc 41; walk run 2, ACC-12).
// The field name and its value stay "visibility"/"public";
// only the operator-facing words change (design D4).
Help: forms.Help("Listed", "Shown in the member catalog."),
},
// The three edit-only fields: the create page publishes the
// product in one step, so its write-up, its feature list and its
// Active state all follow creation. None declares a Notice,
// because a field simply absent from the create page needs no
// sentence saying so (maintainer, 2026-09-03).
forms.RecordDescription().On(forms.EditOnly),
productFeaturesField(),
productActiveField(),
productExcludeField(),
},
Commit: "Create product",
CommitEdit: "Save changes",
WayOut: forms.LinkOut("Cancel", "/operator/products"),
})
// productNameField is the record name plus the duplicate-name warning's
// live region (spec product-management "Duplicate product name warns
// without blocking"). core.products.name carries no uniqueness
// constraint, so same-named products are legitimate; the form warns and
// never blocks. The scoped request swaps into the field's declared slot,
// whose id is escaped for a CSS selector because the form's name carries a
// dot. hx-include sends the form's own fields, which is how the edit side
// tells the check to exclude the product being edited.
func productNameField() forms.Field {
f := forms.RecordName()
f.Slot = "warning"
f.Attrs = map[string]string{
"hx-get": "/partials/operator/products/name-check",
"hx-trigger": "input changed delay:300ms",
"hx-target": `#form-operator\.product-name-warning`,
"hx-swap": "innerHTML",
"hx-include": "closest form",
}
return f
}
// productFeaturesField is the free-text feature list the member Products
// page shows, one per line. Edit-only: the product is created and
// published in one step, and its write-up follows (findings FA-2, FA-3).
// One line under the control, its hint; the side's reason is declared and
// not rendered.
func productFeaturesField() forms.Field {
f := forms.Field{
Name: "features",
Label: "Additional features",
Control: forms.Textarea,
Optional: true,
Rows: 3,
MaxLen: 2000,
Hint: "Shown on the Products page, one per line; supplements entitlement-derived limits.",
}
return f.On(forms.EditOnly)
}
// productActiveField is the product's Active state. Edit-only, because a
// product is created active; the help icon after the label explains what
// inactive means, so the operator meets the concept where it can be acted
// on (finding FA-3).
func productActiveField() forms.Field {
f := forms.Field{
Name: "is_active",
Label: "Active",
Control: forms.Checkbox,
Value: "true",
Help: forms.Help("Active", "An inactive product is not offered when new grants are created. Grants that already reference it keep working."),
}
return f.On(forms.EditOnly)
}
// productExcludeField carries the product being edited to the
// duplicate-name check, through the name field's hx-include. It writes
// nothing and renders nothing.
func productExcludeField() forms.Field {
f := forms.Field{
Name: productExcludeName,
Label: "Excluded product",
Control: forms.Hidden,
Optional: true,
}
return f.On(forms.EditOnly)
}
// productExcludeName is the edit side's hidden carrier for the
// duplicate-name check: the product being edited must not warn about its
// own name. It writes nothing; it rides the name field's hx-include so the
// check can leave this product out, and UpdateProduct ignores it, because
// the route and not the field is the authority on which product is being
// edited (finding FA-10).
const productExcludeName = "exclude"
// productSetOffLadderLine is the Entitlement set field's line for a product
// on no ladder: it describes the "None" option that product is offered; the create page carries it as well, as it did as the declared hint.
const productSetOffLadderLine = "The set is what the product delivers; a product with none delivers no capabilities."
// productTier is what the product edit knows about a product's tiers: the
// names of the ladders it is a tier of, in the query's order (ladder name),
// and whether they could not be read. A failed read is never "no tier"
// (#207). Its methods read no row.
type productTier struct {
ladders []string
unread bool
}
// productTierFrom builds the answer from one ListLaddersByProduct read.
func productTierFrom(rows []billing.ListLaddersByProductRow, err error) productTier {
if err != nil {
return productTier{unread: true}
}
names := make([]string, len(rows))
for i, row := range rows {
names[i] = row.LadderName
}
return productTier{ladders: names}
}
// isTier reports whether the product is a tier of at least one ladder.
func (t productTier) isTier() bool { return len(t.ladders) > 0 }
// offersNone reports whether "None" is offered: only when the product is
// known to be no ladder's tier. A tier product never gets it, and neither
// does a product whose ladders could not be read.
func (t productTier) offersNone() bool { return !t.unread && !t.isTier() }
// tierNeedsSetSentence is the Entitlement set field's line for a tier
// product, and the field's error when "None" is refused for one.
func tierNeedsSetSentence(ladders []string) string {
return "This product is a tier of " + joinWithAnd(ladders) + ", and a tier needs an entitlement set."
}
// productSetLine is the Entitlement set field's one line on the edit side:
// nothing when the ladders are unread (the off-ladder sentence describes an
// option that is not offered, and the tier sentence needs the names);
// nothing when a tier product's field carries an error, so the error is the
// field's one line; the tier sentence for a tier product; the off-ladder
// sentence otherwise.
func productSetLine(tier productTier, errs *forms.Errors) string {
if tier.unread {
return ""
}
if tier.isTier() {
if errs.Has("entitlement_set_id") {
return ""
}
return tierNeedsSetSentence(tier.ladders)
}
return productSetOffLadderLine
}
// productSetSlot is a line in the markup a declared hint renders with, so
// the line keeps the hint's type; an empty line gives no entry.
func productSetSlot(line string) map[string]template.HTML {
if line == "" {
return nil
}
return map[string]template.HTML{
"entitlement_set_id": template.HTML(`<div class="form-text">` + template.HTMLEscapeString(line) + `</div>`),
}
}
// productSetOptions builds the entitlement-set select's options for one
// side. The create side opens on "New set named after this product". The
// edit side opens on "None" only when the product is known to be on no
// ladder; a tier product, or one whose ladders could not be read, opens on
// the disabled "Choose an entitlement set", because a tier needs a set. The
// active sets follow, plus, on the edit side, the product's own set when it
// has been deactivated since (so Save round-trips the existing assignment
// instead of silently rewriting it).
func productSetOptions(side forms.Side, sets []EntitlementSetOption, tier productTier) []forms.Option {
var out []forms.Option
if side == forms.EditOnly {
if tier.offersNone() {
out = append(out, forms.NoneOption())
} else {
out = append(out, forms.ChooseOption("an entitlement set"))
}
} else {
out = append(out, forms.Option{Value: productSetOptionNew, Label: "New set named after this product"})
}
for _, s := range sets {
label := s.Name
if s.Inactive {
label += " (current; inactive)"
}
out = append(out, forms.Option{Value: s.SetID, Label: label})
}
return out
}
// productFormOptions is the option map both the render and the parse read,
// so what the control offered and what the handler accepts are one list.
// The create side does not read tier.
func productFormOptions(side forms.Side, sets []EntitlementSetOption, tier productTier) map[string][]forms.Option {
return map[string][]forms.Option{"entitlement_set_id": productSetOptions(side, sets, tier)}
}
// productCreateForm renders the create page's form: unbound on a first
// visit, submission-bound on a refusal.
func productCreateForm(sets []EntitlementSetOption, values forms.Values, errs *forms.Errors) forms.FormView {
mode := forms.ModeUnbound
if errs.Any() {
mode = forms.ModeSubmission
}
return forms.Render(productForm, forms.Binding{
Mode: mode,
Side: forms.CreateOnly,
Values: values,
Errors: errs,
Options: productFormOptions(forms.CreateOnly, sets, productTier{}),
Slots: productSetSlot(productSetOffLadderLine),
})
}
// productEditForm renders the record page's form, bound to the product or
// to a refused submission of it.
func productEditForm(productID string, sets []EntitlementSetOption, tier productTier, values forms.Values, errs *forms.Errors, slots map[string]template.HTML) forms.FormView {
mode := forms.ModeRecord
if errs.Any() {
mode = forms.ModeSubmission
}
action, err := web.RouteURL(productForm.EditPath, productID)
if err != nil {
action = productForm.Path
}
slots = maps.Clone(slots)
if slots == nil {
slots = map[string]template.HTML{}
}
maps.Copy(slots, productSetSlot(productSetLine(tier, errs)))
return forms.Render(productForm, forms.Binding{
Mode: mode,
Side: forms.EditOnly,
Action: action,
Values: values,
Errors: errs,
Options: productFormOptions(forms.EditOnly, sets, tier),
Slots: slots,
})
}
// productEditSlots builds the name slot: the duplicate-name warning under
// the name field, rendered at load so the no-JavaScript path and the
// "someone else created it since" case are both covered. The form's other
// slot, the Entitlement set field's line, is added per render by
// productEditForm (productSetLine). The cross-reference to the assigned
// set's rules that once sat under the entitlement-set select is gone
// (maintainer, 2026-09-03: "It seems superfluous. Remove.").
func productEditSlots(t *SafeTemplates, data OperatorProductEditData) map[string]template.HTML {
if len(data.NameWarning.Matches) == 0 {
return nil
}
return map[string]template.HTML{
"name": t.Fragment("operator_product_name_warning.html", data.NameWarning),
}
}
// productEditValues binds a product record to the declaration's fields.
func productEditValues(p OperatorProductViewModel) forms.Values {
values := forms.NewValues()
values.Set("name", p.Name)
values.Set("display_category", p.DisplayCategory)
values.Set("entitlement_set_id", p.EntitlementSetID)
values.Set("description", p.Description)
values.Set("features", p.Features)
values.Set(productExcludeName, p.ProductID)
if f, ok := productForm.Field("visibility"); ok {
values.SetBool(f, p.IsPublic)
}
if f, ok := productForm.Field("is_active"); ok {
values.SetBool(f, p.IsActive)
}
return values
}
// The price-add form (spec product-management "Prices are added from a
// closed panel"; spec form-library; design D10). A dense sub-record panel
// beside the product's own declaration (lane B).
const productPriceFormName = "operator.product.price.add"
// productPriceAddPanelID is the collapse element the price form's Cancel
// closes (operator_product_prices.html's own panel wrapper).
const productPriceAddPanelID = "createPricePanel"
var productPriceForm = forms.Register(forms.FormSpec{
Name: productPriceFormName,
Kind: forms.KindSubRecord,
Family: forms.Dense,
Method: "POST",
Path: "/partials/operator/products/{productID}/prices",
// The whole composite re-renders: adding a price changes the prices
// table, so a refusal must swap into the same target the success does
// (design D9, lesson L§17). Single instance per page, so no
// Binding.Target override is needed.
Target: "#operator-body",
Swap: "innerHTML",
Fields: []forms.Field{
// A currency amount, not an integer count: Text with a decimal
// inputmode reads better than the Number control, whose Parse
// path assumes whole numbers (strconv.Atoi) and whose native
// spinner and locale handling are known rough edges for currency
// input. The handler still parses and range-checks the raw
// string itself (dollars to integer cents, Stripe's ceiling).
{
Name: "amount",
Label: "Amount",
Control: forms.Text,
Inputmode: "decimal",
// A money amount in a text control: shorter than the third a
// text field usually needs.
Width: forms.WidthNarrower,
},
{
Name: "currency",
Label: "Currency",
Control: forms.Select,
Options: []forms.Option{{Value: "usd", Label: "USD"}},
// A code, not a sentence: narrower than a select's third.
Width: forms.WidthNarrower,
},
// recurring_interval's three options are validated by Parse's own
// membership check, closing FA-11 (the control was `required` in
// markup with nothing behind it): "one_time" is the form's own
// value for a price with no recurrence, translated to a NULL
// column by the handler.
{
Name: "recurring_interval",
Label: "Interval",
Control: forms.Select,
Options: []forms.Option{
{Value: "month", Label: "Monthly"},
{Value: "year", Label: "Yearly"},
{Value: "one_time", Label: "One-time"},
},
// One word: narrower than a select's third.
Width: forms.WidthNarrower,
},
// Trial days is intentionally undeclared: trial_period_days is
// stored but not yet honored anywhere in the billing chain
// (checkout sets no trial), so collecting it would promise a
// trial members never get (finding #1, FA-6). CreatePrice no
// longer reads it at all; the column stays for when the payments
// model builds trials.
},
Commit: "Add price",
WayOut: forms.ClosePanel("Cancel", productPriceAddPanelID),
})
// productPriceFormView renders the price-add panel, bound to whatever was
// submitted (fresh, or a refusal).
func productPriceFormView(productID string, values forms.Values, errs *forms.Errors) forms.FormView {
mode := forms.ModeRecord
if errs.Any() {
mode = forms.ModeSubmission
}
action, err := web.RouteURL(productPriceForm.Path, productID)
if err != nil {
action = productPriceForm.Path
}
return forms.Render(productPriceForm, forms.Binding{Mode: mode, Action: action, Values: values, Errors: errs})
}