package server import ( "database/sql" "html/template" "io/fs" "log/slog" "net/http" "strconv" "git.coopcloud.tech/wiki-cafe/member-console/internal/auth" "git.coopcloud.tech/wiki-cafe/member-console/internal/billing" "git.coopcloud.tech/wiki-cafe/member-console/internal/embeds" "git.coopcloud.tech/wiki-cafe/member-console/internal/entitlements" fwmod "git.coopcloud.tech/wiki-cafe/member-console/internal/fedwiki" "git.coopcloud.tech/wiki-cafe/member-console/internal/identity" "git.coopcloud.tech/wiki-cafe/member-console/internal/organization" stripedb "git.coopcloud.tech/wiki-cafe/member-console/internal/stripe" "github.com/google/uuid" "go.temporal.io/sdk/client" ) // OperatorRole is the OIDC role required to access operator pages const OperatorRole = "operator-member" // OperatorPartialsHandler handles HTMX partial requests for operator pages type OperatorPartialsHandler struct { SiteQ fwmod.Querier EntitlementsQ entitlements.Querier BillingQ billing.Querier StripeQ stripedb.Querier Database *sql.DB IdentityQ identity.Querier OrgQ organization.Querier Logger *slog.Logger AuthConfig *auth.Config Templates *SafeTemplates StripeDashboardURL string TemporalClient client.Client } // OperatorPartialsConfig holds configuration for the operator partials handler type OperatorPartialsConfig struct { SiteQ fwmod.Querier EntitlementsQ entitlements.Querier BillingQ billing.Querier StripeQ stripedb.Querier Database *sql.DB IdentityQ identity.Querier OrgQ organization.Querier Logger *slog.Logger AuthConfig *auth.Config StripeDashboardURL string TemporalClient client.Client } // NewOperatorPartialsHandler creates a new OperatorPartialsHandler func NewOperatorPartialsHandler(cfg OperatorPartialsConfig) (*OperatorPartialsHandler, error) { // Parse partial templates templateSubFS, err := fs.Sub(embeds.Templates, "templates/partials") if err != nil { return nil, err } tmpl, err := template.ParseFS(templateSubFS, "operator_*.html") if err != nil { return nil, err } return &OperatorPartialsHandler{ SiteQ: cfg.SiteQ, EntitlementsQ: cfg.EntitlementsQ, BillingQ: cfg.BillingQ, StripeQ: cfg.StripeQ, Database: cfg.Database, IdentityQ: cfg.IdentityQ, OrgQ: cfg.OrgQ, Logger: cfg.Logger, AuthConfig: cfg.AuthConfig, Templates: NewSafeTemplates(tmpl, cfg.Logger), StripeDashboardURL: cfg.StripeDashboardURL, TemporalClient: cfg.TemporalClient, }, nil } // RegisterRoutes registers all operator HTMX partial routes func (h *OperatorPartialsHandler) RegisterRoutes(mux *http.ServeMux) { mux.HandleFunc("GET /partials/operator/users", h.requireOperatorRole(h.GetPersons)) mux.HandleFunc("GET /partials/operator/organizations", h.requireOperatorRole(h.GetOrganizations)) mux.HandleFunc("GET /partials/operator/sites", h.requireOperatorRole(h.GetSites)) // Grant management mux.HandleFunc("GET /partials/operator/grants", h.requireOperatorRole(h.GetGrants)) mux.HandleFunc("POST /partials/operator/grants", h.requireOperatorRole(h.CreateGrant)) mux.HandleFunc("POST /partials/operator/grants/{grantID}/revoke", h.requireOperatorRole(h.RevokeGrant)) // Product management mux.HandleFunc("GET /partials/operator/products", h.requireOperatorRole(h.GetProducts)) mux.HandleFunc("POST /partials/operator/products", h.requireOperatorRole(h.CreateProduct)) mux.HandleFunc("GET /partials/operator/products/{productID}/edit", h.requireOperatorRole(h.GetProductEdit)) mux.HandleFunc("PUT /partials/operator/products/{productID}", h.requireOperatorRole(h.UpdateProduct)) // Entitlement set management mux.HandleFunc("GET /partials/operator/entitlement-sets", h.requireOperatorRole(h.GetEntitlementSets)) mux.HandleFunc("POST /partials/operator/entitlement-sets", h.requireOperatorRole(h.CreateEntitlementSet)) mux.HandleFunc("GET /partials/operator/entitlement-sets/{setID}/edit", h.requireOperatorRole(h.GetEntitlementSetEdit)) mux.HandleFunc("PUT /partials/operator/entitlement-sets/{setID}", h.requireOperatorRole(h.UpdateEntitlementSet)) mux.HandleFunc("GET /partials/operator/entitlement-sets/{setID}/rules", h.requireOperatorRole(h.GetEntitlementSetRules)) mux.HandleFunc("POST /partials/operator/entitlement-sets/{setID}/rules", h.requireOperatorRole(h.CreateEntitlementSetRule)) mux.HandleFunc("DELETE /partials/operator/entitlement-sets/{setID}/rules/{ruleID}", h.requireOperatorRole(h.DeleteEntitlementSetRule)) // Organization type management mux.HandleFunc("GET /partials/operator/org-types", h.requireOperatorRole(h.GetOrgTypes)) mux.HandleFunc("POST /partials/operator/org-types/{orgType}/default-plan", h.requireOperatorRole(h.UpdateOrgTypeDefaultPlan)) mux.HandleFunc("POST /partials/operator/org-types/{orgType}/backfill", h.requireOperatorRole(h.BackfillOrgType)) // Enrollment & transition tools mux.HandleFunc("GET /partials/operator/organizations/{orgID}/enrollment", h.requireOperatorRole(h.GetOrgEnrollment)) mux.HandleFunc("POST /partials/operator/organizations/{orgID}/pools/{poolID}/grant", h.requireOperatorRole(h.IssueGrant)) mux.HandleFunc("POST /partials/operator/organizations/{orgID}/pools/{poolID}/grant/extend", h.requireOperatorRole(h.ExtendGrant)) mux.HandleFunc("POST /partials/operator/grants/{grantID}/revoke-and-transition", h.requireOperatorRole(h.RevokeGrantAndTransition)) // Billing views mux.HandleFunc("GET /partials/operator/billing/accounts", h.requireOperatorRole(h.GetBillingAccounts)) mux.HandleFunc("GET /partials/operator/billing/subscriptions", h.requireOperatorRole(h.GetSubscriptions)) mux.HandleFunc("GET /partials/operator/billing/invoices", h.requireOperatorRole(h.GetInvoices)) mux.HandleFunc("GET /partials/operator/billing/payments", h.requireOperatorRole(h.GetPayments)) // Product prices mux.HandleFunc("GET /partials/operator/products/{productID}/prices", h.requireOperatorRole(h.GetProductPrices)) mux.HandleFunc("POST /partials/operator/products/{productID}/prices", h.requireOperatorRole(h.CreatePrice)) // Plan ladder management mux.HandleFunc("GET /partials/operator/plan-ladders", h.requireOperatorRole(h.GetPlanLadders)) mux.HandleFunc("POST /partials/operator/plan-ladders", h.requireOperatorRole(h.CreatePlanLadder)) mux.HandleFunc("GET /partials/operator/plan-ladders/{ladderID}/edit", h.requireOperatorRole(h.GetPlanLadderEdit)) mux.HandleFunc("PUT /partials/operator/plan-ladders/{ladderID}", h.requireOperatorRole(h.UpdatePlanLadder)) mux.HandleFunc("DELETE /partials/operator/plan-ladders/{ladderID}", h.requireOperatorRole(h.DeletePlanLadder)) mux.HandleFunc("GET /partials/operator/plan-ladders/{ladderID}/tiers", h.requireOperatorRole(h.GetPlanLadderTiers)) mux.HandleFunc("POST /partials/operator/plan-ladders/{ladderID}/tiers", h.requireOperatorRole(h.CreatePlanLadderTier)) mux.HandleFunc("PUT /partials/operator/plan-ladders/{ladderID}/tiers/{productID}/rank", h.requireOperatorRole(h.UpdatePlanLadderTierRank)) mux.HandleFunc("DELETE /partials/operator/plan-ladders/{ladderID}/tiers/{productID}", h.requireOperatorRole(h.DeletePlanLadderTier)) mux.HandleFunc("GET /partials/operator/plan-ladders/validation", h.requireOperatorRole(h.GetPlanLadderValidation)) } // requireOperatorRole is middleware that checks for the operator role func (h *OperatorPartialsHandler) requireOperatorRole(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if !h.AuthConfig.HasRole(r, OperatorRole) { h.Logger.Warn("operator partials access denied", slog.String("path", r.URL.Path), slog.String("reason", "missing operator-member role")) http.Error(w, "Forbidden", http.StatusForbidden) return } next(w, r) } } // PersonViewModel represents a person for operator template rendering type PersonViewModel struct { PersonID string DisplayName string Email string Status string CreatedAt string } // PersonsData holds data for the persons list partial type PersonsData struct { Persons []PersonViewModel Error string } // GetPersons handles GET /partials/operator/users — lists all persons func (h *OperatorPartialsHandler) GetPersons(w http.ResponseWriter, r *http.Request) { persons, err := h.IdentityQ.ListPersons(r.Context()) if err != nil { h.Logger.Error("failed to list persons", slog.Any("error", err)) h.renderError(w, "operator_users.html", "Failed to retrieve people") return } vms := make([]PersonViewModel, len(persons)) for i, p := range persons { vms[i] = PersonViewModel{ PersonID: p.PersonID, DisplayName: p.DisplayName, Email: p.PrimaryEmail, Status: p.Status, CreatedAt: p.CreatedAt.Format("Jan 2, 2006"), } } data := PersonsData{Persons: vms} h.Templates.Render(w, "operator_users.html", data) } // OrganizationViewModel represents an organization for operator template rendering type OrganizationViewModel struct { OrgID string Name string Slug string OrgType string Status string MemberCount int CreatedAt string } // OrganizationsData holds data for the organizations list partial type OrganizationsData struct { Organizations []OrganizationViewModel Error string } // GetOrganizations handles GET /partials/operator/organizations func (h *OperatorPartialsHandler) GetOrganizations(w http.ResponseWriter, r *http.Request) { orgs, err := h.OrgQ.ListOrganizations(r.Context()) if err != nil { h.Logger.Error("failed to list organizations", slog.Any("error", err)) h.renderError(w, "operator_organizations.html", "Failed to retrieve organizations") return } vms := make([]OrganizationViewModel, len(orgs)) for i, org := range orgs { memberCount := 0 members, err := h.OrgQ.GetOrgMembersByOrgID(r.Context(), org.OrgID) if err == nil { memberCount = len(members) } vms[i] = OrganizationViewModel{ OrgID: org.OrgID, Name: org.Name, Slug: org.Slug, OrgType: org.OrgType, Status: org.Status, MemberCount: memberCount, CreatedAt: org.CreatedAt.Format("Jan 2, 2006"), } } data := OrganizationsData{Organizations: vms} h.Templates.Render(w, "operator_organizations.html", data) } // OperatorSiteViewModel represents a site for operator template rendering type OperatorSiteViewModel struct { ID string Domain string OwnerOrgName string IsCustomDomain bool CreatedAt string } // OperatorSitesData holds data for the sites list partial type OperatorSitesData struct { Sites []OperatorSiteViewModel Error string } // GetSites handles GET /partials/operator/sites func (h *OperatorPartialsHandler) GetSites(w http.ResponseWriter, r *http.Request) { sites, err := h.SiteQ.ListAllSites(r.Context()) if err != nil { h.Logger.Error("failed to get all sites", slog.Any("error", err)) h.renderError(w, "operator_sites.html", "Failed to retrieve sites") return } // Build a cache of workspace_id → org name to avoid repeated lookups orgNameCache := make(map[string]string) siteVMs := make([]OperatorSiteViewModel, len(sites)) for i, site := range sites { orgName := "" if cached, ok := orgNameCache[site.WorkspaceID]; ok { orgName = cached } else { ws, err := h.OrgQ.GetWorkspaceByID(r.Context(), site.WorkspaceID) if err == nil { org, err := h.OrgQ.GetOrganizationByID(r.Context(), ws.OrgID) if err == nil { orgName = org.Name } } orgNameCache[site.WorkspaceID] = orgName } siteVMs[i] = OperatorSiteViewModel{ ID: site.SiteID, Domain: site.Domain, OwnerOrgName: orgName, IsCustomDomain: site.IsCustomDomain, CreatedAt: site.CreatedAt.Format("Jan 2, 2006"), } } data := OperatorSitesData{Sites: siteVMs} h.Templates.Render(w, "operator_sites.html", data) } // Helper functions func (h *OperatorPartialsHandler) renderError(w http.ResponseWriter, tmplName string, message string) { data := struct{ Error string }{Error: message} h.Templates.Render(w, tmplName, data) } // GrantViewModel represents a grant for operator template rendering. type GrantViewModel struct { GrantID string OrgName string ProductName string EntitlementSetName string Quantity int32 GrantReason string Status string CreatedAt string } // GrantsData holds data for the grants management partial. type GrantsData struct { Grants []GrantViewModel Organizations []OrganizationViewModel Products []ProductViewModel EntitlementSets []EntitlementSetOption Success string Error string } // ProductViewModel represents a product for the grant creation form. type ProductViewModel struct { ProductID string Name string ProductType string } // GetGrants handles GET /partials/operator/grants — renders the grant management section. func (h *OperatorPartialsHandler) GetGrants(w http.ResponseWriter, r *http.Request) { h.renderGrantsPage(w, r, "", "") } // CreateGrant handles POST /partials/operator/grants — creates a grant and re-renders the page. func (h *OperatorPartialsHandler) CreateGrant(w http.ResponseWriter, r *http.Request) { session := h.AuthConfig.GetUserSession(r.Context()) if session == nil { h.renderGrantsPage(w, r, "", "Unauthorized") return } if err := r.ParseForm(); err != nil { h.renderGrantsPage(w, r, "", "Invalid request") return } grantPath := r.FormValue("grant_path") productID := r.FormValue("product_id") entitlementSetID := r.FormValue("entitlement_set_id") orgID := r.FormValue("org_id") reason := r.FormValue("reason") quantityStr := r.FormValue("quantity") // Validate: exactly one path must be selected if grantPath != "product" && grantPath != "entitlement_set" { h.renderGrantsPage(w, r, "", "Select either Product or Entitlement Set path") return } if grantPath == "product" && productID == "" { h.renderGrantsPage(w, r, "", "Product is required for product path") return } if grantPath == "entitlement_set" && entitlementSetID == "" { h.renderGrantsPage(w, r, "", "Entitlement set is required for entitlement set path") return } if orgID == "" { h.renderGrantsPage(w, r, "", "Organization is required") return } quantity := int32(1) if quantityStr != "" { if q, err := strconv.Atoi(quantityStr); err == nil && q > 0 { quantity = int32(q) } } // Safety guard: plan-tier products must go through the Enrollment page // so that Transition() is invoked and ladder attachments are created. if grantPath == "product" && productID != "" { ladders, err := h.BillingQ.ListLaddersByProduct(r.Context(), productID) if err == nil && len(ladders) > 0 { h.renderGrantsPage(w, r, "", "Plan products are granted through the organization's Enrollment tab; the general Grants tab is for addons and one-time products only.") return } } input := entitlements.CreateGrantInput{ OrgID: orgID, GrantedByPersonID: session.PersonID, GrantReason: reason, Quantity: quantity, } if grantPath == "product" { input.ProductID = productID } else { input.EntitlementSetID = entitlementSetID } _, err := entitlements.CreateGrantAndMaterialize(r.Context(), h.Database, input) if err != nil { h.Logger.Error("failed to create grant", slog.Any("error", err)) h.renderGrantsPage(w, r, "", "Failed to create grant: "+err.Error()) return } h.renderGrantsPage(w, r, "Grant created successfully.", "") } // RevokeGrant handles POST /partials/operator/grants/{grantID}/revoke — revokes a grant and re-renders. func (h *OperatorPartialsHandler) RevokeGrant(w http.ResponseWriter, r *http.Request) { session := h.AuthConfig.GetUserSession(r.Context()) if session == nil { h.renderGrantsPage(w, r, "", "Unauthorized") return } grantID := r.PathValue("grantID") if grantID == "" { h.renderGrantsPage(w, r, "", "Grant ID is required") return } grantUUID, err := uuid.Parse(grantID) if err != nil { h.renderGrantsPage(w, r, "", "Invalid grant ID") return } // Pre-tx peek to find the provision's pool, so we know which row to lock. // State may change between this read and the lock — we re-resolve the // ladder attachment after the lock to make the decision authoritative. provision, err := h.EntitlementsQ.GetPoolProvisionByGrantID(r.Context(), uuid.NullUUID{UUID: grantUUID, Valid: true}) if err != nil || provision.ProvisionID == "" { // No provision (revoked grant, or never provisioned). Plain revoke path. if err := entitlements.RevokeGrantAndRematerialize(r.Context(), h.Database, grantID, session.PersonID, "operator_revocation"); err != nil { h.Logger.Error("failed to revoke grant", slog.Any("error", err)) h.renderGrantsPage(w, r, "", "Failed to revoke grant: "+err.Error()) return } h.renderGrantsPage(w, r, "Grant revoked successfully.", "") return } tx, err := h.Database.BeginTx(r.Context(), nil) if err != nil { h.Logger.Error("failed to begin transaction", slog.Any("error", err)) h.renderGrantsPage(w, r, "", "Transaction failed") return } defer tx.Rollback() // Lock ordering: pool → grant → provision → ladder. if _, err := tx.ExecContext(r.Context(), "SELECT 1 FROM entitlements.resource_pools WHERE pool_id = $1 FOR UPDATE", provision.PoolID); err != nil { h.Logger.Error("failed to lock pool row", slog.Any("error", err)) h.renderGrantsPage(w, r, "", "Pool lock failed") return } q := entitlements.New(tx) // Authoritative re-read inside the tx, after the lock. attachments, err := q.GetActiveAttachmentsByPool(r.Context(), provision.PoolID) if err != nil { h.Logger.Error("failed to read ladder attachments", slog.Any("error", err)) h.renderGrantsPage(w, r, "", "Failed to read pool state") return } hasLadderAttachment := false for _, att := range attachments { if att.ProvisionID == provision.ProvisionID { hasLadderAttachment = true break } } if _, err := q.RevokeGrant(r.Context(), entitlements.RevokeGrantParams{ GrantID: grantID, RevokedByPersonID: uuid.NullUUID{UUID: uuid.MustParse(session.PersonID), Valid: true}, RevocationReason: sql.NullString{String: "operator_revocation", Valid: true}, }); err != nil { h.Logger.Error("failed to revoke grant", slog.Any("error", err)) h.renderGrantsPage(w, r, "", "Failed to revoke grant: "+err.Error()) return } if hasLadderAttachment { // Transition(End) ends the prior provision itself, captures from_rank, // and re-applies the org-type default. Do NOT pre-end the provision — // that erases the from_rank context Transition needs to classify the // transition as a downgrade vs. initiate. if _, err := entitlements.Transition(r.Context(), tx, provision.PoolID, entitlements.TransitionTarget{End: true}, entitlements.TransitionActor{ ActorType: "operator", ActorID: uuid.NullUUID{UUID: uuid.MustParse(session.PersonID), Valid: true}, Reason: "operator_revocation", }); err != nil { h.Logger.Error("transition failed after revocation", slog.Any("error", err)) h.renderGrantsPage(w, r, "", "Transition failed: "+err.Error()) return } } else { // No ladder attachment — end the provision and rematerialize so the // pool reflects the change. No transition row (consistent with // non-plan revocations). if _, err := q.UpdatePoolProvisionStatus(r.Context(), entitlements.UpdatePoolProvisionStatusParams{ ProvisionID: provision.ProvisionID, Status: "ended", }); err != nil { h.Logger.Error("failed to end provision", slog.Any("error", err)) h.renderGrantsPage(w, r, "", "Failed to end provision: "+err.Error()) return } if err := entitlements.MaterializePoolEntitlements(r.Context(), q, provision.PoolID); err != nil { h.Logger.Error("failed to rematerialize pool", slog.Any("error", err)) h.renderGrantsPage(w, r, "", "Failed to rematerialize: "+err.Error()) return } } if err := tx.Commit(); err != nil { h.Logger.Error("failed to commit transaction", slog.Any("error", err)) h.renderGrantsPage(w, r, "", "Commit failed") return } h.renderGrantsPage(w, r, "Grant revoked successfully.", "") } // renderGrantsPage loads all data needed for the grants management page and renders it. func (h *OperatorPartialsHandler) renderGrantsPage(w http.ResponseWriter, r *http.Request, success string, errMsg string) { data := GrantsData{ Success: success, Error: errMsg, } if errMsg == "" { // Load organizations for the form orgs, err := h.OrgQ.ListOrganizations(r.Context()) if err != nil { h.Logger.Error("failed to list organizations", slog.Any("error", err)) data.Error = "Failed to load organizations" } else { data.Organizations = make([]OrganizationViewModel, len(orgs)) for i, org := range orgs { data.Organizations[i] = OrganizationViewModel{ OrgID: org.OrgID, Name: org.Name, Slug: org.Slug, } } } // Load products for the form products, err := h.BillingQ.ListActiveProducts(r.Context()) if err != nil { h.Logger.Error("failed to list products", slog.Any("error", err)) } else { data.Products = make([]ProductViewModel, len(products)) for i, p := range products { data.Products[i] = ProductViewModel{ ProductID: p.ProductID, Name: p.Name, ProductType: p.ProductType.String, } } } // Load active entitlement sets for the form data.EntitlementSets = h.loadEntitlementSetOptions(r) // Load all grants grants, err := h.EntitlementsQ.ListAllGrants(r.Context()) if err != nil { h.Logger.Error("failed to list grants", slog.Any("error", err)) } else { data.Grants = make([]GrantViewModel, len(grants)) for i, g := range grants { // Look up org name orgName := "" if g.GrantedToOrgID.Valid { org, err := h.OrgQ.GetOrganizationByID(r.Context(), g.GrantedToOrgID.UUID.String()) if err == nil { orgName = org.Name } } // Look up product name productName := "" if g.ProductID.Valid { if p, err := h.BillingQ.GetProductByID(r.Context(), g.ProductID.UUID.String()); err == nil { productName = p.Name } } // Look up entitlement set name (for direct entitlement set grants) entitlementSetName := "" if !g.ProductID.Valid && g.EntitlementSetID.Valid { if es, err := h.EntitlementsQ.GetEntitlementSetByID(r.Context(), g.EntitlementSetID.UUID.String()); err == nil { entitlementSetName = es.Name } } data.Grants[i] = GrantViewModel{ GrantID: g.GrantID, OrgName: orgName, ProductName: productName, EntitlementSetName: entitlementSetName, Quantity: g.Quantity, GrantReason: g.GrantReason, Status: g.Status, CreatedAt: g.CreatedAt.Format("Jan 2, 2006"), } } } } h.Templates.Render(w, "operator_grants.html", data) }