Files
member-console/internal/server/operator_products.go

312 lines
9.3 KiB
Go

package server
import (
"database/sql"
"encoding/json"
"log/slog"
"net/http"
"strings"
"git.coopcloud.tech/wiki-cafe/member-console/internal/billing"
"github.com/google/uuid"
"github.com/sqlc-dev/pqtype"
)
// OperatorProductViewModel represents a product for operator template rendering.
type OperatorProductViewModel struct {
ProductID string
Name string
Description string
ProductType 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
Success string
Error string
}
// EntitlementSetOption represents an entitlement set for dropdown selection.
type EntitlementSetOption struct {
SetID string
Name string
}
// 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
Success string
Error string
}
// GetProducts handles GET /partials/operator/products
func (h *OperatorPartialsHandler) GetProducts(w http.ResponseWriter, r *http.Request) {
h.renderProductsPage(w, r, "", "")
}
// 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")
productType := r.FormValue("product_type")
entitlementSetID := r.FormValue("entitlement_set_id")
if name == "" || entitlementSetID == "" {
h.renderProductsPage(w, r, "", "Name and entitlement set are required")
return
}
esUUID, err := uuid.Parse(entitlementSetID)
if err != nil {
h.renderProductsPage(w, r, "", "Invalid entitlement set ID")
return
}
isPublic := r.FormValue("is_public") == "true"
_, err = h.BillingQ.CreateProduct(r.Context(), billing.CreateProductParams{
Name: name,
ProductType: sql.NullString{String: productType, Valid: productType != ""},
IsActive: true,
IsPublic: isPublic,
EntitlementSetID: uuid.NullUUID{UUID: esUUID, Valid: true},
LifecycleStatus: "published",
})
if err != nil {
h.Logger.Error("failed to create product", slog.Any("error", err))
h.renderProductsPage(w, r, "", "Failed to create product: "+err.Error())
return
}
// Signal dependent tabs (Grants, Org Types) to re-fetch — they show product dropdowns
w.Header().Set("HX-Trigger", "productMutation")
h.renderProductsPage(w, r, "Product created successfully.", "")
}
// GetProductEdit handles GET /partials/operator/products/{productID}/edit
func (h *OperatorPartialsHandler) GetProductEdit(w http.ResponseWriter, r *http.Request) {
productID := r.PathValue("productID")
h.renderProductEditPage(w, r, productID, "", "")
}
// 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")
productType := r.FormValue("product_type")
entitlementSetID := r.FormValue("entitlement_set_id")
description := r.FormValue("description")
if name == "" || entitlementSetID == "" {
h.renderProductEditPage(w, r, productID, "", "Name and entitlement set are required")
return
}
esUUID, err := uuid.Parse(entitlementSetID)
if err != nil {
h.renderProductEditPage(w, r, productID, "", "Invalid entitlement set ID")
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 != ""},
ProductType: sql.NullString{String: productType, Valid: productType != ""},
IsActive: isActive,
IsPublic: isPublic,
EntitlementSetID: uuid.NullUUID{UUID: esUUID, Valid: true},
Metadata: metadata,
})
if err != nil {
h.Logger.Error("failed to update product", slog.Any("error", err))
h.renderProductEditPage(w, r, productID, "", "Failed to update product: "+err.Error())
return
}
// Signal dependent tabs (Grants, Org Types) to re-fetch — they show product dropdowns
w.Header().Set("HX-Trigger", "productMutation")
h.renderProductsPage(w, r, "Product updated successfully.", "")
}
func (h *OperatorPartialsHandler) renderProductsPage(w http.ResponseWriter, r *http.Request, success string, errMsg string) {
data := OperatorProductsData{
Success: success,
Error: errMsg,
}
if errMsg == "" {
// Load products
products, err := h.BillingQ.ListAllProducts(r.Context())
if err != nil {
h.Logger.Error("failed to list products", slog.Any("error", err))
data.Error = "Failed to load products"
} else {
data.Products = make([]OperatorProductViewModel, len(products))
for i, p := range products {
esName := ""
if p.EntitlementSetID.Valid {
if es, err := h.EntitlementsQ.GetEntitlementSetByID(r.Context(), p.EntitlementSetID.UUID.String()); err == nil {
esName = es.Name
}
}
desc := ""
if p.Description.Valid {
desc = p.Description.String
}
data.Products[i] = OperatorProductViewModel{
ProductID: p.ProductID,
Name: p.Name,
Description: desc,
ProductType: p.ProductType.String,
IsActive: p.IsActive,
IsPublic: p.IsPublic,
EntitlementSetID: p.EntitlementSetID.UUID.String(),
EntitlementSetName: esName,
CreatedAt: p.CreatedAt.Format("Jan 2, 2006"),
}
}
}
// Load entitlement sets for dropdown
data.EntitlementSets = h.loadEntitlementSetOptions(r)
}
h.Templates.Render(w, "operator_products.html", data)
}
func (h *OperatorPartialsHandler) renderProductEditPage(w http.ResponseWriter, r *http.Request, productID string, success string, errMsg string) {
data := OperatorProductEditData{
Success: success,
Error: errMsg,
}
product, err := h.BillingQ.GetProductByID(r.Context(), productID)
if err != nil {
h.Logger.Error("failed to get product", slog.Any("error", err))
h.renderProductsPage(w, r, "", "Product not found")
return
}
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.Product = OperatorProductViewModel{
ProductID: product.ProductID,
Name: product.Name,
Description: desc,
ProductType: product.ProductType.String,
IsActive: product.IsActive,
IsPublic: product.IsPublic,
EntitlementSetID: esID,
Features: features,
CreatedAt: product.CreatedAt.Format("Jan 2, 2006"),
}
data.EntitlementSets = h.loadEntitlementSetOptions(r)
// 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,
}
}
}
h.Templates.Render(w, "operator_product_edit.html", data)
}
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
}