Files
member-console/internal/server/operator_products.go
T
cgalo5758 9b96e9c9e9 Rework operator IA and unify UI vocabulary
- Restructure operator sidebar into a flat task list with indented
  children; fold plan topology into plan ladders
- Expand member catalog non-plan section to all published non-tier
  products; require recurring Stripe-mapped prices for purchase
- Add operator domains placements and terminal-claims ledger; redirect
  /domains to the FedWiki Sites Domains anchor
- Apply canonical vocabulary and chrome/form conventions; migrate seeded
  FedWiki Sites display name
2026-08-23 17:12:42 -05:00

658 lines
25 KiB
Go

package server
import (
"database/sql"
"encoding/json"
"errors"
"log/slog"
"net/http"
"strings"
"git.coopcloud.tech/wiki-cafe/member-console/internal/billing"
"git.coopcloud.tech/wiki-cafe/member-console/internal/web"
"github.com/google/uuid"
"github.com/sqlc-dev/pqtype"
)
// displayCategoryAddon is the one display_category value the member storefront
// renders a section for. display_category is presentation-only (Doc 41 Decision
// 136): no code branches on it for behavior. storefrontCategories lists every
// storefront-rendered value; the add-on filter and topology bucketing read it.
const displayCategoryAddon = "addon"
var storefrontCategories = []string{displayCategoryAddon}
// OperatorProductViewModel represents a product for operator template rendering.
type OperatorProductViewModel struct {
ProductID string
Name string
Description string
DisplayCategory string
IsActive bool
IsPublic bool
EntitlementSetID string
EntitlementSetName string
Features string // newline-separated feature strings from metadata.features
CreatedAt string
}
// OperatorProductsData holds data for the products list partial.
type OperatorProductsData struct {
Products []OperatorProductViewModel
EntitlementSets []EntitlementSetOption
FieldErrors web.FieldErrors
Success string
Error string
// EntitlementSetGate drives both the create-product form and the
// products list's empty state (ux-first-run 3.2, 4.1) via the shared
// "emptyState" define. Blocked when no active entitlement set exists
// yet, since a product cannot be created without one to reference.
EntitlementSetGate EmptyStateParams
// Summary is the compact catalog-overview strip rendered above the
// product list (ux-ia-naming 2.1): progressive disclosure of the whole
// catalog's shape, not a concatenation of the entitlement-sets or
// plan-ladders surfaces — Summary only links to those.
Summary ProductCatalogSummary
}
// ProductCatalogSummary is the compact catalog-overview strip shown above
// the products list: lifecycle counts across the whole catalog, plus how
// many published products are ladder tiers vs. off-ladder. Computed in Go
// from data already loaded for the list (lifecycle counts) plus one cheap
// aggregate query (PublishedTiers) — see loadProductsPageData.
//
// Unresolved per-product readiness warnings are deliberately omitted here:
// that predicate (computePriceReadiness + stripeSyncFailure) is O(N) extra
// queries per product, one of which reads the Stripe price-mapping store —
// not cheap to run over the whole catalog on every list-page load. Ladder
// membership is a single aggregate COUNT, so it stayed.
type ProductCatalogSummary struct {
Total int
Published int
Draft int
Retired int
PublishedTiers int // published products that are a tier on some ladder
PublishedOffLadder int // published products that are not a tier on any ladder
}
// EmptyStateParams drives the shared "emptyState" template define
// (templates/partials/operator_empty_state.html), reused by every operator
// list/creation surface that needs to distinguish BLOCKED (a prerequisite
// is missing) from EMPTY (prerequisites exist, nothing created yet) per
// spec ux-first-run. Computed in Go on the view model, not decided in the
// template.
type EmptyStateParams struct {
// Headline is the not-blocked headline, e.g. "No products yet."
Headline string
// Blocked is true when a prerequisite this screen depends on does not
// exist yet; false when the screen's own prerequisites are satisfied
// and the list or form is just genuinely empty.
Blocked bool
// BlockerCopy names the missing prerequisite in plain language.
// Required when Blocked is true.
BlockerCopy string
// PrerequisiteURL / PrerequisiteLabel link the operator surface that
// creates the missing prerequisite. Required when Blocked is true.
PrerequisiteURL string
PrerequisiteLabel string
// ActionURL / ActionLabel present the screen's own primary creation
// action (often a same-page anchor to a form already on screen).
// Optional; omit both when there is nothing to offer directly.
ActionURL string
ActionLabel string
// Note is optional secondary copy shown under the headline in the
// not-blocked branch (e.g. a dependency note, or two-ended purpose
// copy explaining what the created thing unlocks).
Note string
}
// EntitlementSetOption represents an entitlement set for dropdown selection.
type EntitlementSetOption struct {
SetID string
Name string
// Inactive marks the option as the product's currently-assigned set that is
// no longer active. Such a set is absent from ListActiveEntitlementSets, so
// the product edit form injects it (labeled "current — inactive") purely so
// Save round-trips the existing assignment instead of silently rewriting it
// to the first active set (finding #38). Always false for create-form
// options.
Inactive bool
}
// ProductLadderMembership represents a ladder the product is a tier of.
type ProductLadderMembership struct {
LadderID string
LadderKey string
LadderName string
Rank int32
}
// OperatorProductEditData holds data for the product edit form.
type OperatorProductEditData struct {
Product OperatorProductViewModel
EntitlementSets []EntitlementSetOption
LadderMemberships []ProductLadderMembership
Readiness ProductReadinessVM
FieldErrors web.FieldErrors
Success string
Error string
}
// ProductDetailData is the body data for the addressable per-product composite
// page (operator_product_detail.html). It co-locates the edit/readiness view
// model and the prices view model on one URL; the composite template renders
// each sub-section via its existing partial.
type ProductDetailData struct {
Edit OperatorProductEditData
Prices ProductPricesData
Error string
}
// CreateProduct handles POST /partials/operator/products
func (h *OperatorPartialsHandler) CreateProduct(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
h.renderProductsPage(w, r, "", "Invalid request")
return
}
name := r.FormValue("name")
displayCategory := r.FormValue("display_category")
entitlementSetID := r.FormValue("entitlement_set_id")
errs := web.New()
if name == "" {
errs.Set("name", "Name is required.")
}
if entitlementSetID == "" {
errs.Set("entitlement_set_id", "Entitlement set is required.")
}
var esUUID uuid.UUID
if entitlementSetID != "" {
parsed, err := uuid.Parse(entitlementSetID)
if err != nil {
errs.Set("entitlement_set_id", "Invalid entitlement set ID.")
} else {
esUUID = parsed
}
}
if errs.Any() {
h.renderProductsFormErrors(w, r, errs)
return
}
isPublic := r.FormValue("is_public") == "true"
// Internal wrap product: publish immediately, never public, no price
// required (the entitlement set required above is the sole guard).
if r.FormValue("wrap_product") == "true" {
isPublic = false
}
_, err := h.BillingQ.CreateProduct(r.Context(), billing.CreateProductParams{
Name: name,
DisplayCategory: sql.NullString{String: displayCategory, Valid: displayCategory != ""},
IsActive: true,
IsPublic: isPublic,
EntitlementSetID: uuid.NullUUID{UUID: esUUID, Valid: true},
LifecycleStatus: "published",
})
if err != nil {
if fe, ok := web.FieldErrorsFromDB(err, web.ConstraintMessages{
"chk_products_lifecycle_status_domain": {Field: "", Message: "Lifecycle status must be draft, published, or retired."},
}); ok {
h.renderProductsFormErrors(w, r, fe)
return
}
h.Logger.Error("failed to create product", slog.Any("error", err))
h.renderProductsPage(w, r, "", "Failed to create the product. Details are in the server logs.")
return
}
// Signal dependent tabs (Grants, Org Types) to re-fetch — they show product dropdowns
h.renderProductsPage(w, r, "Product created successfully.", "")
}
// UpdateProduct handles PUT /partials/operator/products/{productID}
func (h *OperatorPartialsHandler) UpdateProduct(w http.ResponseWriter, r *http.Request) {
productID := r.PathValue("productID")
if err := r.ParseForm(); err != nil {
h.renderProductEditPage(w, r, productID, "", "Invalid request")
return
}
name := r.FormValue("name")
displayCategory := r.FormValue("display_category")
entitlementSetID := r.FormValue("entitlement_set_id")
description := r.FormValue("description")
errs := web.New()
if name == "" {
errs.Set("name", "Name is required.")
}
// Unlike create, entitlement_set_id is not required here: the sentinel
// ("Select set...") is a legitimate submission, not an omission — it is
// how the edit form leaves (or sets) the product's entitlement set to
// NULL. Only a non-empty value that fails to parse is an error.
esNullUUID := uuid.NullUUID{}
if entitlementSetID != "" {
parsed, err := uuid.Parse(entitlementSetID)
if err != nil {
errs.Set("entitlement_set_id", "Invalid entitlement set ID.")
} else {
esNullUUID = uuid.NullUUID{UUID: parsed, Valid: true}
}
}
if errs.Any() {
h.renderProductEditFormErrors(w, r, productID, errs)
return
}
isActive := r.FormValue("is_active") == "true"
isPublic := r.FormValue("is_public") == "true"
// Build metadata from features input
featuresRaw := r.FormValue("features")
var metadata pqtype.NullRawMessage
if featuresRaw != "" {
var features []string
for _, line := range strings.Split(featuresRaw, "\n") {
line = strings.TrimSpace(line)
if line != "" {
features = append(features, line)
}
}
if len(features) > 0 {
meta := map[string]interface{}{"features": features}
raw, _ := json.Marshal(meta)
metadata = pqtype.NullRawMessage{RawMessage: raw, Valid: true}
}
}
_, err := h.BillingQ.UpdateProduct(r.Context(), billing.UpdateProductParams{
ProductID: productID,
Name: name,
Description: sql.NullString{String: description, Valid: description != ""},
DisplayCategory: sql.NullString{String: displayCategory, Valid: displayCategory != ""},
IsActive: isActive,
IsPublic: isPublic,
EntitlementSetID: esNullUUID,
Metadata: metadata,
})
if err != nil {
if fe, ok := web.FieldErrorsFromDB(err, web.ConstraintMessages{
"chk_products_lifecycle_status_domain": {Field: "", Message: "Lifecycle status must be draft, published, or retired."},
}); ok {
h.renderProductEditFormErrors(w, r, productID, fe)
return
}
h.Logger.Error("failed to update product", slog.Any("error", err))
h.renderProductEditPage(w, r, productID, "", "Failed to update the product. Details are in the server logs.")
return
}
// Stay on the product's composite detail page (the boosted nav already set
// the URL); re-render in place reflecting the saved changes.
h.renderProductDetailBody(w, r, productID, "Product updated successfully.", "")
}
func (h *OperatorPartialsHandler) renderProductsPage(w http.ResponseWriter, r *http.Request, success string, errMsg string) {
fireSuccessToast(w, success)
data := h.loadProductsPageData(r, "", errMsg)
h.Templates.Render(w, "operator_products.html", data)
}
// renderProductsFormErrors re-renders the products page with 422 + FieldErrors
// populated on the inline create-product form. Convention per
// docs/operator-ux-conventions.md §6+§8.
func (h *OperatorPartialsHandler) renderProductsFormErrors(w http.ResponseWriter, r *http.Request, errs web.FieldErrors) {
w.WriteHeader(http.StatusUnprocessableEntity)
data := h.loadProductsPageData(r, "", "")
data.FieldErrors = errs
h.Templates.Render(w, "operator_products.html", data)
}
// renderProductEditFormErrors re-renders the product edit form with 422 +
// FieldErrors populated. Convention per docs/operator-ux-conventions.md §6+§8.
func (h *OperatorPartialsHandler) renderProductEditFormErrors(w http.ResponseWriter, r *http.Request, productID string, errs web.FieldErrors) {
w.WriteHeader(http.StatusUnprocessableEntity)
// Re-render the full composite so the failing edit form appears alongside
// the readiness panel and prices view it now shares a page with.
edit, ok := h.loadProductEditData(r, productID)
if !ok {
h.renderProductsPage(w, r, "", "Product not found")
return
}
edit.FieldErrors = errs
h.Templates.Render(w, "operator_product_detail.html", ProductDetailData{
Edit: edit,
Prices: h.loadProductPricesData(r, productID),
})
}
// loadProductsPageData hydrates the products listing. Shared by the legacy
// partial endpoint (renderProductsPage) and the new MPA page handler
// (GetProductsPage).
func (h *OperatorPartialsHandler) loadProductsPageData(r *http.Request, success string, errMsg string) OperatorProductsData {
data := OperatorProductsData{
Success: success,
Error: errMsg,
}
// A stale-detail GET redirects here with ?flash=missing; surface it as the
// page banner (finding #53) without clobbering an explicit mutation-guard
// errMsg.
if data.Error == "" && r.URL.Query().Get("flash") == "missing" {
data.Error = "That product no longer exists — it may have been deleted."
}
// Finding #54: always hydrate the table and create form, even when a banner
// is set, so an unrecognized error never wipes the whole management surface.
products, err := h.BillingQ.ListAllProducts(r.Context())
if err != nil {
h.Logger.Error("failed to list products", slog.Any("error", err))
if data.Error == "" {
data.Error = "Failed to load products"
}
} else {
data.Products = make([]OperatorProductViewModel, len(products))
summary := ProductCatalogSummary{Total: len(products)}
for i, p := range products {
esName := ""
if p.EntitlementSetID.Valid {
if es, err := h.EntitlementsQ.GetEntitlementSetByID(r.Context(), p.EntitlementSetID.UUID.String()); err == nil {
esName = es.Name
}
}
desc := ""
if p.Description.Valid {
desc = p.Description.String
}
data.Products[i] = OperatorProductViewModel{
ProductID: p.ProductID,
Name: p.Name,
Description: desc,
DisplayCategory: p.DisplayCategory.String,
IsActive: p.IsActive,
IsPublic: p.IsPublic,
EntitlementSetID: p.EntitlementSetID.UUID.String(),
EntitlementSetName: esName,
CreatedAt: p.CreatedAt.Format("Jan 2, 2006"),
}
switch p.LifecycleStatus {
case "published":
summary.Published++
case "draft":
summary.Draft++
case "retired":
summary.Retired++
}
}
// Ladder-tier membership is a single cheap aggregate — see
// ProductCatalogSummary's doc comment for why per-product readiness
// is omitted instead of joined in here.
if tierCount, err := h.BillingQ.CountPublishedTierProducts(r.Context()); err != nil {
h.Logger.Error("failed to count published tier products", slog.Any("error", err))
} else {
summary.PublishedTiers = int(tierCount)
summary.PublishedOffLadder = summary.Published - int(tierCount)
}
data.Summary = summary
}
data.EntitlementSets = h.loadEntitlementSetOptions(r)
data.EntitlementSetGate = h.productEntitlementSetGate(r, data.EntitlementSets)
return data
}
// productEntitlementSetGate computes the blocked/empty state for the
// products surface (ux-first-run 3.2, 4.1): creating a product requires an
// active entitlement set to reference. Falls back to the already-loaded
// options list on a predicate-query error so a transient DB error can never
// leave an enabled form with an empty picker rendered (finding shape #54).
func (h *OperatorPartialsHandler) productEntitlementSetGate(r *http.Request, activeSets []EntitlementSetOption) EmptyStateParams {
anyActive, err := h.EntitlementsQ.AnyActiveEntitlementSet(r.Context())
if err != nil {
h.Logger.Error("failed to check for an active entitlement set", slog.Any("error", err))
anyActive = len(activeSets) > 0
}
if !anyActive {
return EmptyStateParams{
Blocked: true,
BlockerCopy: "Products need an entitlement set to define what they deliver. Create one before creating a product.",
PrerequisiteURL: "/operator/entitlement-sets",
PrerequisiteLabel: "Go to Entitlement Sets",
}
}
return EmptyStateParams{
Headline: "No products yet.",
ActionURL: "#createProductForm",
ActionLabel: "Create Product",
}
}
// loadProductEditData hydrates the edit-form + ladder-memberships + readiness
// view model for one product. ok=false means the product does not exist. Shared
// by the composite detail page (GetProductDetailPage), the in-page re-renders
// after mutations (renderProductDetailBody), and the form-error path.
func (h *OperatorPartialsHandler) loadProductEditData(r *http.Request, productID string) (OperatorProductEditData, bool) {
data, found, err := h.loadProductEditDataResult(r, productID)
return data, found && err == nil
}
// loadProductEditDataResult is loadProductEditData with the not-found vs.
// transient-DB-error distinction preserved: found=false with err=nil means the
// product does not exist; err != nil means the load itself failed. The
// full-page GET (GetProductDetailPage) uses this to redirect on the former and
// return a full-shell 500 on the latter, rather than conflating the two
// (finding #53).
func (h *OperatorPartialsHandler) loadProductEditDataResult(r *http.Request, productID string) (OperatorProductEditData, bool, error) {
product, err := h.BillingQ.GetProductByID(r.Context(), productID)
if errors.Is(err, sql.ErrNoRows) {
return OperatorProductEditData{}, false, nil
}
if err != nil {
h.Logger.Error("failed to get product", slog.Any("error", err))
return OperatorProductEditData{}, false, err
}
desc := ""
if product.Description.Valid {
desc = product.Description.String
}
esID := ""
if product.EntitlementSetID.Valid {
esID = product.EntitlementSetID.UUID.String()
}
features := ""
if product.Metadata.Valid {
var meta map[string]interface{}
if err := json.Unmarshal(product.Metadata.RawMessage, &meta); err == nil {
if fl, ok := meta["features"]; ok {
if featureList, ok := fl.([]interface{}); ok {
strs := make([]string, 0, len(featureList))
for _, f := range featureList {
if s, ok := f.(string); ok {
strs = append(strs, s)
}
}
features = strings.Join(strs, "\n")
}
}
}
}
data := OperatorProductEditData{}
data.Product = OperatorProductViewModel{
ProductID: product.ProductID,
Name: product.Name,
Description: desc,
DisplayCategory: product.DisplayCategory.String,
IsActive: product.IsActive,
IsPublic: product.IsPublic,
EntitlementSetID: esID,
Features: features,
CreatedAt: product.CreatedAt.Format("Jan 2, 2006"),
}
data.EntitlementSets = h.loadEntitlementSetOptions(r)
// Ensure the product's currently-assigned set is always an option, even
// after it has been deactivated (ListActiveEntitlementSets omits inactive
// sets). Without this the required select silently preselects the first
// active set and Save rewrites entitlement_set_id to a different set's
// rules; injecting the current set (labeled "current — inactive" in the
// template) makes Save round-trip the existing assignment faithfully
// (finding #38).
if esID != "" {
present := false
for _, opt := range data.EntitlementSets {
if opt.SetID == esID {
present = true
break
}
}
if !present {
if es, err := h.EntitlementsQ.GetEntitlementSetByID(r.Context(), esID); err == nil {
data.EntitlementSets = append(data.EntitlementSets, EntitlementSetOption{
SetID: es.SetID,
Name: es.Name,
Inactive: true,
})
}
}
}
// Load ladder memberships for this product
ladders, err := h.BillingQ.ListLaddersByProduct(r.Context(), productID)
if err == nil {
data.LadderMemberships = make([]ProductLadderMembership, len(ladders))
for i, l := range ladders {
data.LadderMemberships[i] = ProductLadderMembership{
LadderID: l.PlanLadderID,
LadderKey: l.LadderKey,
LadderName: l.LadderName,
Rank: l.Rank,
}
}
}
// Purchasability readiness: compose the shared price+mapping gate with the
// visibility/structural preconditions the member catalog enforces upstream.
pr := computePriceReadiness(r.Context(), h.BillingQ, h.StripeQ, h.StripeConfigured, productID)
syncFailed, syncErr := h.stripeSyncFailure(r.Context(), productID, pr.PriceID)
// Structural shape (set presence + billing shape) comes from product_shape;
// display_category is presentation-only and never read here (Doc 41).
shape, err := h.BillingQ.GetProductShape(r.Context(), productID)
if err != nil {
h.Logger.Error("failed to load product shape", slog.Any("error", err), slog.String("product_id", productID))
}
data.Readiness = buildProductReadinessVM(product, shape, pr, syncFailed, syncErr)
return data, true, nil
}
// GetProductDetailPage handles GET /operator/products/{productID} — the
// addressable, bookmarkable per-product composite. Renders the operator.html
// shell with the edit form, purchasability/readiness panel, and prices view
// co-located on one URL. Mirrors GetOrganizationDetailPage. Unknown productID
// degrades to the products browse page with an error.
func (h *OperatorPartialsHandler) GetProductDetailPage(w http.ResponseWriter, r *http.Request) {
productID := r.PathValue("productID")
edit, found, err := h.loadProductEditDataResult(r, productID)
if err != nil {
// Transient load failure — keep the operator inside the shell (nav, CSS)
// with a 500 instead of mislabeling it "not found" (finding #53).
errPage := h.buildOperatorPageData(r)
errPage.IAPosition = "catalog:products"
errPage.ActiveCapability = "products"
errPage.BodyTemplate = "operator_products.html"
errPage.BodyData = h.loadProductsPageData(r, "", "Could not load this product right now. Please try again.")
w.WriteHeader(http.StatusInternalServerError)
h.Templates.Render(w, "operator.html", errPage)
return
}
if !found {
// Stale/unknown ID — normalize the URL back to the browse route and carry
// a flash so the operator understands the bounce (finding #53).
http.Redirect(w, r, "/operator/products?flash=missing", http.StatusSeeOther)
return
}
page := h.buildOperatorPageData(r)
page.IAPosition = "catalog:products:" + productID
page.ActiveCapability = "products"
page.BodyTemplate = "operator_product_detail.html"
page.BodyData = ProductDetailData{
Edit: edit,
Prices: h.loadProductPricesData(r, productID),
}
h.Templates.Render(w, "operator.html", page)
}
// GetProductReadiness serves the live-update poll for the purchasability card:
// while a Stripe sync is pending the card polls this route every few seconds
// (hx-trigger="every 3s"). Once the state is terminal — synced, failed, or the
// product vanished — it replies 286, htmx's stop-polling status, and the
// rendered card (poll-attr-free, since SyncPending is false) still swaps in.
// WriteHeader-before-Render matches renderProductsFormErrors.
func (h *OperatorPartialsHandler) GetProductReadiness(w http.ResponseWriter, r *http.Request) {
productID := r.PathValue("productID")
edit, ok := h.loadProductEditData(r, productID)
if !ok {
w.WriteHeader(286)
h.Templates.Render(w, "operator_product_readiness.html", OperatorProductEditData{
Product: OperatorProductViewModel{ProductID: productID},
Error: "Product no longer exists.",
})
return
}
if !edit.Readiness.SyncPending {
w.WriteHeader(286)
}
h.Templates.Render(w, "operator_product_readiness.html", edit)
}
// renderProductDetailBody re-renders the composite product detail body into
// #operator-body after an in-page mutation. The operator stays on
// /operator/products/{productID} (the boosted nav already set the URL), so no
// redirect is issued. Unknown productID degrades to the products browse page.
func (h *OperatorPartialsHandler) renderProductDetailBody(w http.ResponseWriter, r *http.Request, productID string, success string, errMsg string) {
fireSuccessToast(w, success)
edit, ok := h.loadProductEditData(r, productID)
if !ok {
h.renderProductsPage(w, r, "", "Product not found")
return
}
// Finding #41: set only the composite-level Error so the alert renders once
// at page top; setting edit.Error too stacked an identical banner inside the
// Edit card, visually blaming the wrong form.
h.Templates.Render(w, "operator_product_detail.html", ProductDetailData{
Edit: edit,
Prices: h.loadProductPricesData(r, productID),
Error: errMsg,
})
}
// renderProductEditPage re-renders the product composite in place. Retained as a
// thin alias so the many mutation call sites (UpdateProduct, SyncProductToStripe)
// keep working; the product edit form now lives on the composite detail page.
func (h *OperatorPartialsHandler) renderProductEditPage(w http.ResponseWriter, r *http.Request, productID string, success string, errMsg string) {
h.renderProductDetailBody(w, r, productID, success, errMsg)
}
func (h *OperatorPartialsHandler) loadEntitlementSetOptions(r *http.Request) []EntitlementSetOption {
sets, err := h.EntitlementsQ.ListActiveEntitlementSets(r.Context())
if err != nil {
h.Logger.Error("failed to list entitlement sets", slog.Any("error", err))
return nil
}
options := make([]EntitlementSetOption, len(sets))
for i, s := range sets {
options[i] = EntitlementSetOption{
SetID: s.SetID,
Name: s.Name,
}
}
return options
}