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.
1097 lines
44 KiB
Go
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(¶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
|
|
}
|