Files
member-console/internal/server/operator_products.go
T
cgalo5758 71818de0bd Add setup checklist and empty-state guidance
Implement the ux-first-run change: a state-derived setup checklist on
/operator/setup with a landing region that recedes once required steps
are done, and empty states that distinguish blocked from empty across
operator and member surfaces. Also add production deployment and
environment reference docs, plus a config-key completeness test.
2026-08-23 03:06:11 -05:00

614 lines
23 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
}
// 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))
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"),
}
}
}
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
}