Files
member-console/internal/server/operator_enrollment.go
T
cgalo5758 257955c9d3 Add operator list-scale contract and People directory
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.
2026-08-24 03:58:18 -05:00

1367 lines
56 KiB
Go

package server
import (
"database/sql"
"errors"
"fmt"
"log/slog"
"net/http"
"net/url"
"sort"
"strconv"
"strings"
"time"
"git.coopcloud.tech/wiki-cafe/member-console/internal/billing"
"git.coopcloud.tech/wiki-cafe/member-console/internal/entitlements"
"git.coopcloud.tech/wiki-cafe/member-console/internal/web"
wf "git.coopcloud.tech/wiki-cafe/member-console/internal/workflows/entitlements"
"git.coopcloud.tech/wiki-cafe/member-console/internal/workflows/queues"
"github.com/google/uuid"
"go.temporal.io/sdk/client"
)
// maxGrantQuantity caps the CreateNonPlanGrant quantity field. Well under
// int32's range so validation rejects the input before the int32(q) cast
// can wrap or go negative (finding #31); also just a sane ceiling for a
// per-org addon/usage/one-time quantity.
const maxGrantQuantity = 1_000_000
// maxGrantReasonLength caps the free-text "Why this grant?" reason fields.
// The columns these feed (grants.description, pool_provision_transitions.
// reason) are TEXT with no DB-level bound; this is a UX guard, not a
// constraint workaround (finding #48).
const maxGrantReasonLength = 500
// enrollmentListCap bounds the composite's Members list to its most
// recent rows (maintainer 2026-08-23; the grants ledger and Tier changes
// graduated from this cap to the governed-list pager in the 2026-08-24
// design round). The composite is a summary, not a report, so the list
// only needs enough rows to answer "who is in this org right now".
const enrollmentListCap = 16
// capEnrollmentList truncates items to the most recent enrollmentListCap
// entries, returning the (possibly unchanged) slice, the pre-truncation
// count, and whether truncation happened. The template renders the honest
// "Showing the latest 16 of N" line only when wasCapped is true, so a
// caller must pass items already ordered most-recent-first (see the
// ordering note on each call site below -- callers whose backing query
// sorts oldest-first must reverse or re-sort before calling this).
func capEnrollmentList[T any](items []T) (capped []T, total int, wasCapped bool) {
total = len(items)
if total <= enrollmentListCap {
return items, total, false
}
return items[:enrollmentListCap], total, true
}
// pageSliceClamped windows an already-loaded, already-ordered slice to
// the params' page (operatorListPageSize rows), clamping a past-the-end
// page back to 1 like FetchPage does for SQL loaders. The composite's
// embedded lists are org-scoped and modest, so Go-side windowing over the
// full set is simpler than teaching each backing query LIMIT/OFFSET.
func pageSliceClamped[T any](items []T, p *ListParams) (window []T, total int) {
total = len(items)
start := int(p.Offset())
if start >= total && p.Page > 1 {
p.Page = 1
start = 0
}
end := start + int(p.Limit())
if end > total {
end = total
}
if start > total {
start = total
}
return items[start:end], total
}
// siblingListState collects the request's query params EXCEPT the given
// list's own (prefix q/page and, when named, its facet param), for
// ListNav.Extra: an embedded list's links then preserve its sibling
// lists' state on the shared org-detail URL.
func siblingListState(r *http.Request, ownPrefix string, ownFacetParams ...string) url.Values {
own := map[string]bool{ownPrefix + "q": true, ownPrefix + "page": true, ownPrefix + "per": true}
for _, f := range ownFacetParams {
own[f] = true
}
v := url.Values{}
for k, vals := range r.URL.Query() {
if own[k] {
continue
}
for _, val := range vals {
if val != "" {
v.Add(k, val)
}
}
}
return v
}
// poolMissingMessage is the breakage reason shown both where the composite
// blocks the Issue Grant form (loadOrgEnrollmentData / the template) and
// where IssueGrant refuses a POST that reaches the handler anyway
// (ux-honest-surfaces: "a pool-less organization is presented as broken,
// not empty" — one wording, so the block and its stated reason can never
// drift apart).
const poolMissingMessage = "This organization is missing its default resource pool. Every organization should have exactly one; without it, grant delivery is impossible until the pool is repaired."
// grantConstraints maps the DB constraints a grant INSERT/UPDATE can hit to
// the fields on the composite's grant forms, so violations render as friendly
// field-level errors instead of leaking raw driver text (per
// docs/operator-ux-conventions.md §4/§6 — constraint names live next to the
// forms they belong to). Constraints an operator cannot plausibly trigger are
// left to web.FieldErrorsFromDB's per-class fallback.
var grantConstraints = web.ConstraintMessages{
"chk_grants_recipient": {Field: "", Message: "A grant must name exactly one recipient."},
"chk_grants_no_self_extend": {Field: "", Message: "A grant cannot extend itself."},
"chk_grants_reason_domain": {Field: "reason", Message: "Choose a valid reason."},
"chk_grants_default_iff_system_authored": {Field: "reason", Message: "The 'default' reason is reserved for system-authored grants."},
"grants_product_id_fkey": {Field: "product_id", Message: "The selected product no longer exists. Refresh the page and choose another."},
}
// fieldErrorsBanner flattens translated FieldErrors into a single banner
// message for actions without field-level rendering (the revoke buttons have
// no form to attach a 422 FieldErrors re-render to). Prefers the form-level
// "" message, else any field message — the translator sets exactly one entry.
func fieldErrorsBanner(fe web.FieldErrors) string {
if msg := fe.Get(""); msg != "" {
return msg
}
for _, msg := range fe {
return msg
}
return "The change was rejected by a data constraint."
}
// parseGrantValidUntil reads the optional "valid_until" field from a grant form.
// The datetime-local input is a naive wall-clock time. When the browser supplies
// valid_until_offset (Date.getTimezoneOffset() for the picked date, via
// static/grant-valid-until-tz.js) the instant is resolved exactly regardless of
// the server's timezone; otherwise the server's local zone is assumed. A time in
// the past is rejected. Returns a zero NullTime and "" when the field is empty,
// or a user-facing message on error.
func parseGrantValidUntil(r *http.Request) (sql.NullTime, string) {
v := r.FormValue("valid_until")
if v == "" {
return sql.NullTime{}, ""
}
var (
t time.Time
err error
)
if off := r.FormValue("valid_until_offset"); off != "" {
if mins, aerr := strconv.Atoi(off); aerr == nil {
// getTimezoneOffset() is minutes local is behind UTC: parse the naive
// value as UTC, then add the offset to recover the true instant.
if t, err = time.Parse("2006-01-02T15:04", v); err == nil {
t = t.Add(time.Duration(mins) * time.Minute)
}
} else {
t, err = time.ParseInLocation("2006-01-02T15:04", v, time.Local)
}
} else {
t, err = time.ParseInLocation("2006-01-02T15:04", v, time.Local)
}
if err != nil {
return sql.NullTime{}, "Use the date picker (YYYY-MM-DD HH:MM)."
}
if t.Before(time.Now()) {
return sql.NullTime{}, "Choose a time in the future."
}
return sql.NullTime{Time: t, Valid: true}, ""
}
// PoolRungViewModel is one ladder placement of a delivery: which ladder,
// at what rank, since when. Rendered as a badge pill under the delivery's
// product line.
type PoolRungViewModel struct {
LadderKey string
Rank int32
ActivatedAt string
}
// PoolDeliveryViewModel is one delivery (provision) a pool holds,
// grouped: the product renders ONCE with the single Extend control, and
// its ladder placements render as sub-pills (maintainer design round
// 2026-08-24: the delivery is the act-on unit; rungs are facts about it.
// This replaced one-line-per-position, which showed a shared product N
// times and needed a "Same delivery" disambiguation marker).
type PoolDeliveryViewModel struct {
ProvisionID string
ProductName string
ProductID string
// GrantBacked is the precondition for extending: a subscription-backed
// delivery renders its Extend control disabled with the reason.
GrantBacked bool
Rungs []PoolRungViewModel
}
// PoolEnrollmentViewModel represents a pool's current enrollment state.
type PoolEnrollmentViewModel struct {
PoolID string
PoolName string
PoolType string
// Status is the pool's core.resource_pools.status value ("active" as
// built today — invariant 10, resource-pools card — but rendered
// honestly rather than assumed, so a future non-active pool is visibly
// distinct instead of silently indistinguishable from a healthy one).
Status string
// Deliveries lists every delivery (provision) this pool holds, each
// grouping its ladder placements. One Extend control and one panel per
// delivery; a shared product (a tier on several ladders) is ONE
// delivery with several rung pills.
Deliveries []PoolDeliveryViewModel
// Usage lists this pool's per-resource-key usage counters (used vs
// limit), so a member-facing quota refusal is diagnosable from the
// console (ux-honest-surfaces: "pool status and usage are visible on
// the organization view").
Usage []PoolUsageViewModel
HasAttachment bool
}
// PoolUsageViewModel is one per-resource usage counter row on the
// org-detail pools panel.
type PoolUsageViewModel struct {
ResourceKey string
Used int64
Limit int64
}
// TransitionHistoryViewModel is one row of the Tier changes table
// (formerly "Position history"; maintainer design round 2026-08-24:
// operators cannot decode "position" or integer ranks, so rows carry the
// ladder key, a humanized verb, and tier NAMES resolved from the ladder's
// current shape, with "rank N" as the fallback when a historical rank no
// longer exists on the ladder).
type TransitionHistoryViewModel struct {
TransitionID string
LadderKey string
// ChangeLabel is the humanized transition_type (Started / Upgraded /
// Downgraded / Transferred / Ended); ChangeTooltip explains the one
// verb that needs it (Transferred).
ChangeLabel string
ChangeTooltip string
// FromLabel / ToLabel are tier names (best effort), "rank N" when the
// rank is not on the ladder's current shape, or the empty-value marker
// for the side a Started/Ended row does not have.
FromLabel string
ToLabel string
ActorType string
ActorName string
Reason string
EffectiveAt string
}
// transitionChangeLabels maps the transition_type enum to operator
// vocabulary. transfer gets a tooltip: same rank, different funding
// source (model card invariant: callers never choose the type; confer
// derives it from ranks).
var transitionChangeLabels = map[string]string{
"initiate": "Started",
"upgrade": "Upgraded",
"downgrade": "Downgraded",
"transfer": "Transferred",
"end": "Ended",
}
// TierOption represents a selectable tier for force-transition.
type TierOption struct {
LadderID string
LadderKey string
ProductID string
ProductName string
Rank int32
}
// grantReasonDomain is the operator-selectable subset of grants.grant_reason
// (the 'default' value is system-authored only and never offered here). The
// single issuance form's reason <select> is built from this list.
var grantReasonDomain = []string{"manual", "evaluation", "promotional", "complimentary", "sponsored", "board_decision", "legacy"}
func isValidGrantReason(reason string) bool {
for _, v := range grantReasonDomain {
if v == reason {
return true
}
}
return false
}
// IssuanceProductOption is one selectable product on the single issuance form
// (Doc 41: one form over all published products). Internal wrap products
// (is_public=false) render with an "internal" badge; SupersedesSubscription
// flags a product whose position would displace a subscription-held rung (the
// subscription keeps billing — §11.2 residue), surfaced as a non-blocking
// advisory.
type IssuanceProductOption struct {
ProductID string
Name string
IsInternal bool
DisplayCategory string
SupersedesSubscription bool
}
// BillingSummaryViewModel holds the billing snapshot rendered on the per-org
// composite. HasAccount=false signals no billing account exists for the org;
// the template renders an empty-state message instead of a summary card.
// Subscription and invoice fields are zero-valued when those records don't
// exist yet (e.g. an account created but never billed).
type BillingSummaryViewModel struct {
HasAccount bool
BillingAccountID string
AccountStatus string
HasSubscription bool
SubscriptionStatus string
SubscriptionPeriodEnd string
CancelAtPeriodEnd bool
HasInvoice bool
LatestInvoiceID string
LatestInvoiceDate string
LatestInvoiceAmountDue string // pre-formatted via formatCurrency, e.g. "USD 10.00" (finding #6)
LatestInvoicePaid bool
// OutstandingBalance is the raw minor-unit amount, kept only so the
// template can branch styling on "> 0" without parsing the formatted
// string back out; render OutstandingBalanceFormatted for display.
OutstandingBalance int64
OutstandingBalanceFormatted string
Currency string
}
// MemberRowViewModel represents one enrolled person on the per-org composite's
// Members section. The PersonID drives the link to /operator/persons/{personID}
// (closes the deferred 3.7 link from operator-mpa-conversion).
type MemberRowViewModel struct {
PersonID string
DisplayName string
Email string
RoleName string
JoinedAt string
}
// OrgEnrollmentData holds data for the enrollment detail partial.
type OrgEnrollmentData struct {
OrgID string
OrgName string
OrgSlug string
// OrgOwnerPersonID / OrgOwnerName identify the organization's owner
// (core.organizations.owner_person_id, NOT NULL) so the org-detail
// header never reads as unowned (organization delta, owner visibility
// — mirrors the ListOrganizationsWithOwner treatment on the org list).
// OrgOwnerPersonID empty means the lookup failed (defensive; the
// column itself is never NULL).
OrgOwnerPersonID string
OrgOwnerName string
Pools []PoolEnrollmentViewModel
// PoolMissing is true when the organization has zero resource pool
// rows at all -- not merely none active. Every organization gets
// exactly one default pool at provisioning (resource-pools card
// invariant 3); an org with none cannot receive conferrals. The
// composite renders an explicit breakage warning and blocks the Issue
// Grant form for this case instead of the neutral "no active pools"
// empty state (ux-honest-surfaces: "a pool-less organization is
// presented as broken, not empty").
PoolMissing bool
// Transitions is the Tier changes table's current page (tc_ params);
// TransitionsTotal is the full count and TierChangesNav drives the
// shared pager (maintainer design round 2026-08-24: the flat 16-cap
// graduated to the governed-list pager).
Transitions []TransitionHistoryViewModel
TransitionsTotal int
TierChangesNav ListNav
IssuanceProducts []IssuanceProductOption // all published products for the single issuance form
GrantReasons []string // operator-selectable grant_reason domain (grantReasonDomain)
// Grants is the ledger's current page under the Active/History tabs
// (tab, q, page params): the Active tab (default) shows only rows
// delivering right now, History shows every grant ever; search matches
// product name or reason on both (maintainer design round 2026-08-24).
// GrantsNav drives the tabs, search, and pager.
Grants []GrantViewModel
GrantsTotal int
GrantsNav ListNav
BillingSummary BillingSummaryViewModel
// Members is capped to the enrollmentListCap most recently joined rows
// (the backing query sorts alphabetically, so loadOrgEnrollmentData
// re-sorts by JoinedAt before capping); MembersTotal / MembersCapped
// follow the same contract as above.
Members []MemberRowViewModel
MembersTotal int
MembersCapped bool
// FormPoolID identifies which grant form the FieldErrors apply to —
// the composite renders multiple grant forms (Issue / Create / Extend
// per pool) and only the form that triggered the validation failure
// should render error markers. Empty when no form errors apply.
FormPoolID string
FormName string // "issue" | "extend"
FieldErrors web.FieldErrors
// FormValues carries the submitted values for the form that errored so the
// re-render repopulates its inputs instead of blanking them. Scoped by
// FormName/FormPoolID like FieldErrors; nil on a non-error render.
FormValues map[string]string
Success string
Error string
// Per-section errors (finding #52). A query failure in one section used
// to silently render as that section's genuine-empty state (no members,
// no transitions, no products to issue against) with no indication
// anything went wrong. Each is set only on a query failure for that
// section and rendered as its own inline alert.
ProductsError string
MembersError string
GrantsError string
TransitionsError string
}
// GetOrgEnrollment handles GET /partials/operator/organizations/{orgID}/enrollment
func (h *OperatorPartialsHandler) GetOrgEnrollment(w http.ResponseWriter, r *http.Request) {
orgID := r.PathValue("orgID")
h.renderOrgEnrollmentPage(w, r, orgID, "", "")
}
// conferralFormErrors maps the four named conferral rejections (design D3) to
// field-level form errors for the issuance/extend forms; ok=false when err is
// not a conferral domain error.
func conferralFormErrors(err error) (web.FieldErrors, bool) {
fe := web.New()
switch {
case errors.Is(err, entitlements.ErrConferralPrecedesIncumbent):
fe.Set("valid_until", "This grant's start precedes a position it would supersede.")
case errors.Is(err, entitlements.ErrConferralShapeDiverged):
fe.Set("product_id", "This product's shape changed since an existing delivery — align it before granting.")
case errors.Is(err, entitlements.ErrConferralShapeCollision):
fe.Set("product_id", "This grant collides with a rung already held by another delivery.")
case errors.Is(err, entitlements.ErrConferralAlreadyEnded):
fe.Set("", "That delivery was already ended.")
default:
return nil, false
}
return fe, true
}
// IssueGrant handles POST /partials/operator/organizations/{orgID}/grant/create
// — the single issuance form (Doc 41 merges the former plan/non-plan split):
// any published product, a quantity, an optional valid_until, a reason from
// the operator domain, and a free-text description. The pool-scoped alias
// route (.../pools/{poolID}/grant) was removed: the handler never read
// poolID (ConferGrant targets the org's default pool behind a multi-pool
// refusal guard) and no template posted to it.
// The decree is recorded and conferred in one transaction; an already-delivering
// product still records the decree as a ledger entry.
func (h *OperatorPartialsHandler) IssueGrant(w http.ResponseWriter, r *http.Request) {
session := h.AuthConfig.GetUserSession(r.Context())
orgID := r.PathValue("orgID")
if session == nil {
h.renderOrgEnrollmentPage(w, r, orgID, "", "Unauthorized")
return
}
if err := r.ParseForm(); err != nil {
h.renderOrgEnrollmentPage(w, r, orgID, "", "Invalid request")
return
}
productID := r.FormValue("product_id")
reason := r.FormValue("reason")
description := r.FormValue("description")
quantityStr := r.FormValue("quantity")
errs := web.New()
if productID == "" {
errs.Set("product_id", "Select a product.")
}
if reason == "" {
errs.Set("reason", "Select a reason.")
} else if !isValidGrantReason(reason) {
errs.Set("reason", "Choose a valid reason.")
}
if len(description) > maxGrantReasonLength {
errs.Set("description", fmt.Sprintf("Description must be %d characters or fewer.", maxGrantReasonLength))
}
quantity := int32(1)
if quantityStr != "" {
// Bound against int32 wrap on the cast (finding #31).
if q, err := strconv.Atoi(quantityStr); err == nil && q >= 1 && q <= maxGrantQuantity {
quantity = int32(q)
} else {
errs.Set("quantity", fmt.Sprintf("Quantity must be a whole number between 1 and %d.", maxGrantQuantity))
}
}
// valid_until is optional and allowed on ANY product now (Doc 41 uniform
// bounds), not only trials.
var validUntil sql.NullTime
if vu, msg := parseGrantValidUntil(r); msg != "" {
errs.Set("valid_until", msg)
} else {
validUntil = vu
}
if errs.Any() {
h.renderOrgEnrollmentFormErrors(w, r, orgID, "issue", "", errs)
return
}
// Multi-pool refusal guard (finding #32): the form has no pool selector and
// ConferGrant targets the org's default pool; refuse rather than silently
// target one of several pools. Orgs get exactly one pool today.
pools, err := h.EntitlementsQ.GetResourcePoolsByOrgID(r.Context(), orgID)
if err != nil {
h.Logger.Error("failed to list pools for issue-grant pool guard", slog.Any("error", err), slog.String("org_id", orgID))
h.renderOrgEnrollmentPage(w, r, orgID, "", "Failed to load resource pools")
return
}
if len(pools) > 1 {
errs.Set("", "This organization has more than one resource pool. Issuing a grant from this form isn't supported yet; it targets the default pool only.")
h.renderOrgEnrollmentFormErrors(w, r, orgID, "issue", "", errs)
return
}
if len(pools) == 0 {
// Defense in depth: the template blocks this form when PoolMissing is
// set (see loadOrgEnrollmentData), but a stale render or a direct POST
// must not be able to attempt conferral against a pool that doesn't
// exist (ux-honest-surfaces: "a pool-less organization is presented
// as broken, not empty"). Rendered as a page-level banner/toast
// (renderOrgEnrollmentPage), not a form FieldErrors re-render: the
// re-rendered page's PoolMissing gate would hide the issuance form
// -- and $formErr along with it -- so the field-scoped path used by
// the sibling multi-pool guard above would silently swallow this
// message here.
h.renderOrgEnrollmentPage(w, r, orgID, "", poolMissingMessage)
return
}
result, err := entitlements.ConferGrant(r.Context(), h.Database, entitlements.ConferGrantInput{
ProductID: productID,
OrgID: orgID,
GrantedByPersonID: session.PersonID,
GrantReason: reason,
Description: description,
Quantity: quantity,
ValidUntil: validUntil,
ActorType: "operator",
ActorID: uuid.NullUUID{UUID: uuid.MustParse(session.PersonID), Valid: true},
TransitionReason: description,
})
if err != nil {
if fe, ok := conferralFormErrors(err); ok {
h.renderOrgEnrollmentFormErrors(w, r, orgID, "issue", "", fe)
return
}
if fe, ok := web.FieldErrorsFromDB(err, grantConstraints); ok {
h.renderOrgEnrollmentFormErrors(w, r, orgID, "issue", "", fe)
return
}
h.Logger.Error("failed to issue grant", slog.Any("error", err))
h.renderOrgEnrollmentPage(w, r, orgID, "", "Failed to issue grant. Details are in the server logs.")
return
}
// valid_until: schedule expiration for ANY bounded grant (Doc 41 uniform
// expiry). GrantExpirationWorkflow is the sole enforcement (no sweep), so a
// failed schedule must not report plain success (finding #49).
if validUntil.Valid {
if h.TemporalClient == nil {
h.Logger.Warn("no Temporal client configured; grant expiration was not scheduled", slog.String("grant_id", result.Grant.GrantID))
h.renderOrgEnrollmentPage(w, r, orgID, "",
"Grant issued, but expiration scheduling FAILED (no scheduler) — it will not auto-expire. Revoke it manually to end it.")
return
}
workflowOptions := client.StartWorkflowOptions{
ID: "grant-expiration-" + result.Grant.GrantID,
TaskQueue: queues.Main,
}
if _, err := h.TemporalClient.ExecuteWorkflow(r.Context(), workflowOptions, wf.GrantExpirationWorkflow, wf.GrantExpirationInput{
GrantID: result.Grant.GrantID,
ValidUntil: validUntil.Time,
}); err != nil {
h.Logger.Warn("failed to schedule grant expiration workflow", slog.Any("error", err), slog.String("grant_id", result.Grant.GrantID))
h.renderOrgEnrollmentPage(w, r, orgID, "",
"Grant issued, but expiration scheduling FAILED — it will not auto-expire on its own. Retry, or revoke it manually.")
return
}
}
if result.Outcome == "noop" {
h.renderOrgEnrollmentPage(w, r, orgID, "This product is already delivering — the grant was recorded as a ledger entry.", "")
return
}
h.renderOrgEnrollmentPage(w, r, orgID, "Grant issued successfully.", "")
}
// ExtendGrant handles POST /partials/operator/organizations/{orgID}/pools/{poolID}/grant/extend
// — issues a new grant that extends ONE named position's currently-delivering
// grant (same product), then confers it. The form carries the position's
// provision_id (maintainer 2026-08-23: a pool can hold positions on several
// ladders, so "the pool's grant-backed provision" is not a well-defined
// target — the button rides a specific tier and the handler extends exactly
// that one). Conferral recognizes the lineage (extends_grant_id) and
// supersedes the incumbent as a 'transfer' (extend-as-replace); there is no
// separate transition call.
func (h *OperatorPartialsHandler) ExtendGrant(w http.ResponseWriter, r *http.Request) {
session := h.AuthConfig.GetUserSession(r.Context())
if session == nil {
h.renderOrgEnrollmentPage(w, r, r.PathValue("orgID"), "", "Unauthorized")
return
}
orgID := r.PathValue("orgID")
poolID := r.PathValue("poolID")
if err := r.ParseForm(); err != nil {
h.renderOrgEnrollmentPage(w, r, orgID, "", "Invalid request")
return
}
provisionID := r.FormValue("provision_id")
description := r.FormValue("reason")
errs := web.New()
if description == "" {
errs.Set("reason", "Reason is required.")
}
// Optional valid_until: when set, the new grant expires at that time and an
// expiration workflow is scheduled; when omitted the extension is open-ended.
var validUntil sql.NullTime
if vu, msg := parseGrantValidUntil(r); msg != "" {
errs.Set("valid_until", msg)
} else {
validUntil = vu
}
if errs.Any() {
h.renderOrgEnrollmentFormErrors(w, r, orgID, "extend", provisionID, errs)
return
}
// Multi-pool refusal guard (finding #32), mirroring IssueGrant's copy: the
// pool named in the URL is only safe to trust as "the org's pool" when
// the org resolves to exactly one active resource pool. No writes yet
// (the transaction below hasn't opened), so a refusal here writes nothing.
// Rendered via the page-level banner rather than IssueGrant's per-field
// 422 path: the extend form (unlike issue) has no form-level FieldErrors
// slot in the template, only per-field ones, and that page-level banner
// is the existing, reliable error-rendering surface for this composite.
pools, err := h.EntitlementsQ.GetResourcePoolsByOrgID(r.Context(), orgID)
if err != nil {
h.Logger.Error("failed to list pools for extend-grant pool guard", slog.Any("error", err), slog.String("org_id", orgID))
h.renderOrgEnrollmentPage(w, r, orgID, "", "Failed to load resource pools")
return
}
if len(pools) > 1 {
h.renderOrgEnrollmentPage(w, r, orgID, "", "This organization has more than one resource pool. Issuing a grant from this form isn't supported yet; it targets the default pool only.")
return
}
// Verify the URL pool actually belongs to the URL org and is that org's
// default pool (D3): the multi-pool refusal above covers the future
// multi-pool case, this covers a stale or crafted URL today.
pool, err := h.EntitlementsQ.GetResourcePoolByID(r.Context(), poolID)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
h.renderOrgEnrollmentPage(w, r, orgID, "", "This pool does not belong to this organization's default pool, so the extension was refused.")
return
}
h.Logger.Error("failed to resolve pool for extend-grant pool guard", slog.Any("error", err), slog.String("pool_id", poolID))
h.renderOrgEnrollmentPage(w, r, orgID, "", "Failed to load the resource pool")
return
}
if pool.OrgID != orgID || pool.PoolType != "default" {
h.renderOrgEnrollmentPage(w, r, orgID, "", "This pool does not belong to this organization's default pool, so the extension was refused.")
return
}
tx, err := h.Database.BeginTx(r.Context(), nil)
if err != nil {
h.Logger.Error("failed to begin transaction", slog.Any("error", err))
h.renderOrgEnrollmentPage(w, r, orgID, "", "Transaction failed")
return
}
defer tx.Rollback()
q := entitlements.New(tx)
// Serialize concurrent extends on the pool.
if _, err := tx.ExecContext(r.Context(), "SELECT 1 FROM core.resource_pools WHERE pool_id = $1 FOR UPDATE", poolID); err != nil {
h.Logger.Error("failed to lock pool row", slog.Any("error", err))
h.renderOrgEnrollmentPage(w, r, orgID, "", "Pool lock failed")
return
}
// Target = exactly the provision the form named, verified to be an
// active, grant-backed provision of THIS pool. Never "the first
// grant-backed one": a pool with positions on several ladders (or a
// grant-backed non-plan delivery) would make that an arbitrary pick
// that can disagree with the tier the button named.
if provisionID == "" {
h.renderOrgEnrollmentPage(w, r, orgID, "", "The extension did not name a position. Reload the page and try again.")
return
}
provisions, err := q.GetActivePoolProvisionsByPoolID(r.Context(), poolID)
if err != nil {
h.Logger.Error("failed to list active provisions for extend", slog.Any("error", err))
h.renderOrgEnrollmentPage(w, r, orgID, "", "Failed to resolve the pool's active delivery")
return
}
var target *entitlements.PoolProvision
for i := range provisions {
if provisions[i].ProvisionID == provisionID {
target = &provisions[i]
break
}
}
if target == nil {
h.renderOrgEnrollmentPage(w, r, orgID, "", "That position is no longer active on this pool, so the extension was refused. Reload the page to see the current positions.")
return
}
if !target.GrantID.Valid {
h.renderOrgEnrollmentPage(w, r, orgID, "", "That position's delivery is not grant-backed (it may be subscription-billed), so there is no grant to extend.")
return
}
grant, err := q.CreateGrant(r.Context(), entitlements.CreateGrantParams{
ProductID: target.ProductID,
GrantedToOrgID: uuid.NullUUID{UUID: uuid.MustParse(orgID), Valid: true},
GrantedByPersonID: uuid.NullUUID{UUID: uuid.MustParse(session.PersonID), Valid: true},
GrantReason: "manual",
Description: sql.NullString{String: description, Valid: description != ""},
Quantity: target.Quantity,
ValidUntil: validUntil,
ExtendsGrantID: target.GrantID,
})
if err != nil {
if fe, ok := web.FieldErrorsFromDB(err, grantConstraints); ok {
h.renderOrgEnrollmentFormErrors(w, r, orgID, "extend", provisionID, fe)
return
}
h.Logger.Error("failed to create extend grant", slog.Any("error", err))
h.renderOrgEnrollmentPage(w, r, orgID, "", "Failed to create grant. Details are in the server logs.")
return
}
if _, _, err := q.Confer(r.Context(), entitlements.ConferParams{
PoolID: poolID,
ProductID: target.ProductID,
GrantID: uuid.NullUUID{UUID: uuid.MustParse(grant.GrantID), Valid: true},
Quantity: target.Quantity,
ActorType: "operator",
ActorID: uuid.NullUUID{UUID: uuid.MustParse(session.PersonID), Valid: true},
Reason: sql.NullString{String: description, Valid: description != ""},
}); err != nil {
if fe, ok := conferralFormErrors(err); ok {
h.renderOrgEnrollmentFormErrors(w, r, orgID, "extend", provisionID, fe)
return
}
h.Logger.Error("extend confer failed", slog.Any("error", err))
h.renderOrgEnrollmentPage(w, r, orgID, "", "Extend failed. Details are in the server logs.")
return
}
if err := entitlements.MaterializePoolEntitlements(r.Context(), q, poolID); err != nil {
h.Logger.Error("failed to materialize after extend", slog.Any("error", err))
h.renderOrgEnrollmentPage(w, r, orgID, "", "Extend failed. Details are in the server logs.")
return
}
if err := tx.Commit(); err != nil {
h.Logger.Error("failed to commit transaction", slog.Any("error", err))
h.renderOrgEnrollmentPage(w, r, orgID, "", "Commit failed")
return
}
// Schedule expiration when the extension has a deadline (finding #49).
if validUntil.Valid {
if h.TemporalClient == nil {
h.Logger.Warn("no Temporal client configured; extension expiration was not scheduled", slog.String("grant_id", grant.GrantID))
h.renderOrgEnrollmentPage(w, r, orgID, "",
"Extended, but expiration scheduling FAILED (no scheduler) — it will not auto-expire.")
return
}
workflowOptions := client.StartWorkflowOptions{
ID: "grant-expiration-" + grant.GrantID,
TaskQueue: queues.Main,
}
if _, err := h.TemporalClient.ExecuteWorkflow(r.Context(), workflowOptions, wf.GrantExpirationWorkflow, wf.GrantExpirationInput{
GrantID: grant.GrantID,
ValidUntil: validUntil.Time,
}); err != nil {
h.Logger.Warn("failed to schedule extension expiration workflow", slog.Any("error", err), slog.String("grant_id", grant.GrantID))
h.renderOrgEnrollmentPage(w, r, orgID, "",
"Extended, but expiration scheduling FAILED — it will not auto-expire on its own. Retry, or revoke it manually.")
return
}
}
msg := "Delivery extended successfully."
if validUntil.Valid {
msg = "Delivery extended; new expiration scheduled."
}
h.renderOrgEnrollmentPage(w, r, orgID, msg, "")
}
// RevokeGrantAndTransition handles POST /partials/operator/grants/{grantID}/revoke-and-transition
// — records the revocation decree, then ends the grant's conferral. end_conferral
// succeeds quietly against nothing live, so orphan grants and off-ladder grants
// need no special branch.
func (h *OperatorPartialsHandler) RevokeGrantAndTransition(w http.ResponseWriter, r *http.Request) {
session := h.AuthConfig.GetUserSession(r.Context())
if session == nil {
h.renderOrgEnrollmentPage(w, r, r.FormValue("org_id"), "", "Unauthorized")
return
}
grantID := r.PathValue("grantID")
orgID := r.FormValue("org_id")
if orgID == "" {
h.renderOrgEnrollmentPage(w, r, orgID, "", "Organization ID is required")
return
}
grantUUID, err := uuid.Parse(grantID)
if err != nil {
h.renderOrgEnrollmentPage(w, r, orgID, "", "Invalid grant ID")
return
}
// Decree-level guards: a non-active grant is already revoked/expired, and a
// system-managed default-reason grant is not operator-revocable (reapply
// would just re-mint it). Operators change the default by editing the org
// type's default ladder instead.
grantRow, err := h.EntitlementsQ.GetGrantByID(r.Context(), grantID)
if err != nil {
h.Logger.Error("failed to load grant for revoke", slog.Any("error", err))
h.renderOrgEnrollmentPage(w, r, orgID, "", "Grant not found")
return
}
if grantRow.Status != "active" {
h.renderOrgEnrollmentPage(w, r, orgID, "", "This grant is not active (already revoked or expired).")
return
}
if grantRow.GrantReason == "default" {
h.renderOrgEnrollmentPage(w, r, orgID, "",
"This grant is the org-type default. Edit the org type's default ladder to change it.")
return
}
tx, err := h.Database.BeginTx(r.Context(), nil)
if err != nil {
h.Logger.Error("failed to begin transaction", slog.Any("error", err))
h.renderOrgEnrollmentPage(w, r, orgID, "", "Transaction failed")
return
}
defer tx.Rollback()
q := entitlements.New(tx)
// The grant delivers to the org's default pool (ConferGrant targets it);
// resolve it for the post-end re-materialize.
pool, err := q.GetDefaultPoolByOrgID(r.Context(), orgID)
if err != nil {
h.Logger.Error("failed to resolve default pool for revoke", slog.Any("error", err))
h.renderOrgEnrollmentPage(w, r, orgID, "", "Failed to resolve the org's pool")
return
}
if _, err := tx.ExecContext(r.Context(), "SELECT 1 FROM core.resource_pools WHERE pool_id = $1 FOR UPDATE", pool.PoolID); err != nil {
h.Logger.Error("failed to lock pool row", slog.Any("error", err))
h.renderOrgEnrollmentPage(w, r, orgID, "", "Pool lock failed")
return
}
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 {
if fe, ok := web.FieldErrorsFromDB(err, grantConstraints); ok {
h.renderOrgEnrollmentPage(w, r, orgID, "", fieldErrorsBanner(fe))
return
}
h.Logger.Error("failed to revoke grant", slog.Any("error", err))
h.renderOrgEnrollmentPage(w, r, orgID, "", "Failed to revoke grant. Details are in the server logs.")
return
}
if _, err := q.EndConferral(r.Context(), entitlements.EndConferralParams{
GrantID: uuid.NullUUID{UUID: grantUUID, Valid: true},
ActorType: "operator",
ActorID: uuid.NullUUID{UUID: uuid.MustParse(session.PersonID), Valid: true},
Reason: sql.NullString{String: "operator_revocation", Valid: true},
}); err != nil {
if fe, ok := conferralFormErrors(err); ok {
h.renderOrgEnrollmentPage(w, r, orgID, "", fieldErrorsBanner(fe))
return
}
h.Logger.Error("failed to end grant conferral", slog.Any("error", err))
h.renderOrgEnrollmentPage(w, r, orgID, "", "Failed to end the grant's delivery. Details are in the server logs.")
return
}
// Restore the org-type baseline when the revoke left the default ladder
// vacant — a deliberate separate operation, never a side effect of
// end_conferral (guarded so a position held by another source is never
// superseded by the restoration).
if _, err := entitlements.ReapplyDefaultsIfVacant(r.Context(), tx, pool.PoolID, entitlements.Actor{
ActorType: "system",
Reason: "post-revocation default restoration",
}); err != nil {
h.Logger.Error("failed to restore default after revoke", slog.Any("error", err))
h.renderOrgEnrollmentPage(w, r, orgID, "", "Failed to restore the org default. Details are in the server logs.")
return
}
if err := entitlements.MaterializePoolEntitlements(r.Context(), q, pool.PoolID); err != nil {
h.Logger.Error("failed to materialize after revoke", slog.Any("error", err))
h.renderOrgEnrollmentPage(w, r, orgID, "", "Failed to recompute entitlements. Details are in the server logs.")
return
}
if err := tx.Commit(); err != nil {
h.Logger.Error("failed to commit transaction", slog.Any("error", err))
h.renderOrgEnrollmentPage(w, r, orgID, "", "Commit failed")
return
}
h.renderOrgEnrollmentPage(w, r, orgID, "Grant revoked.", "")
}
func (h *OperatorPartialsHandler) renderOrgEnrollmentPage(w http.ResponseWriter, r *http.Request, orgID string, success string, errMsg string) {
fireSuccessToast(w, success)
fireErrorToast(w, errMsg)
data := h.loadOrgEnrollmentData(r, orgID, "", errMsg)
h.Templates.Render(w, "operator_enrollment.html", data)
}
// renderOrgEnrollmentFormErrors writes 422 (so error-handler.js lets the
// 4xx-with-body swap proceed) and re-renders the composite with the
// supplied FieldErrors populated on the named form. formName is one of
// "issue", "create", or "extend"; poolID is set for issue/extend so the
// template can match the right form instance.
//
// Convention per docs/operator-ux-conventions.md §6 + §8.
func (h *OperatorPartialsHandler) renderOrgEnrollmentFormErrors(w http.ResponseWriter, r *http.Request, orgID, formName, poolID string, errs web.FieldErrors) {
w.WriteHeader(http.StatusUnprocessableEntity)
data := h.loadOrgEnrollmentData(r, orgID, "", "")
data.FormName = formName
data.FormPoolID = poolID
data.FieldErrors = errs
data.FormValues = map[string]string{
"product_id": r.FormValue("product_id"),
"valid_until": r.FormValue("valid_until"),
"reason": r.FormValue("reason"),
"quantity": r.FormValue("quantity"),
"description": r.FormValue("description"),
}
h.Templates.Render(w, "operator_enrollment.html", data)
}
// loadOrgEnrollmentData hydrates the per-org composite view model. Shared by
// the legacy partial endpoint (renderOrgEnrollmentPage) and the new MPA page
// handler (GetOrganizationDetailPage); kept here next to the data shape it
// produces. Errors are recorded on data.Error so the template renders a
// single error banner rather than the caller juggling HTTP statuses.
func (h *OperatorPartialsHandler) loadOrgEnrollmentData(r *http.Request, orgID string, success string, errMsg string) OrgEnrollmentData {
data := OrgEnrollmentData{
OrgID: orgID,
Success: success,
Error: errMsg,
GrantReasons: grantReasonDomain,
}
org, err := h.OrgQ.GetOrganizationByID(r.Context(), orgID)
if err != nil {
h.Logger.Error("failed to get organization", slog.Any("error", err), slog.String("org_id", orgID))
data.Error = "Organization not found"
return data
}
data.OrgName = org.Name
data.OrgSlug = org.Slug
data.OrgOwnerPersonID = org.OwnerPersonID
if owner, err := h.IdentityQ.GetPersonByID(r.Context(), org.OwnerPersonID); err == nil {
data.OrgOwnerName = owner.DisplayName
} else {
h.Logger.Warn("failed to resolve organization owner", slog.Any("error", err), slog.String("org_id", orgID), slog.String("owner_person_id", org.OwnerPersonID))
}
// Load pools for the org. Two reads: the active-only set the transition
// history / subscriptionHeldLadders logic below already relied on
// (targeting semantics: "which pool would conferral use"), and an
// any-status read for the panel's own display, so a pool's actual
// status is visible instead of GetResourcePoolsByOrgID's active-only
// filter silently hiding it, and so "this org has zero pool rows" (the
// pool-less breakage case) is distinguishable from "this org's only
// pool exists but isn't active" (ux-honest-surfaces: "pool status and
// usage are visible on the organization view"; "a pool-less
// organization is presented as broken, not empty").
pools, err := h.EntitlementsQ.GetResourcePoolsByOrgID(r.Context(), orgID)
if err != nil {
h.Logger.Error("failed to list pools", slog.Any("error", err), slog.String("org_id", orgID))
data.Error = "Failed to load pools"
}
allPools, err := h.EntitlementsQ.ListResourcePoolsByOrgIDAnyStatus(r.Context(), orgID)
if err != nil {
h.Logger.Error("failed to list pools (any status)", slog.Any("error", err), slog.String("org_id", orgID))
if data.Error == "" {
data.Error = "Failed to load pools"
}
} else {
data.PoolMissing = len(allPools) == 0
}
poolVMs := make([]PoolEnrollmentViewModel, len(allPools))
for i, pool := range allPools {
vm := PoolEnrollmentViewModel{
PoolID: pool.PoolID,
PoolName: pool.Name,
PoolType: pool.PoolType,
Status: pool.Status,
}
// Grant-backing per provision, so each position's extendability is
// its own fact, never a pool-wide guess.
grantBacked := map[string]bool{}
if provisions, err := h.EntitlementsQ.GetActivePoolProvisionsByPoolID(r.Context(), pool.PoolID); err == nil {
for _, p := range provisions {
grantBacked[p.ProvisionID] = p.GrantID.Valid
}
}
// Every active ladder placement this pool holds, grouped by
// delivery — a pool can be on several ladders at once, and a shared
// product places ONE delivery on several of them. Grouping renders
// the product once with its single Extend control; per-ladder facts
// become rung pills (maintainer design round 2026-08-24).
attachments, err := h.EntitlementsQ.GetActiveAttachmentsByPool(r.Context(), pool.PoolID)
if err == nil {
deliveryIndex := map[string]int{}
for _, att := range attachments {
di, ok := deliveryIndex[att.ProvisionID]
if !ok {
d := PoolDeliveryViewModel{
ProvisionID: att.ProvisionID,
ProductID: att.ProductID,
GrantBacked: grantBacked[att.ProvisionID],
}
if product, err := h.BillingQ.GetProductByID(r.Context(), att.ProductID); err == nil {
d.ProductName = product.Name
}
di = len(vm.Deliveries)
deliveryIndex[att.ProvisionID] = di
vm.Deliveries = append(vm.Deliveries, d)
}
rung := PoolRungViewModel{
ActivatedAt: att.ActivatedAt.Format("Jan 2, 2006 3:04 PM"),
}
if ladder, err := h.BillingQ.GetPlanLadderByID(r.Context(), att.PlanLadderID); err == nil {
rung.LadderKey = ladder.LadderKey
}
if tier, err := h.BillingQ.GetTier(r.Context(), billing.GetTierParams{
PlanLadderID: att.PlanLadderID,
ProductID: att.ProductID,
}); err == nil {
rung.Rank = tier.Rank
}
vm.Deliveries[di].Rungs = append(vm.Deliveries[di].Rungs, rung)
}
vm.HasAttachment = len(vm.Deliveries) > 0
}
if usage, err := h.EntitlementsQ.ListPoolUsageWithLimitsByPoolID(r.Context(), pool.PoolID); err == nil {
for _, u := range usage {
vm.Usage = append(vm.Usage, PoolUsageViewModel{
ResourceKey: u.ResourceKey,
Used: u.CurrentUsage,
Limit: u.ResourceLimit,
})
}
}
poolVMs[i] = vm
}
data.Pools = poolVMs
// Load transitions for all pools, resolving names an operator can
// read: the ladder's key, and tier names for the from/to ranks. Names
// resolve against the ladder's CURRENT shape (best effort; a reordered
// ladder can have moved products since), so a rank with no current
// tier falls back to "rank N" and the section heading's tooltip states
// the caveat. Ladder shapes are fetched once each and cached.
ladderKeys := map[string]string{}
ladderTierNames := map[string]map[int32]string{}
tierLabel := func(ladderID string, rank sql.NullInt32) string {
if !rank.Valid {
return "—"
}
if _, ok := ladderTierNames[ladderID]; !ok {
names := map[int32]string{}
if tiers, err := h.BillingQ.ListTiersByLadderWithProducts(r.Context(), ladderID); err == nil {
for _, t := range tiers {
names[t.Rank] = t.ProductName
}
}
ladderTierNames[ladderID] = names
}
if name, ok := ladderTierNames[ladderID][rank.Int32]; ok {
return name
}
return "rank " + strconv.Itoa(int(rank.Int32))
}
for _, pool := range pools {
transitions, err := h.EntitlementsQ.ListTransitionsByPool(r.Context(), pool.PoolID)
if err != nil {
h.Logger.Error("failed to list transitions for pool", slog.Any("error", err), slog.String("pool_id", pool.PoolID))
data.TransitionsError = "Failed to load tier changes for one or more pools."
continue
}
for _, trn := range transitions {
actorName := ""
if trn.ActorID.Valid {
if person, err := h.IdentityQ.GetPersonByID(r.Context(), trn.ActorID.UUID.String()); err == nil {
actorName = person.DisplayName
}
}
reason := ""
if trn.Reason.Valid {
reason = trn.Reason.String
}
if _, ok := ladderKeys[trn.PlanLadderID]; !ok {
ladderKeys[trn.PlanLadderID] = ""
if ladder, err := h.BillingQ.GetPlanLadderByID(r.Context(), trn.PlanLadderID); err == nil {
ladderKeys[trn.PlanLadderID] = ladder.LadderKey
}
}
changeLabel := transitionChangeLabels[trn.TransitionType]
if changeLabel == "" {
changeLabel = trn.TransitionType
}
changeTooltip := ""
if trn.TransitionType == "transfer" {
changeTooltip = "Same tier, different funding source: another grant or subscription took over this placement."
}
data.Transitions = append(data.Transitions, TransitionHistoryViewModel{
TransitionID: trn.TransitionID,
LadderKey: ladderKeys[trn.PlanLadderID],
ChangeLabel: changeLabel,
ChangeTooltip: changeTooltip,
FromLabel: tierLabel(trn.PlanLadderID, trn.FromRank),
ToLabel: tierLabel(trn.PlanLadderID, trn.ToRank),
ActorType: trn.ActorType,
ActorName: actorName,
Reason: reason,
EffectiveAt: trn.EffectiveAt.Format("Jan 2, 2006 3:04 PM"),
})
}
}
// ListTransitionsByPool sorts effective_at DESC (most recent first); the
// loop above appends one pool's block at a time, so this is only a true
// global recency order for the common single-active-pool org. The pager
// windows the full set (tc_page; maintainer design round 2026-08-24
// replaced the flat 16-cap with the shared pager here).
tcParams := ParseListParamsNS(r, "tc_", "")
tcParams.PerPage = ParsePerPage(r, "tc_", embeddedListDefaultPerPage)
data.Transitions, data.TransitionsTotal = pageSliceClamped(data.Transitions, &tcParams)
data.TierChangesNav = ListNav{
BasePath: "/operator/organizations/" + orgID,
ParamPrefix: "tc_",
Page: tcParams.Page,
Total: int64(data.TransitionsTotal),
Extra: siblingListState(r, "tc_"),
PerPage: tcParams.PerPage,
DefaultPerPage: embeddedListDefaultPerPage,
PerPageOptions: perPageOptions,
Target: "#tier-changes-panel",
SyncSelect: "#plan-grants-panel",
}
// Single issuance form (Doc 41): every published, entitlement-set-bearing
// product is issuable — internal wrap products (is_public=false) render with
// an "internal" badge. The pre-check below flags products whose conferral
// would supersede a subscription-held rung (advisory only; the subscription
// keeps billing — §11.2 residue).
subscriptionHeldLadders := h.subscriptionHeldLadders(r, pools)
products, err := h.BillingQ.ListActiveProducts(r.Context())
if err != nil {
h.Logger.Error("failed to list active products", slog.Any("error", err), slog.String("org_id", orgID))
data.ProductsError = "Failed to load the product catalog. The issuance form is unavailable until this is resolved."
} else {
for _, p := range products {
if p.LifecycleStatus != "published" || !p.EntitlementSetID.Valid {
continue
}
opt := IssuanceProductOption{
ProductID: p.ProductID,
Name: p.Name,
IsInternal: !p.IsPublic,
}
if p.DisplayCategory.Valid {
opt.DisplayCategory = p.DisplayCategory.String
}
if len(subscriptionHeldLadders) > 0 {
if ladders, err := h.BillingQ.ListLaddersByProduct(r.Context(), p.ProductID); err == nil {
for _, l := range ladders {
if subscriptionHeldLadders[l.PlanLadderID] {
opt.SupersedesSubscription = true
break
}
}
}
}
data.IssuanceProducts = append(data.IssuanceProducts, opt)
}
}
// Billing summary section. Reads the first billing account for the org
// and pulls the latest subscription + latest invoice + outstanding open
// balance via three small queries (the LEFT JOIN LATERAL composite query
// confused sqlc nullability inference; splitting is simpler and the join
// count is dwarfed by the per-pool loops above).
accounts, err := h.BillingQ.ListBillingAccountsByOrgID(r.Context(), orgID)
if err == nil && len(accounts) > 0 {
acct := accounts[0]
summary := BillingSummaryViewModel{
HasAccount: true,
BillingAccountID: acct.BillingAccountID,
AccountStatus: acct.Status,
}
if sub, err := h.BillingQ.GetLatestSubscriptionByBillingAccountID(r.Context(), acct.BillingAccountID); err == nil {
summary.HasSubscription = true
summary.SubscriptionStatus = sub.Status
summary.CancelAtPeriodEnd = sub.CancelAtPeriodEnd
if sub.CurrentPeriodEnd.Valid {
summary.SubscriptionPeriodEnd = sub.CurrentPeriodEnd.Time.Format("Jan 2, 2006")
}
}
if inv, err := h.BillingQ.GetLatestInvoiceByBillingAccountID(r.Context(), acct.BillingAccountID); err == nil {
summary.HasInvoice = true
summary.LatestInvoiceID = inv.InvoiceID
summary.LatestInvoiceDate = inv.CreatedAt.Format("Jan 2, 2006")
summary.LatestInvoiceAmountDue = formatCurrency(inv.AmountDue, inv.Currency)
summary.LatestInvoicePaid = inv.Status == "paid"
summary.Currency = inv.Currency
}
if bal, err := h.BillingQ.GetOutstandingBalanceByBillingAccountID(r.Context(), acct.BillingAccountID); err == nil {
summary.OutstandingBalance = bal
// bal is a BIGINT SUM; formatCurrency takes int32 like every other
// amount column in this codebase (Stripe's own amounts are int32).
// A per-account open balance exceeding ~$21M would truncate here —
// out of scope for this display fix (finding #6).
summary.OutstandingBalanceFormatted = formatCurrency(int32(bal), summary.Currency)
}
data.BillingSummary = summary
}
// Members section. Each row links to /operator/persons/{personID}
// (closes the deferred 3.7 task from operator-mpa-conversion).
members, err := h.OrgQ.ListActiveMembersByOrgID(r.Context(), orgID)
if err != nil {
h.Logger.Error("failed to list active members", slog.Any("error", err), slog.String("org_id", orgID))
data.MembersError = "Failed to load members."
} else {
// ListActiveMembersByOrgID sorts alphabetically by display name, not
// by recency, so the "most recent" cap below needs its own sort by
// JoinedAt (most recently joined first) rather than relying on
// query order the way the grants and transitions lists do.
sort.Slice(members, func(i, j int) bool {
return members[i].JoinedAt.After(members[j].JoinedAt)
})
capped, total, wasCapped := capEnrollmentList(members)
data.MembersTotal = total
data.MembersCapped = wasCapped
data.Members = make([]MemberRowViewModel, len(capped))
for i, m := range capped {
data.Members[i] = MemberRowViewModel{
PersonID: m.PersonID,
DisplayName: m.DisplayName,
Email: m.PrimaryEmail,
RoleName: m.RoleName,
JoinedAt: m.JoinedAt.Format("Jan 2, 2006"),
}
}
}
// Load every grant for the org with derived delivery state, via the
// shared ListGrantsWithDelivery query (design decision 1: the grants
// index and this composite render from the same derivation so they can
// never disagree about what "live" / "superseded" / "inactive" mean —
// see buildGrantViewModels). The ledger renders under two tabs
// (maintainer design round 2026-08-24): Active (default) shows only
// rows delivering right now; History shows every grant ever, with the
// supersession audit trail. Search (product name or reason, both tabs)
// and the pager window the set Go-side — org-scoped cardinality is
// modest, and the delivery state is already derived per row.
grantTabOptions := []FacetOption{{Value: "", Label: "Active"}, {Value: "history", Label: "History"}}
gParams := ParseListParamsNS(r, "", "tab")
gParams.Facet = ValidFacet(gParams.Facet, grantTabOptions)
gParams.PerPage = ParsePerPage(r, "", embeddedListDefaultPerPage)
orgUUID, err := uuid.Parse(orgID)
if err != nil {
h.Logger.Error("failed to parse org ID for grants query", slog.Any("error", err), slog.String("org_id", orgID))
data.GrantsError = "Failed to load grants."
} else {
grants, err := h.EntitlementsQ.ListGrantsWithDelivery(r.Context(), uuid.NullUUID{UUID: orgUUID, Valid: true})
if err != nil {
h.Logger.Error("failed to list grants", slog.Any("error", err), slog.String("org_id", orgID))
data.GrantsError = "Failed to load grants."
} else {
// resolveOrgName=false: this composite is already scoped to one
// named organization, so every row's OrgName would just repeat
// it. ListGrantsWithDelivery orders live-first, then created_at
// DESC (see queries/grants.sql).
all := h.buildGrantViewModels(r, grants, false)
filtered := make([]GrantViewModel, 0, len(all))
q := strings.ToLower(strings.TrimSpace(gParams.Q))
for _, g := range all {
if gParams.Facet != "history" && g.DeliveryState != "live" {
continue
}
if q != "" && !strings.Contains(strings.ToLower(g.ProductName), q) && !strings.Contains(strings.ToLower(g.GrantReason), q) {
continue
}
filtered = append(filtered, g)
}
data.Grants, data.GrantsTotal = pageSliceClamped(filtered, &gParams)
}
}
data.GrantsNav = ListNav{
BasePath: "/operator/organizations/" + orgID,
SearchPlaceholder: "Search product or reason",
FacetParam: "tab",
FacetOptions: grantTabOptions,
Q: gParams.Q,
Facet: gParams.Facet,
Page: gParams.Page,
Total: int64(data.GrantsTotal),
Extra: siblingListState(r, "", "tab"),
PerPage: gParams.PerPage,
DefaultPerPage: embeddedListDefaultPerPage,
PerPageOptions: perPageOptions,
Target: "#plan-grants-panel",
SyncSelect: "#tier-changes-panel",
}
return data
}
// subscriptionHeldLadders returns the set of plan ladders currently occupied by
// a live subscription-sourced provision on any of the org's pools. The issuance
// form uses it to advise (non-blocking) when a grant would supersede a
// subscription-held rung — the subscription keeps billing (§11.2 residue). Pure
// read; best-effort (per-pool query failures are skipped, not surfaced).
func (h *OperatorPartialsHandler) subscriptionHeldLadders(r *http.Request, pools []entitlements.ResourcePool) map[string]bool {
held := map[string]bool{}
for _, pool := range pools {
provisions, err := h.EntitlementsQ.GetActivePoolProvisionsByPoolID(r.Context(), pool.PoolID)
if err != nil {
continue
}
for _, p := range provisions {
if !p.SubscriptionID.Valid {
continue
}
attachments, err := h.EntitlementsQ.GetLadderAttachmentsByProvision(r.Context(), p.ProvisionID)
if err != nil {
continue
}
for _, a := range attachments {
if a.Status != "ended" {
held[a.PlanLadderID] = true
}
}
}
}
return held
}