Governed operator lists (organizations, grants, people, billing×4) gain
server-side search, status filters, and 50-row pages with true totals
from count(*) OVER(); state is URL-addressable, out-of-range pages
clamp,
and no-match is distinct from true-empty.
People is the eighth flat sidebar entry: /operator/persons lists persons
newest-joined first (excluding the reserved system person), rows linking
to the existing detail.
Billing gains an operator invoice detail at
/operator/billing/invoices/{invoiceID} reusing the member projection;
open invoices past due present as Overdue (derived, filterable, stored
status untouched); all four views lead with the linked organization and
mute object IDs.
Grants filter over the derived Live/Superseded/Inactive state, the SQL
HAVING predicate pinned to the Go derivation by test. Embedded lists
(org composite ledger, Tier changes) adopt the shared controls under
namespaced params with sibling-state-preserving URLs and scoped htmx
swaps that hold the viewport.
Review corrections: blocked ladder Delete renders disabled with tooltip
and mutations fire toasts; collapse triggers paint their open state;
sections use outside headings; plan topology drops the orphan-product
check; domains policy collapses behind a disclosure.
731 lines
33 KiB
Go
731 lines
33 KiB
Go
package server
|
|
|
|
import (
|
|
"bytes"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"fmt"
|
|
"html/template"
|
|
"io/fs"
|
|
"log/slog"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
"unicode/utf16"
|
|
|
|
"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/config"
|
|
"git.coopcloud.tech/wiki-cafe/member-console/internal/domains"
|
|
"git.coopcloud.tech/wiki-cafe/member-console/internal/embeds"
|
|
"git.coopcloud.tech/wiki-cafe/member-console/internal/entitlements"
|
|
"git.coopcloud.tech/wiki-cafe/member-console/internal/identity"
|
|
stripedb "git.coopcloud.tech/wiki-cafe/member-console/internal/integrations/stripe/store"
|
|
"git.coopcloud.tech/wiki-cafe/member-console/internal/organization"
|
|
"git.coopcloud.tech/wiki-cafe/member-console/internal/web"
|
|
"go.temporal.io/sdk/client"
|
|
)
|
|
|
|
// OperatorRole is the OIDC role required to access operator pages
|
|
const OperatorRole = "operator-member"
|
|
|
|
// fireSuccessToast sets the HX-Trigger response header so the operator's
|
|
// browser fires a `showSuccessToast` event with the given message. The
|
|
// `success-toast.js` driver listens for that event and renders the toast.
|
|
// Must be called BEFORE any w.Write — once the body starts streaming the
|
|
// header is locked. Empty msg is a no-op so callers can pass success
|
|
// strings through render helpers without conditional checks.
|
|
//
|
|
// This is the convention per docs/operator-ux-conventions.md §3: mutation
|
|
// success fires a toast, not a re-rendered alert banner.
|
|
func fireSuccessToast(w http.ResponseWriter, msg string) {
|
|
if msg == "" {
|
|
return
|
|
}
|
|
payload, err := asciiTriggerJSON(map[string]string{"showSuccessToast": msg})
|
|
if err != nil {
|
|
return
|
|
}
|
|
w.Header().Set("HX-Trigger", payload)
|
|
}
|
|
|
|
// fireErrorToast surfaces a mutation error as a visible toast in addition to
|
|
// the in-body .alert-danger banner. The banner alone is invisible when the
|
|
// operator triggered the action from a scrolled-down section of the page
|
|
// (e.g. revoking a grant from the per-org composite); the toast lives in
|
|
// the page-shell container outside the swap target and is always visible.
|
|
// Empty msg is a no-op.
|
|
func fireErrorToast(w http.ResponseWriter, msg string) {
|
|
if msg == "" {
|
|
return
|
|
}
|
|
payload, err := asciiTriggerJSON(map[string]string{"showErrorToast": msg})
|
|
if err != nil {
|
|
return
|
|
}
|
|
w.Header().Set("HX-Trigger", payload)
|
|
}
|
|
|
|
// asciiTriggerJSON marshals v to JSON and escapes every non-ASCII rune as a
|
|
// \uXXXX sequence, so the result is safe to place in an HX-Trigger response
|
|
// header. HTTP header values are transported as ISO-8859-1, so raw multi-byte
|
|
// UTF-8 (e.g. an em-dash in a toast message) is otherwise mangled by the browser;
|
|
// the \uXXXX escapes keep the header ASCII while htmx's JSON.parse restores the
|
|
// original text client-side.
|
|
func asciiTriggerJSON(v any) (string, error) {
|
|
b, err := json.Marshal(v)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
var sb strings.Builder
|
|
for _, r := range string(b) {
|
|
switch {
|
|
case r < 0x80:
|
|
sb.WriteRune(r)
|
|
case r > 0xFFFF:
|
|
r1, r2 := utf16.EncodeRune(r)
|
|
fmt.Fprintf(&sb, `\u%04x\u%04x`, r1, r2)
|
|
default:
|
|
fmt.Fprintf(&sb, `\u%04x`, r)
|
|
}
|
|
}
|
|
return sb.String(), nil
|
|
}
|
|
|
|
// OperatorPartialsHandler handles HTMX partial requests for operator pages
|
|
type OperatorPartialsHandler struct {
|
|
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
|
|
StripeConfigured bool
|
|
TemporalClient client.Client
|
|
// Registry is the domains allocation API, for the operator Domains
|
|
// surface (operator_domains.go). Nil disables that page's data rather
|
|
// than the page: it renders the "registry unavailable" banner, the same
|
|
// way every other read failure here degrades.
|
|
Registry *domains.Registry
|
|
// IntegrationConfigs: declared config per installed integration, for
|
|
// the generic settings surface (see Config.IntegrationConfigs).
|
|
IntegrationConfigs []IntegrationConfigInfo
|
|
}
|
|
|
|
// OperatorPartialsConfig holds configuration for the operator partials handler
|
|
type OperatorPartialsConfig struct {
|
|
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
|
|
StripeConfigured bool
|
|
TemporalClient client.Client
|
|
Registry *domains.Registry
|
|
IntegrationConfigs []IntegrationConfigInfo
|
|
}
|
|
|
|
// NewOperatorPartialsHandler creates a new OperatorPartialsHandler
|
|
func NewOperatorPartialsHandler(cfg OperatorPartialsConfig) (*OperatorPartialsHandler, error) {
|
|
templateSubFS, err := fs.Sub(embeds.Templates, "templates/partials")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
shellSubFS, err := fs.Sub(embeds.Templates, "templates")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// html/template's `{{ template "literal" . }}` action requires a string
|
|
// literal — there is no dynamic-name form. operator.html's <main> needs
|
|
// to dispatch to a body partial chosen at request time, so we expose
|
|
// `renderBody` as a template function that looks up the named template
|
|
// inside the same set and returns its rendered HTML.
|
|
//
|
|
// The closure captures `tmpl` (assigned below). Funcs must be installed
|
|
// before parsing so the parser accepts references to "renderBody"; we
|
|
// pre-construct the empty template, install the func, then ParseFS.
|
|
var tmpl *template.Template
|
|
tmpl = template.New("operator").Funcs(template.FuncMap{
|
|
"renderBody": func(name string, data any) (template.HTML, error) {
|
|
var buf bytes.Buffer
|
|
if err := tmpl.ExecuteTemplate(&buf, name, data); err != nil {
|
|
return "", err
|
|
}
|
|
return template.HTML(buf.String()), nil
|
|
},
|
|
// fieldErr returns the per-field error message ONLY when both the
|
|
// active form name and a per-form scope key (e.g. poolID) match —
|
|
// pages with multiple forms (the per-org composite has Issue,
|
|
// Create, and per-pool Extend forms) need to scope errors to one
|
|
// form. Pass empty string for `scope` if the form has no per-row
|
|
// disambiguation. Returns the empty string when no error applies,
|
|
// which the template uses with `{{ if }}` / `{{ with }}` to skip
|
|
// rendering the is-invalid class + invalid-feedback markup.
|
|
"fieldErr": func(activeForm, expectedForm, activeScope, expectedScope string, errs web.FieldErrors, field string) string {
|
|
if activeForm != expectedForm {
|
|
return ""
|
|
}
|
|
if activeScope != expectedScope {
|
|
return ""
|
|
}
|
|
return errs.Get(field)
|
|
},
|
|
"routeURL": web.RouteURL,
|
|
// stripeEntityURL renders a Stripe dashboard deep-link for a Stripe
|
|
// entity ID, or "" when the dashboard URL is unconfigured — templates
|
|
// gate the <a> wrapper on the returned value (finding #39). Captures the
|
|
// deployment's dashboard base URL from config.
|
|
"stripeEntityURL": func(entityType, stripeID string) string {
|
|
return stripeEntityURL(cfg.StripeDashboardURL, entityType, stripeID)
|
|
},
|
|
"deploymentName": config.DeploymentName,
|
|
})
|
|
|
|
if tmpl, err = tmpl.ParseFS(templateSubFS, "operator_*.html"); err != nil {
|
|
return nil, err
|
|
}
|
|
if tmpl, err = tmpl.ParseFS(shellSubFS, "operator.html"); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &OperatorPartialsHandler{
|
|
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),
|
|
IntegrationConfigs: cfg.IntegrationConfigs,
|
|
StripeDashboardURL: cfg.StripeDashboardURL,
|
|
StripeConfigured: cfg.StripeConfigured,
|
|
TemporalClient: cfg.TemporalClient,
|
|
Registry: cfg.Registry,
|
|
}, nil
|
|
}
|
|
|
|
// RegisterRoutes registers all operator HTMX partial routes.
|
|
//
|
|
// Note: GET /operator/fedwiki-sites is NOT registered here. It used to be
|
|
// a hardcoded entry in this method (and the read-only page handler lived
|
|
// here too), but per design.md Decision 8 an integration's own operator
|
|
// surface is registered by the integration itself, at the path its
|
|
// provider manifest declares (OperatorSurfacePath) — see
|
|
// internal/integrations/fedwiki.Adapter.RegisterRoutes and
|
|
// internal/integrations/fedwiki/web's FedWikiOperatorHandler
|
|
// (openspec/changes/integration-extraction task 2.7).
|
|
func (h *OperatorPartialsHandler) RegisterRoutes(mux *http.ServeMux) {
|
|
// Top-level MPA pages (operator_pages.go).
|
|
mux.HandleFunc("GET /operator/persons", h.requireOperatorRole(h.GetPersonsPage))
|
|
mux.HandleFunc("GET /operator/organizations", h.requireOperatorRole(h.GetOrganizationsPage))
|
|
mux.HandleFunc("GET /operator/organizations/{orgID}", h.requireOperatorRole(h.GetOrganizationDetailPage))
|
|
mux.HandleFunc("GET /operator/grants", h.requireOperatorRole(h.GetGrantsPage))
|
|
mux.HandleFunc("GET /operator/billing/accounts", h.requireOperatorRole(h.GetBillingAccountsPage))
|
|
mux.HandleFunc("GET /operator/billing/subscriptions", h.requireOperatorRole(h.GetSubscriptionsPage))
|
|
mux.HandleFunc("GET /operator/billing/invoices", h.requireOperatorRole(h.GetInvoicesPage))
|
|
mux.HandleFunc("GET /operator/billing/invoices/{invoiceID}", h.requireOperatorRole(h.GetOperatorInvoiceDetailPage))
|
|
mux.HandleFunc("GET /operator/billing/payments", h.requireOperatorRole(h.GetPaymentsPage))
|
|
mux.HandleFunc("GET /operator/org-types", h.requireOperatorRole(h.GetOrgTypesPage))
|
|
mux.HandleFunc("GET /operator/products", h.requireOperatorRole(h.GetProductsPage))
|
|
mux.HandleFunc("GET /operator/entitlement-sets", h.requireOperatorRole(h.GetEntitlementSetsPage))
|
|
mux.HandleFunc("GET /operator/plan-ladders", h.requireOperatorRole(h.GetPlanLaddersPage))
|
|
// /operator/plan-topology has no route: the topology overview folded
|
|
// into /operator/plan-ladders as its map-first view (D4), and with no
|
|
// production deployments there is no old URL to stay compatible with —
|
|
// the subtree fallback answers it with the panel's 404.
|
|
mux.HandleFunc("GET /operator/domains", h.requireOperatorRole(h.GetDomainsPage))
|
|
mux.HandleFunc("GET /operator/integrations", h.requireOperatorRole(h.GetIntegrationsPage))
|
|
// Subtree fallback: any /operator/* path no specific route claims gets
|
|
// an in-shell 404 instead of falling through to the "/" catch-all
|
|
// (which renders the member dashboard). Method-less so stray POSTs to
|
|
// unknown operator paths land here too; every registered pattern above
|
|
// and in the integrations' own RegisterRoutes wins by specificity.
|
|
mux.HandleFunc("/operator/", h.requireOperatorRole(h.GetOperatorNotFound))
|
|
// Literal /config-orphans is preferred over the {slug} wildcard for its
|
|
// exact path (same ServeMux specificity note as /plan-ladders/validation).
|
|
mux.HandleFunc("DELETE /operator/integrations/config-orphans/{key}", h.requireOperatorRole(h.DeleteOrphanOverride))
|
|
mux.HandleFunc("GET /operator/integrations/{slug}/settings", h.requireOperatorRole(h.GetIntegrationSettingsPage))
|
|
mux.HandleFunc("POST /operator/integrations/{slug}/settings", h.requireOperatorRole(h.PostIntegrationSetting))
|
|
mux.HandleFunc("GET /operator/persons/{personID}", h.requireOperatorRole(h.GetPersonPage))
|
|
mux.HandleFunc("GET /operator/products/{productID}", h.requireOperatorRole(h.GetProductDetailPage))
|
|
// Literal /validation is registered alongside the {ladderID} wildcard; Go's
|
|
// ServeMux prefers the more specific literal for that exact path, so the
|
|
// full-page structural-validation view (audit finding #43) never shadows a
|
|
// real ladder detail.
|
|
mux.HandleFunc("GET /operator/plan-ladders/validation", h.requireOperatorRole(h.GetPlanLadderValidationPage))
|
|
mux.HandleFunc("GET /operator/plan-ladders/{ladderID}", h.requireOperatorRole(h.GetPlanLadderDetailPage))
|
|
mux.HandleFunc("GET /operator/entitlement-sets/{setID}", h.requireOperatorRole(h.GetEntitlementSetDetailPage))
|
|
|
|
// Legacy pane-loader GETs (/partials/operator/{organizations,sites,
|
|
// grants,products,entitlement-sets,org-types,plan-ladders,billing/*})
|
|
// were retired in slice 8 — they only ever served the legacy tabstrip
|
|
// panes, which were demolished alongside operator-tabs.js. The
|
|
// POST/PUT/DELETE handlers below are kept because they back in-page
|
|
// forms, row edits, and modal submissions on the new MPA pages.
|
|
//
|
|
// Grant management. The grant-action surface is the per-org composite
|
|
// (operator-panel-navigation requirement: composite is the SOLE UI entry
|
|
// point for grant actions). The legacy POST /partials/operator/grants
|
|
// route was retired alongside the global Grants page going read-only —
|
|
// the new home for non-plan CreateGrant is on the composite at
|
|
// /partials/operator/organizations/{orgID}/grant/create. The simple
|
|
// RevokeGrant (non-composite) endpoint is also retired; revocations go
|
|
// through RevokeGrantAndTransition exclusively.
|
|
mux.HandleFunc("POST /partials/operator/organizations/{orgID}/grant/create", h.requireOperatorRole(h.IssueGrant))
|
|
// Product management
|
|
mux.HandleFunc("POST /partials/operator/products", h.requireOperatorRole(h.CreateProduct))
|
|
mux.HandleFunc("PUT /partials/operator/products/{productID}", h.requireOperatorRole(h.UpdateProduct))
|
|
mux.HandleFunc("POST /partials/operator/products/{productID}/sync-stripe", h.requireOperatorRole(h.SyncProductToStripe))
|
|
mux.HandleFunc("GET /partials/operator/products/{productID}/readiness", h.requireOperatorRole(h.GetProductReadiness))
|
|
// Entitlement set management. The edit form and rules manager live on the
|
|
// set composite page (GET /operator/entitlement-sets/{setID}); the routes
|
|
// below back the in-page forms.
|
|
mux.HandleFunc("POST /partials/operator/entitlement-sets", h.requireOperatorRole(h.CreateEntitlementSet))
|
|
mux.HandleFunc("PUT /partials/operator/entitlement-sets/{setID}", h.requireOperatorRole(h.UpdateEntitlementSet))
|
|
mux.HandleFunc("POST /partials/operator/entitlement-sets/{setID}/rules", h.requireOperatorRole(h.CreateEntitlementSetRule))
|
|
mux.HandleFunc("GET /partials/operator/entitlement-sets/{setID}/rules/fields", h.requireOperatorRole(h.GetEntitlementSetRuleFields))
|
|
mux.HandleFunc("DELETE /partials/operator/entitlement-sets/{setID}/rules/{ruleID}", h.requireOperatorRole(h.DeleteEntitlementSetRule))
|
|
// Organization type management: selecting a candidate default posts to
|
|
// the preview (read-only classification, per-card swap); the commit
|
|
// enacts the change with per-bucket dispositions.
|
|
mux.HandleFunc("POST /partials/operator/org-types/{orgType}/default-change/preview", h.requireOperatorRole(h.PreviewOrgTypeDefaultChange))
|
|
mux.HandleFunc("POST /partials/operator/org-types/{orgType}/default-change", h.requireOperatorRole(h.CommitOrgTypeDefaultChange))
|
|
// 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/extend", h.requireOperatorRole(h.ExtendGrant))
|
|
mux.HandleFunc("POST /partials/operator/grants/{grantID}/revoke-and-transition", h.requireOperatorRole(h.RevokeGrantAndTransition))
|
|
// Product prices (the prices view itself lives on the product composite page;
|
|
// these POSTs back the in-page add-price form and the per-row price
|
|
// affordances: promote a price to the product's default, retire a
|
|
// non-default price. Per-price Stripe sync reuses the sync-stripe route
|
|
// above with an explicit price_id form value.
|
|
mux.HandleFunc("POST /partials/operator/products/{productID}/prices", h.requireOperatorRole(h.CreatePrice))
|
|
mux.HandleFunc("POST /partials/operator/products/{productID}/prices/{priceID}/make-default", h.requireOperatorRole(h.MakeDefaultPrice))
|
|
mux.HandleFunc("POST /partials/operator/products/{productID}/prices/{priceID}/deactivate", h.requireOperatorRole(h.DeactivatePrice))
|
|
// Plan ladder management. The edit form and tier manager live on the ladder
|
|
// composite page (GET /operator/plan-ladders/{ladderID}); the routes below
|
|
// back the in-page forms.
|
|
mux.HandleFunc("POST /partials/operator/plan-ladders", h.requireOperatorRole(h.CreatePlanLadder))
|
|
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("POST /partials/operator/plan-ladders/{ladderID}/tiers", h.requireOperatorRole(h.CreatePlanLadderTier))
|
|
// Tier reordering within a ladder is a two-step flow: dropping a dragged row
|
|
// POSTs the full pending product order to the read-only preview (pending
|
|
// ranks + consequences of the pending rank 0), and the commit re-sequences
|
|
// ranks (two-phase, collision-free) and enacts any required outgoing-default
|
|
// dispositions before re-rendering the ladder detail body.
|
|
mux.HandleFunc("POST /partials/operator/plan-ladders/{ladderID}/tiers/reorder/preview", h.requireOperatorRole(h.PreviewPlanLadderTiersReorder))
|
|
mux.HandleFunc("POST /partials/operator/plan-ladders/{ladderID}/tiers/reorder", h.requireOperatorRole(h.ReorderPlanLadderTiers))
|
|
// Removing a tier with live holders is also two-step: Remove renders a
|
|
// read-only preview (holders classified by position source), and the
|
|
// commit deletes + renumbers + reconciles every holder in one atomic
|
|
// transaction. The plain DELETE below remains the holder-less modal path.
|
|
mux.HandleFunc("POST /partials/operator/plan-ladders/{ladderID}/tiers/{productID}/remove/preview", h.requireOperatorRole(h.PreviewPlanLadderTierRemoval))
|
|
mux.HandleFunc("POST /partials/operator/plan-ladders/{ladderID}/tiers/{productID}/remove", h.requireOperatorRole(h.CommitPlanLadderTierRemoval))
|
|
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))
|
|
// Domain moderation. The only write on the operator Domains surface:
|
|
// force-release frees a name whatever workspace holds it, through the
|
|
// registry's own lock and placement guard (operator_domains.go).
|
|
mux.HandleFunc("POST /partials/operator/domains/{claimID}/force-release", h.requireOperatorRole(h.ForceReleaseClaim))
|
|
// Ladder display reordering lives on the topology overview (the one surface
|
|
// where column order is visible): a drag-and-drop on the column headers POSTs
|
|
// the full new order, which re-sequences sort_order and re-renders the grid.
|
|
mux.HandleFunc("POST /partials/operator/plan-ladders/reorder", h.requireOperatorRole(h.ReorderPlanLadders))
|
|
}
|
|
|
|
// requireOperatorRole is middleware that checks for the operator role
|
|
func (h *OperatorPartialsHandler) requireOperatorRole(next http.HandlerFunc) http.HandlerFunc {
|
|
return RequireOperatorRole(h.AuthConfig, h.Logger, next)
|
|
}
|
|
|
|
// PersonViewModel represents a person for operator template rendering
|
|
type PersonViewModel struct {
|
|
PersonID string
|
|
DisplayName string
|
|
Email string
|
|
Status string
|
|
CreatedAt string
|
|
}
|
|
|
|
// PersonDetailData holds data for the operator_person_detail.html template
|
|
// (singular — per design D4 persons is lookup-only, no list view).
|
|
type PersonDetailData struct {
|
|
Person PersonViewModel
|
|
Memberships []PersonMembershipViewModel
|
|
Error string
|
|
}
|
|
|
|
// PersonMembershipViewModel represents one org the person belongs to, with
|
|
// the role they hold there. Each row links to /operator/organizations/{orgID}
|
|
// on the person-detail page (closes the deferred 3.6 memberships gap from
|
|
// operator-mpa-conversion).
|
|
type PersonMembershipViewModel struct {
|
|
OrgID string
|
|
OrgName string
|
|
OrgSlug string
|
|
RoleName string
|
|
JoinedAt string
|
|
}
|
|
|
|
// loadPersonDetailData hydrates a single person by ID. Used by the MPA
|
|
// page handler (GetPersonPage). Returns an error-shaped data struct when
|
|
// the person is missing so the template renders the alert banner.
|
|
func (h *OperatorPartialsHandler) loadPersonDetailData(r *http.Request, personID string) PersonDetailData {
|
|
p, err := h.IdentityQ.GetPersonByID(r.Context(), personID)
|
|
if err != nil {
|
|
h.Logger.Warn("person not found", slog.String("person_id", personID), slog.Any("error", err))
|
|
return PersonDetailData{Error: "Person not found"}
|
|
}
|
|
data := PersonDetailData{
|
|
Person: PersonViewModel{
|
|
PersonID: p.PersonID,
|
|
DisplayName: p.DisplayName,
|
|
Email: p.PrimaryEmail,
|
|
Status: p.Status,
|
|
CreatedAt: p.CreatedAt.Format("Jan 2, 2006"),
|
|
},
|
|
}
|
|
if memberships, err := h.OrgQ.ListActiveMembershipsByPersonID(r.Context(), personID); err == nil {
|
|
data.Memberships = make([]PersonMembershipViewModel, len(memberships))
|
|
for i, m := range memberships {
|
|
data.Memberships[i] = PersonMembershipViewModel{
|
|
OrgID: m.OrgID,
|
|
OrgName: m.OrgName,
|
|
OrgSlug: m.OrgSlug,
|
|
RoleName: m.RoleName,
|
|
JoinedAt: m.JoinedAt.Format("Jan 2, 2006"),
|
|
}
|
|
}
|
|
}
|
|
return 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
|
|
// OwnerName is the guaranteed owner's display name (organizations.owner_person_id
|
|
// is NOT NULL), so a row never reads as unowned (ux-honest-surfaces UX-16).
|
|
OwnerName string
|
|
// IsSystemOrg marks the reserved System tenant row (org_type = systemtenant.OrgType)
|
|
// so it carries a visible synthetic marker instead of inviting ordinary
|
|
// member-org actions.
|
|
IsSystemOrg bool
|
|
}
|
|
|
|
// OrganizationsData holds data for the organizations list partial
|
|
type OrganizationsData struct {
|
|
Organizations []OrganizationViewModel
|
|
Error string
|
|
// Nav is the list-controls view model (operator-list-scale): search,
|
|
// the org-type facet, and the true-total pager. Its Filtered() decides
|
|
// whether an empty Organizations slice renders the no-match state or
|
|
// the page's true-empty state.
|
|
Nav ListNav
|
|
}
|
|
|
|
// 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.
|
|
//
|
|
// DeliveryState is the operationally-meaningful classification derived by
|
|
// joining the grant to its pool_provisions / pool_provision_ladders (the
|
|
// shared ListGrantsWithDelivery query — ux-honest-surfaces design decision
|
|
// 1, "one query, two consumers"):
|
|
//
|
|
// "live" -> at least one linked provision is status='active'
|
|
// "superseded" -> not live, and a later grant is on record as its
|
|
// replacement (ReplacedByGrantID is set)
|
|
// "inactive" -> not live and not superseded: revoked/expired with no
|
|
// recorded successor, or a grant that never delivered
|
|
//
|
|
// This is the single vocabulary and derivation both the grants index
|
|
// (/operator/grants) and the org-detail composite render from, so the two
|
|
// surfaces can never disagree about what these words mean. Grants are an
|
|
// issuance ledger (grants.status, Status below); DeliveryState is the
|
|
// operational fact and is intentionally kept separate — see
|
|
// docs/models/entitlements.md.
|
|
type GrantViewModel struct {
|
|
GrantID string
|
|
OrgName string
|
|
ProductName string
|
|
EntitlementSetName string
|
|
Quantity int32
|
|
GrantReason string
|
|
Status string
|
|
CreatedAt string
|
|
DeliveryState string
|
|
ActivatedAt string
|
|
EndedAt string
|
|
// ReplacedByGrantID / ReplacedByLabel identify the grant that
|
|
// superseded this one via extends_grant_id, set only when
|
|
// DeliveryState is "superseded" — the lineage a superseded row must
|
|
// show so it never reads as an unexplained non-live grant.
|
|
ReplacedByGrantID string
|
|
ReplacedByLabel string
|
|
// ExtendsGrantID / ExtendsLabel name this grant's own ancestor, set
|
|
// when this grant was itself issued as a replacement for an earlier
|
|
// one — the other end of the same lineage link.
|
|
ExtendsGrantID string
|
|
ExtendsLabel string
|
|
}
|
|
|
|
// GrantsData holds data for the grants management partial.
|
|
type GrantsData struct {
|
|
Grants []GrantViewModel
|
|
Organizations []OrganizationViewModel
|
|
Products []ProductViewModel
|
|
EntitlementSets []EntitlementSetOption
|
|
Success string
|
|
Error string
|
|
// Nav is the shared list-controls view model (operator-list-scale).
|
|
// loadGrantsListData stashes the query's true total on Nav.Total so
|
|
// the FetchPage caller (GetGrantsPage, operator_pages.go) can read it
|
|
// back off the returned GrantsData; GetGrantsPage then fills in the
|
|
// rest of Nav (BasePath, facet vocabulary, current Q/Facet/Page).
|
|
Nav ListNav
|
|
}
|
|
|
|
// ProductViewModel represents a product for the grant creation form.
|
|
type ProductViewModel struct {
|
|
ProductID string
|
|
Name string
|
|
DisplayCategory string
|
|
}
|
|
|
|
// Removed in M7e Slice D: legacy CreateGrant + RevokeGrant + renderGrantsPage
|
|
// handlers backed the global Grants page's mutation forms. The composite is
|
|
// now the sole UI entry point for grant actions (operator-panel-navigation
|
|
// spec); the composite-side handlers CreateNonPlanGrant + IssueGrant +
|
|
// RevokeGrantAndTransition cover every UI flow. The e2e test was rewritten
|
|
// to exercise those instead. loadGrantsListData (below) is retained — the
|
|
// read-only MPA page GetGrantsPage still uses it.
|
|
|
|
// loadGrantsListData hydrates the read-only, paginated grants list for the
|
|
// MPA page handler (GetGrantsPage). Errors are recorded on data.Error so
|
|
// the template renders a single banner.
|
|
//
|
|
// params carries the current search/facet/page state (operator-list-scale).
|
|
// The query itself is ListGrantsWithDeliveryPage (design D4): a paginated,
|
|
// searchable, filterable sibling of the shared ListGrantsWithDelivery
|
|
// query the org-detail composite still uses unpaged. Search matches the
|
|
// granted-to organization's name in SQL; product-name search cannot join
|
|
// into the billing schema from the entitlements module, so matchingProductIDs
|
|
// pre-resolves candidate product IDs in Go and passes them as the query's
|
|
// product_ids criterion. Delivery-state filtering happens in the query's
|
|
// HAVING clause on the identical CASE expression ListGrantsWithDelivery
|
|
// uses, so the SQL filter can never disagree with the Go derivation below.
|
|
//
|
|
// The rows are adapted (toDeliveryRows) to the shape buildGrantViewModels
|
|
// already consumes so the paged query and ListGrantsWithDelivery render
|
|
// through the exact same view-model derivation (design decision 1, "one
|
|
// query, two consumers" — now "one derivation, two queries").
|
|
func (h *OperatorPartialsHandler) loadGrantsListData(r *http.Request, params ListParams, success string, errMsg string) GrantsData {
|
|
data := GrantsData{
|
|
Success: success,
|
|
Error: errMsg,
|
|
}
|
|
if errMsg != "" {
|
|
return data
|
|
}
|
|
|
|
queryParams := entitlements.ListGrantsWithDeliveryPageParams{
|
|
PageLimit: params.Limit(),
|
|
PageOffset: params.Offset(),
|
|
}
|
|
if params.Q != "" {
|
|
queryParams.Q = sql.NullString{String: params.Q, Valid: true}
|
|
queryParams.ProductIds = h.matchingProductIDs(r, params.Q)
|
|
}
|
|
if params.Facet != "" {
|
|
queryParams.DeliveryState = sql.NullString{String: params.Facet, Valid: true}
|
|
}
|
|
|
|
rows, err := h.EntitlementsQ.ListGrantsWithDeliveryPage(r.Context(), queryParams)
|
|
if err != nil {
|
|
h.Logger.Error("failed to list grants", slog.Any("error", err))
|
|
data.Error = "Failed to load grants"
|
|
return data
|
|
}
|
|
|
|
if len(rows) > 0 {
|
|
data.Nav.Total = rows[0].TotalCount
|
|
}
|
|
data.Grants = h.buildGrantViewModels(r, toDeliveryRows(rows), true)
|
|
return data
|
|
}
|
|
|
|
// matchingProductIDs resolves the product IDs whose name case-insensitively
|
|
// contains q. The grants module (internal/entitlements) cannot query the
|
|
// billing schema directly, so ListGrantsWithDeliveryPage's product-name
|
|
// search criterion rides on IDs resolved here rather than a SQL join into
|
|
// core.products. ListAllProducts (not just published/active products) is
|
|
// used because a grant issued while its product was published must stay
|
|
// searchable by name after the product is later unpublished. Returns nil
|
|
// (not an error) on a query failure — search degrades to org-name-only
|
|
// rather than failing the whole page.
|
|
func (h *OperatorPartialsHandler) matchingProductIDs(r *http.Request, q string) []string {
|
|
products, err := h.BillingQ.ListAllProducts(r.Context())
|
|
if err != nil {
|
|
h.Logger.Error("failed to list products for grants search", slog.Any("error", err))
|
|
return nil
|
|
}
|
|
needle := strings.ToLower(q)
|
|
var ids []string
|
|
for _, p := range products {
|
|
if strings.Contains(strings.ToLower(p.Name), needle) {
|
|
ids = append(ids, p.ProductID)
|
|
}
|
|
}
|
|
return ids
|
|
}
|
|
|
|
// toDeliveryRows adapts ListGrantsWithDeliveryPage's rows (which carry the
|
|
// extra TotalCount window-function column) to entitlements.ListGrantsWithDeliveryRow,
|
|
// the shape buildGrantViewModels already consumes — the two queries share
|
|
// every column except TotalCount, by construction (design D4: the paged
|
|
// query must never let its derivation drift from the shared one).
|
|
func toDeliveryRows(rows []entitlements.ListGrantsWithDeliveryPageRow) []entitlements.ListGrantsWithDeliveryRow {
|
|
out := make([]entitlements.ListGrantsWithDeliveryRow, len(rows))
|
|
for i, row := range rows {
|
|
out[i] = entitlements.ListGrantsWithDeliveryRow{
|
|
GrantID: row.GrantID,
|
|
GrantedToOrgID: row.GrantedToOrgID,
|
|
ProductID: row.ProductID,
|
|
GrantReason: row.GrantReason,
|
|
Quantity: row.Quantity,
|
|
GrantStatus: row.GrantStatus,
|
|
CreatedAt: row.CreatedAt,
|
|
ExtendsGrantID: row.ExtendsGrantID,
|
|
DeliveryState: row.DeliveryState,
|
|
ReplacedByGrantID: row.ReplacedByGrantID,
|
|
ActivatedAt: row.ActivatedAt,
|
|
EndedAt: row.EndedAt,
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// buildGrantViewModels converts rows from the shared ListGrantsWithDelivery
|
|
// query into GrantViewModels, resolving organization/product names and the
|
|
// supersession-lineage labels (ReplacedBy / Extends). Shared by the grants
|
|
// index (rows for every org, resolveOrgName=true) and the org-detail
|
|
// composite (rows scoped to one org, resolveOrgName=false — the org is
|
|
// already known and named once on the page) so both surfaces render the
|
|
// exact same vocabulary from the exact same derivation (design decision 1,
|
|
// "one query, two consumers": two hand-rolled derivations would drift).
|
|
//
|
|
// Lineage labels resolve from the same result set (a map keyed by
|
|
// grant_id) rather than issuing another query per lineage link — the
|
|
// replacing/extended grant is almost always present in the same rows
|
|
// already loaded for this surface.
|
|
func (h *OperatorPartialsHandler) buildGrantViewModels(r *http.Request, rows []entitlements.ListGrantsWithDeliveryRow, resolveOrgName bool) []GrantViewModel {
|
|
byID := make(map[string]entitlements.ListGrantsWithDeliveryRow, len(rows))
|
|
for _, g := range rows {
|
|
byID[g.GrantID] = g
|
|
}
|
|
productNames := map[string]string{}
|
|
productName := func(productID string) string {
|
|
if productID == "" {
|
|
return ""
|
|
}
|
|
if name, ok := productNames[productID]; ok {
|
|
return name
|
|
}
|
|
name := ""
|
|
if p, err := h.BillingQ.GetProductByID(r.Context(), productID); err == nil {
|
|
name = p.Name
|
|
}
|
|
productNames[productID] = name
|
|
return name
|
|
}
|
|
// lineageLabel names the grant at the other end of a lineage link for
|
|
// display (falls back to a short grant-id fragment when its product
|
|
// can't be resolved, e.g. it fell outside the loaded row set).
|
|
lineageLabel := func(grantID string) string {
|
|
if g, ok := byID[grantID]; ok {
|
|
if name := productName(g.ProductID); name != "" {
|
|
return name
|
|
}
|
|
}
|
|
if len(grantID) >= 8 {
|
|
return "grant " + grantID[:8]
|
|
}
|
|
return "grant " + grantID
|
|
}
|
|
|
|
out := make([]GrantViewModel, 0, len(rows))
|
|
for _, g := range rows {
|
|
orgName := ""
|
|
if resolveOrgName && g.GrantedToOrgID.Valid {
|
|
if org, err := h.OrgQ.GetOrganizationByID(r.Context(), g.GrantedToOrgID.UUID.String()); err == nil {
|
|
orgName = org.Name
|
|
}
|
|
}
|
|
// sqlc emits interface{} for the MAX(...) aggregate columns since
|
|
// nullability is lost through the aggregate/GROUP BY (pq/pgx return
|
|
// time.Time for timestamps, string for the text-cast lineage id).
|
|
activatedAt := ""
|
|
if t, ok := g.ActivatedAt.(time.Time); ok {
|
|
activatedAt = t.Format("Jan 2, 2006 15:04")
|
|
}
|
|
endedAt := ""
|
|
if t, ok := g.EndedAt.(time.Time); ok {
|
|
endedAt = t.Format("Jan 2, 2006 15:04")
|
|
}
|
|
replacedByID := ""
|
|
if v, ok := g.ReplacedByGrantID.(string); ok {
|
|
replacedByID = v
|
|
}
|
|
extendsID := ""
|
|
if g.ExtendsGrantID.Valid {
|
|
extendsID = g.ExtendsGrantID.UUID.String()
|
|
}
|
|
|
|
vm := GrantViewModel{
|
|
GrantID: g.GrantID,
|
|
OrgName: orgName,
|
|
ProductName: productName(g.ProductID),
|
|
Quantity: g.Quantity,
|
|
GrantReason: g.GrantReason,
|
|
Status: g.GrantStatus,
|
|
CreatedAt: g.CreatedAt.Format("Jan 2, 2006"),
|
|
DeliveryState: g.DeliveryState,
|
|
ActivatedAt: activatedAt,
|
|
EndedAt: endedAt,
|
|
}
|
|
if replacedByID != "" {
|
|
vm.ReplacedByGrantID = replacedByID
|
|
vm.ReplacedByLabel = lineageLabel(replacedByID)
|
|
}
|
|
if extendsID != "" {
|
|
vm.ExtendsGrantID = extendsID
|
|
vm.ExtendsLabel = lineageLabel(extendsID)
|
|
}
|
|
out = append(out, vm)
|
|
}
|
|
return out
|
|
}
|