312 lines
9.3 KiB
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
|
|
}
|