Files
member-console/internal/server/operator_create_pages.go
T
cgalo5758 88db730fcc Add dual licensing and SPDX headers
Introduce a commercial license option alongside AGPL-3.0-only, require a
CLA for contributors, and document the terms in COMMERCIAL.md and
NOTICE. Add a script to stamp SPDX headers on Go files and apply it
across the tree.
2026-09-06 02:29:42 -05:00

147 lines
6.6 KiB
Go

// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package server
import (
"net/http"
"git.coopcloud.tech/wiki-cafe/member-console/internal/forms"
)
// Record creation gets its own page (design D20, round 4; maintainer,
// 2026-09-03: "agree!"). Products, entitlement sets, and plan ladders each
// have a create page under their list, reached from the list section
// header's one filled "New ..." link:
//
// GET /operator/products/new
// GET /operator/entitlement-sets/new
// GET /operator/plan-ladders/new
//
// The POST endpoints these forms submit to are unchanged
// (/partials/operator/{products,entitlement-sets,plan-ladders}): success is
// still a full navigation to the new record with ?flash=created, and a
// validation failure now re-renders THIS page with the field errors and the
// values the operator typed, at 422, instead of the list page.
//
// Sub-record creation (a price, a tier, a rule, Issue grant, Extend) stays
// inline behind a tertiary opener on its section header: those records have
// no identity apart from the record they hang off, so a page of their own
// would leave the parent behind for one field.
// ProductNewData is the body data for GET /operator/products/new. The
// page is the product declaration rendered unbound (form-library): no
// values, no errors, and no gate, because the entitlement-set select's
// first option creates the set with the product, so a deployment with no
// set yet is no longer a blocked state (design D14).
type ProductNewData struct {
Form forms.FormView
}
// Header titles the page and roots its trail at the products list
// ("Operator / Products / New product"; design D18).
func (ProductNewData) Header() PageHeader {
return PageHeader{Title: "New product", Crumbs: []Link{{Label: "Products", URL: "/operator/products"}}}
}
// EntitlementSetNewData is the body data for GET
// /operator/entitlement-sets/new. The page is the set declaration rendered
// unbound (form-library): no values, no errors, and no Active control,
// because Active is the declaration's edit-only field (design D21).
type EntitlementSetNewData struct {
Form forms.FormView
}
// Header titles the page below the sets list ("Operator / Products /
// Entitlement sets / New entitlement set"), the trail the sets list itself
// carries plus this page.
func (EntitlementSetNewData) Header() PageHeader {
return PageHeader{Title: "New entitlement set", Crumbs: []Link{
{Label: "Products", URL: "/operator/products"},
{Label: "Entitlement sets", URL: "/operator/entitlement-sets"},
}}
}
// PlanLadderNewData is the body data for GET /operator/plan-ladders/new.
// The page is the ladder declaration rendered unbound (form-library).
type PlanLadderNewData struct {
Form forms.FormView
}
// Header titles the page below the ladders list ("Operator / Products /
// Plan ladders / New plan ladder").
func (PlanLadderNewData) Header() PageHeader {
return PageHeader{Title: "New plan ladder", Crumbs: []Link{
{Label: "Products", URL: "/operator/products"},
{Label: "Plan ladders", URL: "/operator/plan-ladders"},
}}
}
// GetProductNewPage handles GET /operator/products/new.
func (h *OperatorPartialsHandler) GetProductNewPage(w http.ResponseWriter, r *http.Request) {
page := h.buildOperatorPageData(r)
page.IAPosition = "catalog:products:new"
page.ActiveCapability = "products"
page.BodyTemplate = "operator_product_new.html"
page.BodyData = h.loadProductNewData(r, forms.NewValues(), nil)
h.Templates.Render(w, "operator.html", page)
}
// GetEntitlementSetNewPage handles GET /operator/entitlement-sets/new.
func (h *OperatorPartialsHandler) GetEntitlementSetNewPage(w http.ResponseWriter, r *http.Request) {
page := h.buildOperatorPageData(r)
page.IAPosition = "catalog:entitlement-sets:new"
page.ActiveCapability = "entitlement-sets"
page.BodyTemplate = "operator_entitlement_set_new.html"
page.BodyData = EntitlementSetNewData{Form: entitlementSetCreateForm(forms.NewValues(), nil)}
h.Templates.Render(w, "operator.html", page)
}
// GetPlanLadderNewPage handles GET /operator/plan-ladders/new.
func (h *OperatorPartialsHandler) GetPlanLadderNewPage(w http.ResponseWriter, r *http.Request) {
page := h.buildOperatorPageData(r)
page.IAPosition = "catalog:plan-ladders:new"
page.ActiveCapability = "plan-ladders"
page.BodyTemplate = "operator_plan_ladder_new.html"
page.BodyData = PlanLadderNewData{Form: planLadderCreateForm(forms.NewValues(), nil)}
h.Templates.Render(w, "operator.html", page)
}
// loadProductNewData hydrates the product create page: the declaration
// rendered on its create side, with the picker's active entitlement sets
// behind the side's own first option.
func (h *OperatorPartialsHandler) loadProductNewData(r *http.Request, values forms.Values, errs *forms.Errors) ProductNewData {
sets := h.loadEntitlementSetOptions(r)
return ProductNewData{Form: productCreateForm(sets, values, errs)}
}
// renderProductNewRefusal re-renders the product create page at 422 with
// the declaration in submission mode: every field's error under its
// control, the form-level error in the slot the part always renders, and
// every value that was typed still there (design D9). One function for
// both kinds of refusal, because a refusal that belongs to no field lands
// in the form's own slot rather than in a page banner (finding FA-26). The
// form posts with htmx targeting #operator-body, so the body partial alone
// is the response and the address bar stays on /operator/products/new.
func (h *OperatorPartialsHandler) renderProductNewRefusal(w http.ResponseWriter, r *http.Request, values forms.Values, errs *forms.Errors) {
w.WriteHeader(http.StatusUnprocessableEntity)
h.Templates.Render(w, "operator_product_new.html", h.loadProductNewData(r, values, errs))
}
// renderEntitlementSetNewRefusal re-renders the set create page at 422
// with the declaration in submission mode (design D9).
func (h *OperatorPartialsHandler) renderEntitlementSetNewRefusal(w http.ResponseWriter, r *http.Request, values forms.Values, errs *forms.Errors) {
w.WriteHeader(http.StatusUnprocessableEntity)
h.Templates.Render(w, "operator_entitlement_set_new.html", EntitlementSetNewData{Form: entitlementSetCreateForm(values, errs)})
}
// renderPlanLadderNewRefusal re-renders the ladder create page at 422
// with the declaration in submission mode (design D9).
func (h *OperatorPartialsHandler) renderPlanLadderNewRefusal(w http.ResponseWriter, r *http.Request, values forms.Values, errs *forms.Errors) {
w.WriteHeader(http.StatusUnprocessableEntity)
h.Templates.Render(w, "operator_plan_ladder_new.html", PlanLadderNewData{Form: planLadderCreateForm(values, errs)})
}