Files
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

321 lines
11 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package server
import (
"fmt"
"net/http"
"net/url"
"strconv"
)
// operatorListPageSize is the fixed page size for every governed operator
// list (operator-list-scale: "Governed lists paginate with a true total
// count"). One constant so no list can quietly diverge.
const operatorListPageSize = 50
// ListParams is the parsed URL state of a governed operator list: the
// search term (`q`), one capability-appropriate facet value, and the
// 1-based page. It uses exactly the query-parameter shape
// operator-panel-navigation reserved ("Browse routes reserve
// query-parameter real estate for filters"), so list state is
// URL-addressable and a copied URL reproduces the view.
type ListParams struct {
Q string
Facet string
Page int
// PerPage is the page size; 0 means operatorListPageSize. Embedded
// lists default lower (the composite's ledger and Tier changes use 10)
// and let the operator pick from perPageOptions via ParsePerPage.
PerPage int
}
// ParseListParams reads q, the named facet parameter, and page from the
// request. A malformed or non-positive page clamps to 1 (the
// "malformed page values clamp" scenario); values the list does not
// implement are the caller's to ignore. facetParam may be "" for lists
// with no filter.
func ParseListParams(r *http.Request, facetParam string) ListParams {
return ParseListParamsNS(r, "", facetParam)
}
// ParseListParamsNS is ParseListParams with a parameter-name prefix, for
// governed lists embedded in a page that hosts more than one (each list
// reads and writes <prefix>q / <prefix>page so siblings cannot collide).
// facetParam is always the full parameter name.
func ParseListParamsNS(r *http.Request, prefix, facetParam string) ListParams {
p := ListParams{Q: r.URL.Query().Get(prefix + "q"), Page: 1}
if facetParam != "" {
p.Facet = r.URL.Query().Get(facetParam)
}
if n, err := strconv.Atoi(r.URL.Query().Get(prefix + "page")); err == nil && n > 1 {
p.Page = n
}
return p
}
// Limit and Offset are the SQL window for the current page.
func (p ListParams) Limit() int32 {
if p.PerPage > 0 {
return int32(p.PerPage)
}
return int32(operatorListPageSize)
}
func (p ListParams) Offset() int32 { return int32(p.Page-1) * p.Limit() }
// perPageOptions is the page-size set an operator can pick from on lists
// that offer the picker (embedded lists; the composite's ledger and Tier
// changes default to embeddedListDefaultPerPage, the smallest).
var perPageOptions = []int{10, 25, 50}
// embeddedListDefaultPerPage keeps embedded lists short by default
// (maintainer 2026-08-24); the picker offers the larger sizes.
const embeddedListDefaultPerPage = 10
// ParsePerPage reads <prefix>per from the request, returning def unless
// the value is one of perPageOptions — an unknown size is ignored, like
// an unknown facet.
func ParsePerPage(r *http.Request, prefix string, def int) int {
if n, err := strconv.Atoi(r.URL.Query().Get(prefix + "per")); err == nil {
for _, o := range perPageOptions {
if n == o {
return n
}
}
}
return def
}
// FetchPage runs load for the params' page and clamps back to page 1 when
// the requested page is past the end (an offset beyond the last row
// returns zero rows, and with them zero knowledge of the total — the
// re-run answers with a real page instead of an empty table). load
// returns the page's rows plus the true total for the whole filtered set
// (`count(*) OVER()` in the paged queries, design D2).
func FetchPage[R any](p *ListParams, load func(limit, offset int32) ([]R, int64, error)) ([]R, int64, error) {
rows, total, err := load(p.Limit(), p.Offset())
if err != nil {
return nil, 0, err
}
if len(rows) == 0 && p.Page > 1 {
p.Page = 1
rows, total, err = load(p.Limit(), 0)
}
return rows, total, err
}
// FacetOption is one value of a list's status filter.
type FacetOption struct {
Value string
Label string
}
// ListNav is the view model for the shared list-controls partial
// (operator_list_controls.html: "listControls", "listPager",
// "listNoMatch"). Handlers fill the static shape (BasePath, placeholder,
// facet vocabulary) and the current state (Q/Facet/Page/Total); the
// methods derive everything the template renders, including URLs that
// preserve the rest of the state per the URL-addressable requirement.
type ListNav struct {
BasePath string
SearchPlaceholder string
FacetParam string
FacetOptions []FacetOption
Q string
Facet string
Page int
Total int64
// ParamPrefix namespaces this list's q/page parameter names when the
// hosting page embeds more than one governed list; "" on standalone
// pages. FacetParam is always the full name.
ParamPrefix string
// Extra is carried verbatim into every URL this nav builds (and the
// search form's hidden inputs), so an embedded list's links preserve
// its sibling lists' state on the shared URL.
Extra url.Values
// PerPage is the effective page size (0 → operatorListPageSize);
// DefaultPerPage is the list's default (the <prefix>per parameter is
// omitted at that value so canonical views keep canonical URLs); a
// non-empty PerPageOptions makes listPager render the size picker.
PerPage int
DefaultPerPage int
PerPageOptions []int
// Target is the CSS selector of the panel wrapping an EMBEDDED list.
// When set, every control the shared defines emit (tab, pager,
// page-size and clear links, and the search form) issues a scoped
// htmx request — hx-get with hx-target/hx-select on this selector,
// outerHTML swap, URL pushed — so only the panel re-renders and the
// operator's scroll position is untouched (maintainer 2026-08-24:
// interacting with an embedded box must not jump to the top of the
// page). Plain hrefs remain as the no-JS fallback. Empty on
// standalone pages, whose controls stay ordinary boosted navigations.
Target string
// SyncSelect is the hx-select-oob value naming the SIBLING panels to
// refresh out-of-band from the same response. Without it a sibling's
// links go stale: they carry the URL state of their own last render,
// so after this list changes state (say the ledger switches to
// History) the sibling's pager would push a URL missing that state.
// Refreshing every embedded panel on every interaction keeps all
// their links carrying the full current URL state.
SyncSelect string
}
// QParam, PageParam, and PerParam are the list's parameter names, prefix
// applied; the search form's input names must match what
// ParseListParamsNS / ParsePerPage read.
func (n ListNav) QParam() string { return n.ParamPrefix + "q" }
func (n ListNav) PageParam() string { return n.ParamPrefix + "page" }
func (n ListNav) PerParam() string { return n.ParamPrefix + "per" }
// EffectivePerPage is the page size all derived math uses.
func (n ListNav) EffectivePerPage() int {
if n.PerPage > 0 {
return n.PerPage
}
return operatorListPageSize
}
// PerPageActive reports whether v is the current page size (for the
// picker's non-link rendering of the active option).
func (n ListNav) PerPageActive(v int) bool { return n.EffectivePerPage() == v }
// PerPageOffDefault reports whether the current size must ride along as a
// hidden input on the search form (a GET submit replaces the query
// string).
func (n ListNav) PerPageOffDefault() bool {
def := n.DefaultPerPage
if def == 0 {
def = operatorListPageSize
}
return n.EffectivePerPage() != def
}
// PageSize is the current page size, exposed for templates and tests.
func (n ListNav) PageSize() int { return n.EffectivePerPage() }
// Pages is the number of pages for the current filtered total (at least 1).
func (n ListNav) Pages() int {
size := int64(n.EffectivePerPage())
pages := int((n.Total + size - 1) / size)
if pages < 1 {
pages = 1
}
return pages
}
// From and To are the 1-based display range of the current page
// ("Showing FromTo of Total"); both 0 when the list is empty.
func (n ListNav) From() int {
if n.Total == 0 {
return 0
}
return (n.Page-1)*n.EffectivePerPage() + 1
}
func (n ListNav) To() int {
to := n.Page * n.EffectivePerPage()
if int64(to) > n.Total {
to = int(n.Total)
}
return to
}
// Filtered reports whether any narrowing state is active — it decides
// no-match (search/filter matched nothing) versus the list's true-empty
// state, which empty-state-guidance owns.
func (n ListNav) Filtered() bool { return n.Q != "" || n.Facet != "" }
func (n ListNav) HasPrev() bool { return n.Page > 1 }
func (n ListNav) HasNext() bool { return n.Page < n.Pages() }
// ownParam reports whether k is one of this list's own parameter names.
func (n ListNav) ownParam(k string) bool {
return k == n.QParam() || k == n.PageParam() || k == n.PerParam() || (n.FacetParam != "" && k == n.FacetParam)
}
// url assembles BasePath?<prefix>q=...&<facet>=...&<prefix>page=N&
// <prefix>per=S, omitting empty and default values so canonical views
// have canonical URLs. Extra params ride along unchanged (own keys
// excluded, so a stale caller-supplied copy of this list's state cannot
// shadow it).
func (n ListNav) url(q, facet string, page, per int) string {
v := url.Values{}
for k, vals := range n.Extra {
if n.ownParam(k) {
continue
}
v[k] = vals
}
if q != "" {
v.Set(n.QParam(), q)
}
if facet != "" && n.FacetParam != "" {
v.Set(n.FacetParam, facet)
}
if page > 1 {
v.Set(n.PageParam(), strconv.Itoa(page))
}
def := n.DefaultPerPage
if def == 0 {
def = operatorListPageSize
}
if per > 0 && per != def {
v.Set(n.PerParam(), strconv.Itoa(per))
}
if len(v) == 0 {
return n.BasePath
}
return n.BasePath + "?" + v.Encode()
}
// ExtraInputs is Extra minus this list's own keys, for the search form's
// hidden inputs (a GET form replaces the whole query string, so sibling
// state must be re-submitted explicitly).
func (n ListNav) ExtraInputs() url.Values {
v := url.Values{}
for k, vals := range n.Extra {
if n.ownParam(k) {
continue
}
v[k] = vals
}
return v
}
func (n ListNav) PrevURL() string { return n.url(n.Q, n.Facet, n.Page-1, n.PerPage) }
func (n ListNav) NextURL() string { return n.url(n.Q, n.Facet, n.Page+1, n.PerPage) }
// FacetURL switches to the given facet value (empty clears the filter),
// preserving the search and page size and resetting to page 1.
func (n ListNav) FacetURL(value string) string { return n.url(n.Q, value, 1, n.PerPage) }
// ClearSearchURL drops the search term, preserving the facet and size.
func (n ListNav) ClearSearchURL() string { return n.url("", n.Facet, 1, n.PerPage) }
// PerPageURL switches the page size, preserving search and facet and
// resetting to page 1 (a size change re-windows the whole set).
func (n ListNav) PerPageURL(v int) string { return n.url(n.Q, n.Facet, 1, v) }
// FacetActive reports whether value is the active filter.
func (n ListNav) FacetActive(value string) bool { return n.Facet == value }
// ValidFacet returns facet unchanged when it is one of the allowed
// values, "" otherwise — a reserved-but-unimplemented or mistyped value
// is ignored rather than rejected (operator-panel-navigation: "Unknown
// filter params do not error").
func ValidFacet(facet string, options []FacetOption) string {
for _, o := range options {
if o.Value == facet {
return facet
}
}
return ""
}
// Showing is the "Showing XY of N" line, empty when the list is empty
// (the no-match or empty state speaks instead).
func (n ListNav) Showing() string {
if n.Total == 0 {
return ""
}
return fmt.Sprintf("Showing %d%d of %d", n.From(), n.To(), n.Total)
}