// 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(¶ms, 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 }