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