Files
member-console/internal/server/operator_products.go
T
cgalo5758 3727ff31d8 Add entitlement set rule change ledger and preview flow
Add an append-only ledger of entitlement set rule changes with per-pool
effect rows, a preview-and-commit rule change flow, and an automatic
drain that settles deferred recomputations. Rules gain a tier reduction
policy, resource keys declare over-limit behavior, and the materializer
now lowers limits when a rule stops applying.
Add entitlement set rule change ledger and preview flow

Add an append-only ledger of entitlement set rule changes with a
preview-and-commit operator flow. Rule writes now go through an enclosed
`core.commit_rule_change` function that files an act row and one
obligation per carrying pool, with a drain workflow settling deferred
recomputations. The preview dry-runs the materializer with a rule
overlay and renders per-pool buckets, reduction-policy disclosures, and
provider over-limit consequences. Materializing transactions take a
shared advisory rendezvous that rule changes hold exclusively, enforced
by a possession assertion. Add History and Entitlement changes surfaces,
a rule-less warning on five product-selection surfaces, and a
`tier_reduction_policy` column that gates FedWiki parking.
2026-09-15 03:53:28 -05:00

1097 lines
44 KiB
Go

// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package server
import (
"context"
"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/entitlements"
"git.coopcloud.tech/wiki-cafe/member-console/internal/forms"
internalstripe "git.coopcloud.tech/wiki-cafe/member-console/internal/integrations/stripe/store"
"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
LifecycleStatus string
EntitlementSetID string
EntitlementSetName string
Features string // newline-separated feature strings from metadata.features
// Key is the product's declarative address (entity-keys §5), shown
// read-only on the detail page when set and omitted entirely when NULL.
// Product names carry no uniqueness constraint, so a key is the only way
// something outside the database can name one product; the create form
// does not collect one.
Key string
CreatedAt string
// Lifecycle is the lifecycle status as the word an operator reads
// (Published, Draft, Retired), for the detail page's Details list
// (product-management "Lifecycle stays a fact and becomes a facet",
// design D2). Set on every load path; the list rows also carry it, but
// their Status cell renders the verdict through StatusBadge instead.
Lifecycle string
// VerdictState drives StatusBadge for a list row: "purchasable",
// "ready_to_grant", or "incomplete" for a published product (the
// readiness verdict), or the raw lifecycle status for draft/retired
// (design D1: lifecycle pre-empts the verdict). Empty on the detail
// page's own Product, which never renders a Status cell.
VerdictState string
// StatusNote is the list row's muted text beside the Status badge: the
// catalog qualifier ("Not shown in the member catalog") or the first
// unmet leg ("Missing: Entitlement set"). Empty for draft/retired rows
// and for a fully purchasable product with no qualifier.
StatusNote string
}
// OperatorProductsData holds data for the products list partial. It carries
// no FieldErrors: creation moved to its own page (design D20, round 4;
// ProductNewData), so no form on this page can fail validation.
type OperatorProductsData struct {
Products []OperatorProductViewModel
// EntitlementSets is the deployment's active sets, kept on the list
// for the surfaces that read it beside the table.
EntitlementSets []EntitlementSetOption
Success string
Error string
// EntitlementSetGate is the list's empty state, through the shared
// "emptyState" define. It never blocks now: the create page's select
// opens on "New set named after this product" and creates the set
// with the product (design D14), so a deployment with no set is not a
// prerequisite to disclose.
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
// Nav drives the shared list-controls partial (operator-list-scale):
// search by name, the lifecycle-status facet, and the true-total pager
// (product-management "The list takes the scaffold", design D3).
Nav ListNav
}
// 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, labelled "(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
LadderName string
Rank int32
}
// ProductNameMatch represents another product sharing a name, ignoring case
// (entity-keys): core.products.name carries no uniqueness
// constraint, so same-named products are legitimate (a "Pro" tier on two
// ladders) but confuse ledgers and history — the create/edit forms warn
// without blocking. LadderPhrase is precomputed in Go ("a tier of Hosted
// Website, Email Hosting" or "not on any ladder") so the template needs no
// join helper.
type ProductNameMatch struct {
ProductID string
Name string
LadderPhrase string
}
// OperatorProductNameWarningData drives
// partials/operator_product_name_warning.html: empty Matches renders
// nothing.
type OperatorProductNameWarningData struct {
Matches []ProductNameMatch
}
// OperatorProductEditData holds data for the product edit form.
type OperatorProductEditData struct {
Product OperatorProductViewModel
EntitlementSets []EntitlementSetOption
LadderMemberships []ProductLadderMembership
Readiness ProductReadinessVM
// NameWarning is the duplicate-name warning (design D4), rendered
// server-side at load when another product already carries this
// product's name — the same lookup the live hx-get uses, excluding this
// product itself, so the no-JS path and the "someone else created it
// since" case are both covered (spec scenario: "Existing duplicate is
// disclosed on the edit page").
NameWarning OperatorProductNameWarningData
// Form is the product declaration rendered on its edit side, bound to
// this record or to a refused submission of it (form-library). The
// create page renders the same declaration unbound, so the two forms
// cannot expose different field sets.
Form forms.FormView
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, reading the body
// through the product declaration and never through r.FormValue
// (form-library "The handler parses through the declaration"): the
// declaration is the write allowlist, so the CSRF token, htmx's own
// parameters and anything else the body carries are ignored, and the
// edit-only fields cannot be set from here.
func (h *OperatorPartialsHandler) CreateProduct(w http.ResponseWriter, r *http.Request) {
sets := h.loadEntitlementSetOptions(r)
values, errs := productForm.ParseSide(r, forms.CreateOnly, productFormOptions(forms.CreateOnly, sets))
if errs.Any() {
h.renderProductNewRefusal(w, r, values, errs)
return
}
name := values.String("name")
displayCategory := values.String("display_category")
setChoice := values.String("entitlement_set_id")
// design D6 (round 4; maintainer 2026-09-03: "make it a checkbox with a
// label with a tooltip"): Visibility is one checkbox named "visibility"
// with value "public". An unticked checkbox submits nothing, so absence
// means Unlisted, which is the wrap-product path: publish immediately,
// never public, no price required.
isPublic := values.Bool("visibility")
// design D14: the select's first and default option creates the set
// with the product, in one transaction, so a deployment with no set yet
// can still create its first product and the checklist's entitlement
// step stops blocking the products step.
product, err := h.createProductWithSet(r.Context(), setChoice, billing.CreateProductParams{
Name: name,
DisplayCategory: sql.NullString{String: displayCategory, Valid: displayCategory != ""},
IsActive: true,
IsPublic: isPublic,
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 {
for field, msg := range fe {
errs.Field(field, msg)
}
h.renderProductNewRefusal(w, r, values, errs)
return
}
h.Logger.Error("failed to create product", slog.Any("error", err))
errs.Form("Failed to create the product. Details are in the server logs.")
h.renderProductNewRefusal(w, r, values, errs)
return
}
// Create-then-land (design D20): the create page, then land on the
// record. The new product's own page carries the next step (the
// readiness panel, which links the set's rules, and the price panel's
// opener).
redirectToRecord(w, r, "/operator/products/"+product.ProductID+"?flash=created")
}
// createProductWithSet creates the product and, when the create form's
// entitlement-set select was left on its first option, the set that
// product delivers, in one transaction (design D14, product-management
// "The default option creates the set with the product"). Either both rows
// exist or neither does, so a failed product creation never leaves an
// orphan set on the entitlement-sets surface.
//
// Any other choice is an existing set the select offered, which ParseSide
// has already checked against the same option list the control rendered,
// so an identifier reaching here is one the deployment holds.
func (h *OperatorPartialsHandler) createProductWithSet(ctx context.Context, choice string, params billing.CreateProductParams) (billing.Product, error) {
if choice != productSetOptionNew {
// One row, so the handler's own queriers write it, the way every
// other creation on this surface does.
parsed, err := uuid.Parse(choice)
if err != nil {
return billing.Product{}, err
}
params.EntitlementSetID = uuid.NullUUID{UUID: parsed, Valid: true}
return h.BillingQ.CreateProduct(ctx, params)
}
// Two rows, so one transaction. This is the one write on this surface
// that opens its own: the queriers the handler holds are per-schema
// interfaces with no shared transaction handle, and the set and the
// product must land together or not at all.
tx, err := h.Database.BeginTx(ctx, nil)
if err != nil {
return billing.Product{}, err
}
defer func() { _ = tx.Rollback() }()
set, err := entitlements.New(tx).CreateEntitlementSet(ctx, entitlements.CreateEntitlementSetParams{
Name: params.Name,
Description: sql.NullString{String: "Created with the product " + params.Name + ".", Valid: true},
IsActive: true,
})
if err != nil {
return billing.Product{}, err
}
parsed, err := uuid.Parse(set.SetID)
if err != nil {
return billing.Product{}, err
}
params.EntitlementSetID = uuid.NullUUID{UUID: parsed, Valid: true}
product, err := billing.New(tx).CreateProduct(ctx, params)
if err != nil {
return billing.Product{}, err
}
if err := tx.Commit(); err != nil {
return billing.Product{}, err
}
return product, nil
}
// UpdateProduct handles PUT /partials/operator/products/{productID},
// reading the body through the same declaration the create page posts
// through, on its edit side (form-library). The edit side's
// entitlement-set select opens on "None", whose empty value is a
// legitimate submission that unsets the column, so requiredness differs
// between the two sides through the option list rather than through two
// hand-written rules that could drift (finding FA-1).
func (h *OperatorPartialsHandler) UpdateProduct(w http.ResponseWriter, r *http.Request) {
productID := r.PathValue("productID")
sets := h.loadEntitlementSetOptionsFor(r, productID)
values, errs := productForm.ParseSide(r, forms.EditOnly, productFormOptions(forms.EditOnly, sets))
if errs.Any() {
h.renderProductEditRefusal(w, r, productID, values, errs)
return
}
name := values.String("name")
displayCategory := values.String("display_category")
description := values.String("description")
esNullUUID := uuid.NullUUID{}
if id, ok := values.UUID("entitlement_set_id"); ok {
esNullUUID = uuid.NullUUID{UUID: id, Valid: true}
}
isActive := values.Bool("is_active")
// design D6 (round 4): the edit form presents the same Visibility
// checkbox as the create page. An unticked checkbox submits nothing, so
// absence is Unlisted.
isPublic := values.Bool("visibility")
// Build metadata from features input
featuresRaw := values.String("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 {
for field, msg := range fe {
errs.Field(field, msg)
}
h.renderProductEditRefusal(w, r, productID, values, errs)
return
}
h.Logger.Error("failed to update product", slog.Any("error", err))
errs.Form("Failed to update the product. Details are in the server logs.")
h.renderProductEditRefusal(w, r, productID, values, errs)
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)
}
// renderProductEditRefusal re-renders the product's composite page at 422
// with the declaration in submission mode: every submitted value carried
// back, each field's error under its control, and a refusal that belongs
// to no field in the form-level slot the part always renders (design D9).
// The whole composite is the body because that is what a successful save
// answers with too, so the 200 and the 422 swap into one target
// (lesson L§17).
func (h *OperatorPartialsHandler) renderProductEditRefusal(w http.ResponseWriter, r *http.Request, productID string, values forms.Values, errs *forms.Errors) {
w.WriteHeader(http.StatusUnprocessableEntity)
edit, ok := h.loadProductEditData(r, productID)
if !ok {
h.renderProductsPage(w, r, "", "Product not found")
return
}
edit.Form = productEditForm(productID, edit.EntitlementSets, values, errs, productEditSlots(h.Templates, edit))
h.Templates.Render(w, "operator_product_detail.html", ProductDetailData{
Edit: edit,
Prices: h.loadProductPricesData(r, productID),
})
}
// productStatusFacets is the products list's lifecycle filter vocabulary
// (product-management "Lifecycle stays a fact and becomes a facet", design
// D2): All, Published, Draft, Retired ("All" is the pills' synthetic clear
// state listControls renders on its own).
var productStatusFacets = []FacetOption{
{Value: "published", Label: "Published"},
{Value: "draft", Label: "Draft"},
{Value: "retired", Label: "Retired"},
}
// productsListNav builds the list-controls view model for the products
// list: search by name plus the lifecycle-status facet.
func (h *OperatorPartialsHandler) productsListNav(r *http.Request) (ListNav, string) {
params := ParseListParams(r, "status")
facet := ValidFacet(params.Facet, productStatusFacets)
nav := ListNav{
BasePath: "/operator/products",
SearchPlaceholder: "Search by name",
FacetParam: "status",
FacetOptions: productStatusFacets,
Q: params.Q,
Facet: facet,
Page: params.Page,
}
return nav, facet
}
// loadProductsPageData hydrates the products listing: a paged, searched,
// lifecycle-filtered view governed by the list scaffold (operator-list-scale;
// product-management "The list takes the scaffold", design D3), with the
// per-row purchasability verdict filled from the page's batch reads (design
// D4), never one query per row. Shared by the legacy partial endpoint
// (renderProductsPage) and the MPA page handler (GetProductsPage).
func (h *OperatorPartialsHandler) loadProductsPageData(r *http.Request, success string, errMsg string) OperatorProductsData {
ctx := r.Context()
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."
}
nav, facet := h.productsListNav(r)
params := ListParams{Q: nav.Q, Facet: facet, Page: nav.Page}
// 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.
rows, total, err := FetchPage(&params, func(limit, offset int32) ([]billing.ListProductsPageRow, int64, error) {
page, pErr := h.BillingQ.ListProductsPage(ctx, billing.ListProductsPageParams{
Q: sql.NullString{String: params.Q, Valid: params.Q != ""},
LifecycleStatus: sql.NullString{String: facet, Valid: facet != ""},
PageLimit: limit,
PageOffset: offset,
})
if pErr != nil || len(page) == 0 {
return page, 0, pErr
}
return page, page[0].TotalCount, nil
})
nav.Page, nav.Total = params.Page, total
data.Nav = nav
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 = h.buildProductListViewModels(ctx, rows)
}
// Catalog overview strip: lifecycle counts across the WHOLE catalog
// (ProductCatalogSummary's doc comment), independent of the page and
// facet on screen (design D5 "the catalog overview strip stays as it
// is") — a separate full read from the now-paged list query.
if allProducts, err := h.BillingQ.ListAllProducts(ctx); err != nil {
h.Logger.Error("failed to load catalog overview counts", slog.Any("error", err))
} else {
summary := ProductCatalogSummary{Total: len(allProducts)}
for _, p := range allProducts {
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(ctx); 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()
return data
}
// buildProductListViewModels turns one page of ListProductsPage rows into
// view models, filling each row's purchasability verdict (StatusBadge/
// StatusNote) from four batch reads for the whole page — the shapes, the
// active prices, their Stripe mappings, and the referenced entitlement
// sets' rules (design D4) — plus the resource-key and integration-config
// lookups the provider-configuration leg needs, each read once per page
// rather than once per product.
func (h *OperatorPartialsHandler) buildProductListViewModels(ctx context.Context, rows []billing.ListProductsPageRow) []OperatorProductViewModel {
if len(rows) == 0 {
return nil
}
productIDs := make([]string, len(rows))
var setIDs []string
seenSet := make(map[string]bool, len(rows))
for i, p := range rows {
productIDs[i] = p.ProductID
if p.EntitlementSetID.Valid {
id := p.EntitlementSetID.UUID.String()
if !seenSet[id] {
seenSet[id] = true
setIDs = append(setIDs, id)
}
}
}
shapes, err := h.BillingQ.ListProductShapesByIDs(ctx, productIDs)
if err != nil {
h.Logger.Error("failed to batch-load product shapes", slog.Any("error", err))
}
// The rows' set names in one read (design D4, folded in 2026-09-06):
// a page's products reference a few sets, so the names come from one
// batch and a map, never from one lookup per row.
nameBySet := make(map[string]string, len(setIDs))
if len(setIDs) > 0 {
sets, err := h.EntitlementsQ.ListEntitlementSetsByIDs(ctx, setIDs)
if err != nil {
h.Logger.Error("failed to batch-load entitlement sets", slog.Any("error", err))
}
for _, es := range sets {
nameBySet[es.SetID] = es.Name
}
}
shapeByProduct := make(map[string]billing.CoreProductShape, len(shapes))
for _, s := range shapes {
shapeByProduct[s.ProductID] = s
}
prices, err := h.BillingQ.ListPricesByProductIDs(ctx, productIDs)
if err != nil {
h.Logger.Error("failed to batch-load prices", slog.Any("error", err))
}
pricesByProduct := make(map[string][]billing.Price, len(productIDs))
var priceIDs []string
for _, p := range prices {
pricesByProduct[p.ProductID] = append(pricesByProduct[p.ProductID], p)
priceIDs = append(priceIDs, p.PriceID)
}
mappingByPrice := make(map[string]internalstripe.PriceMapping, len(priceIDs))
// Mirrors computePriceReadiness's guard: stripeQ is nil whenever Stripe
// is not configured for this deployment, so it is never dereferenced
// (a price is simply never mapped in that case).
if len(priceIDs) > 0 && h.StripeConfigured && h.StripeQ != nil {
mappings, err := h.StripeQ.ListPriceMappingsByPriceIDs(ctx, priceIDs)
if err != nil {
h.Logger.Error("failed to batch-load price mappings", slog.Any("error", err))
}
for _, m := range mappings {
mappingByPrice[m.PriceID] = m
}
}
rulesBySet := make(map[string][]entitlements.EntitlementSetRule, len(setIDs))
if len(setIDs) > 0 {
rules, err := h.EntitlementsQ.ListEntitlementSetRulesBySetIDs(ctx, setIDs)
if err != nil {
h.Logger.Error("failed to batch-load entitlement set rules", slog.Any("error", err))
}
for _, rule := range rules {
rulesBySet[rule.SetID] = append(rulesBySet[rule.SetID], rule)
}
}
resourceKeys, err := h.EntitlementsQ.ListResourceKeys(ctx)
if err != nil {
h.Logger.Error("failed to load resource keys for readiness", slog.Any("error", err))
}
keyByResource := make(map[string]entitlements.ResourceKey, len(resourceKeys))
for _, k := range resourceKeys {
keyByResource[k.ResourceKey] = k
}
vms := make([]OperatorProductViewModel, len(rows))
for i, p := range rows {
esID, esName := "", ""
if p.EntitlementSetID.Valid {
esID = p.EntitlementSetID.UUID.String()
esName = nameBySet[esID]
}
desc := ""
if p.Description.Valid {
desc = p.Description.String
}
product := billing.Product{
ProductID: p.ProductID,
Name: p.Name,
DisplayCategory: p.DisplayCategory,
IsActive: p.IsActive,
IsPublic: p.IsPublic,
LifecycleStatus: p.LifecycleStatus,
}
pr := priceReadinessFromBatches(h.StripeConfigured, pricesByProduct[p.ProductID], mappingByPrice)
var providerRows []ProductReadinessRow
if esID != "" {
providerRows = resolveProviderReadinessRows(rulesBySet[esID], keyByResource, h.IntegrationConfigs)
}
vm := buildProductReadinessVM(readinessInputs{
product: product,
shape: shapeByProduct[p.ProductID],
price: pr,
providers: providerRows,
// product_shape's active_rule_count is the sole source of
// whether a product's set provides anything (product-catalog;
// design.md M8, Decision 148): no count beside the view.
activeRules: int(shapeByProduct[p.ProductID].ActiveRuleCount),
stripeMode: h.StripeMode,
}, false, "")
state, note := productListStatus(p.LifecycleStatus, p.IsPublic, vm)
vms[i] = OperatorProductViewModel{
ProductID: p.ProductID,
Name: p.Name,
Description: desc,
DisplayCategory: p.DisplayCategory.String,
IsActive: p.IsActive,
IsPublic: p.IsPublic,
LifecycleStatus: p.LifecycleStatus,
EntitlementSetID: esID,
EntitlementSetName: esName,
CreatedAt: p.CreatedAt.Format("Jan 2, 2006"),
Lifecycle: StatusBadge(p.LifecycleStatus).Label,
VerdictState: state,
StatusNote: note,
}
}
return vms
}
// productEntitlementSetGate is the products list's empty state. It no
// longer blocks: the create page's entitlement-set select opens on "New
// set named after this product" and creates the set with the product
// (design D14, product-management), so a deployment with no set is not a
// blocked state to disclose and the Products step of the setup checklist
// waits on nothing (operator-setup-checklist). The list section header's
// own "New product" link is the action, so the empty state does not
// repeat it (design D20).
func (h *OperatorPartialsHandler) productEntitlementSetGate() EmptyStateParams {
return EmptyStateParams{
Headline: "No products yet.",
}
}
// 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,
LifecycleStatus: product.LifecycleStatus,
EntitlementSetID: esID,
Features: features,
Key: product.Key.String,
CreatedAt: product.CreatedAt.Format("Jan 2, 2006"),
Lifecycle: StatusBadge(product.LifecycleStatus).Label,
}
data.EntitlementSets = h.loadEntitlementSetOptionsFor(r, productID)
// 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,
LadderName: l.LadderName,
Rank: l.Rank,
}
}
}
// Duplicate-name warning, rendered server-side at load (spec scenario:
// "Existing duplicate is disclosed on the edit page") — excludes this
// product itself so it never warns about matching its own name.
data.NameWarning = OperatorProductNameWarningData{
Matches: h.loadProductNameMatches(r, product.Name, productID),
}
// 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))
}
providerRows := h.loadProviderReadinessRows(r.Context(), esID)
data.Readiness = buildProductReadinessVM(readinessInputs{
product: product,
shape: shape,
price: pr,
providers: providerRows,
// product_shape's active_rule_count is the sole source of whether a
// product's set provides anything (product-catalog; design.md M8,
// Decision 148): no count beside the view.
activeRules: int(shape.ActiveRuleCount),
stripeMode: h.StripeMode,
}, syncFailed, syncErr)
// The edit form is the product declaration bound to this record: the
// same field list the create page renders unbound, with the three
// edit-only fields and their reasons (design D5, D8).
data.Form = productEditForm(productID, data.EntitlementSets, productEditValues(data.Product), nil, productEditSlots(h.Templates, data))
return data, true, nil
}
// loadEntitlementSetOptionsFor lists the active entitlement sets a
// product's edit form may offer, plus the product's own set when it has
// been deactivated since (ListActiveEntitlementSets omits inactive sets).
// Without the injection the select would silently preselect the first
// active set and Save would rewrite entitlement_set_id to a different
// set's rules; carrying it, labelled "(current; inactive)", makes Save
// round-trip the existing assignment faithfully (finding #38).
func (h *OperatorPartialsHandler) loadEntitlementSetOptionsFor(r *http.Request, productID string) []EntitlementSetOption {
options := h.loadEntitlementSetOptions(r)
product, err := h.BillingQ.GetProductByID(r.Context(), productID)
if err != nil || !product.EntitlementSetID.Valid {
return options
}
esID := product.EntitlementSetID.UUID.String()
for _, opt := range options {
if opt.SetID == esID {
return options
}
}
if es, err := h.EntitlementsQ.GetEntitlementSetByID(r.Context(), esID); err == nil {
options = append(options, EntitlementSetOption{SetID: es.SetID, Name: es.Name, Inactive: true})
}
return options
}
// loadProviderReadinessRows resolves the provider-configuration readiness
// leg (product-management "Readiness includes the delivering provider's
// configuration", ACC-3) for one product's entitlement set: for every
// resource key its active rules reference, the owning provider's required
// configuration must be resolved. setID == "" (no entitlement set assigned)
// yields no rows — the entitlement-set-present row already reports that
// precondition. Best-effort: a query failure logs and yields no rows rather
// than blocking the rest of the readiness panel. How many active rules the
// set holds is not returned: product_shape's active_rule_count is the sole
// source of that answer (product-catalog; design.md M8, Decision 148).
func (h *OperatorPartialsHandler) loadProviderReadinessRows(ctx context.Context, setID string) []ProductReadinessRow {
if setID == "" {
return nil
}
rules, err := h.EntitlementsQ.GetActiveRulesBySetID(ctx, setID)
if err != nil {
h.Logger.Error("failed to load entitlement set rules for readiness", slog.Any("error", err), slog.String("set_id", setID))
return nil
}
keys, err := h.EntitlementsQ.ListResourceKeys(ctx)
if err != nil {
h.Logger.Error("failed to load resource keys for readiness", slog.Any("error", err))
return nil
}
byKey := make(map[string]entitlements.ResourceKey, len(keys))
for _, k := range keys {
byKey[k.ResourceKey] = k
}
return resolveProviderReadinessRows(rules, byKey, h.IntegrationConfigs)
}
// 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, ok := h.pathUUID(w, r, "productID")
if !ok {
return
}
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. 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)
// design D20, round 2 ("a success toast, not a banner"): a fresh
// ?flash=created landing shows a success toast, the same mechanism as
// the ?flash=missing bounce's alert is not — that one stays an in-page
// error banner on the list page.
if r.URL.Query().Get("flash") == "created" {
page.FlashSuccess = "Product created."
}
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
}
// GetProductNameCheck handles GET /partials/operator/products/name-check —
// the live, non-blocking duplicate-name warning (design D4): products may
// legitimately share a name (a "Pro" tier on two ladders), so the create and
// edit forms never block submission on it, only warn. A blank or
// whitespace-only `name`, or no other product carrying it, renders an empty
// body so the warning container under the field clears. `exclude` (a
// product_id) lets the edit form leave its own product out of its own
// duplicate check.
func (h *OperatorPartialsHandler) GetProductNameCheck(w http.ResponseWriter, r *http.Request) {
matches := h.loadProductNameMatches(r, r.URL.Query().Get("name"), r.URL.Query().Get("exclude"))
h.Templates.Render(w, "operator_product_name_warning.html", OperatorProductNameWarningData{Matches: matches})
}
// GetProductRuleCheck handles GET /partials/operator/products/rule-check —
// the rule-less warning's live region, fired on change from a product select
// on the tier-add panel and the Issue grant panel. It reads, writes nothing,
// and answers with an empty region when the selected product's set carries
// an active rule (design.md M1).
func (h *OperatorPartialsHandler) GetProductRuleCheck(w http.ResponseWriter, r *http.Request) {
h.Templates.Render(w, "operator_rule_less_warning.html",
h.ruleLessWarning(r.Context(), r.URL.Query().Get("product_id")))
}
// productNameMatchBuilder accumulates one matching product's ladder names
// while ListProductsByNameCI's rows (one per product/ladder pair) are
// grouped in Go.
type productNameMatchBuilder struct {
ProductID string
Name string
ladderNames []string
}
// loadProductNameMatches looks up other products sharing name, ignoring
// case, excluding excludeProductID (the edit form's own product, or "" on
// create), and groups each match's ladder memberships into a display
// phrase. Used by both the live name-check partial and the edit page's
// server-side render at load. A blank name, or a query/parse failure,
// returns nil (no warning) rather than surfacing an error — this is an
// advisory, non-blocking UI affordance.
func (h *OperatorPartialsHandler) loadProductNameMatches(r *http.Request, name string, excludeProductID string) []ProductNameMatch {
name = strings.TrimSpace(name)
if name == "" {
return nil
}
var excludeArg uuid.NullUUID
if excludeProductID != "" {
if parsed, err := uuid.Parse(excludeProductID); err == nil {
excludeArg = uuid.NullUUID{UUID: parsed, Valid: true}
}
}
rows, err := h.BillingQ.ListProductsByNameCI(r.Context(), billing.ListProductsByNameCIParams{
Name: name,
ExcludeProductID: excludeArg,
})
if err != nil {
h.Logger.Error("failed to check for duplicate product names", slog.Any("error", err))
return nil
}
order := make([]string, 0, len(rows))
byProduct := make(map[string]*productNameMatchBuilder, len(rows))
for _, row := range rows {
b, ok := byProduct[row.ProductID]
if !ok {
b = &productNameMatchBuilder{ProductID: row.ProductID, Name: row.Name}
byProduct[row.ProductID] = b
order = append(order, row.ProductID)
}
if row.LadderName.Valid {
b.ladderNames = append(b.ladderNames, row.LadderName.String)
}
}
matches := make([]ProductNameMatch, 0, len(order))
for _, id := range order {
b := byProduct[id]
phrase := "not on any ladder"
if len(b.ladderNames) > 0 {
phrase = "a tier of " + strings.Join(b.ladderNames, ", ")
}
matches = append(matches, ProductNameMatch{
ProductID: b.ProductID,
Name: b.Name,
LadderPhrase: phrase,
})
}
return matches
}