388 lines
14 KiB
Go
388 lines
14 KiB
Go
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
|
||
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
|
||
|
||
package server
|
||
|
||
import (
|
||
"fmt"
|
||
"math"
|
||
"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)
|
||
}
|
||
|
||
// Offset is computed in 64 bits and capped at the int32 the query takes: a
|
||
// page number large enough to overflow would otherwise wrap negative and
|
||
// Postgres refuses a negative OFFSET (2026-09 audit candidate "Unbounded
|
||
// page overflows int32 OFFSET"). The capped offset is past any real list,
|
||
// so FetchPage's out-of-range clamp lands the reader on page 1.
|
||
func (p ListParams) Offset() int32 {
|
||
page := int64(p.Page)
|
||
if page < 1 {
|
||
page = 1
|
||
}
|
||
limit := int64(p.Limit())
|
||
if page-1 > math.MaxInt32/limit {
|
||
return math.MaxInt32
|
||
}
|
||
return int32((page - 1) * 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
|
||
}
|
||
|
||
// envParam and envAll are the billing views' environment switch
|
||
// (stripe-environment-stamp D7). It rides ListNav.Extra rather than the
|
||
// facet, because the scaffold carries one facet per list and invoices and
|
||
// subscriptions spend theirs on status; Extra is already carried into
|
||
// every URL the nav builds and into the search form's hidden inputs, so
|
||
// the state survives a search, a facet click, a page and a page-size
|
||
// change without any per-list plumbing.
|
||
const (
|
||
envParam = "env"
|
||
envAll = "all"
|
||
)
|
||
|
||
// ParseEnvAll reports whether the request asks a billing view for every
|
||
// environment. Any other value of env, including none, means the key's own.
|
||
func ParseEnvAll(r *http.Request) bool {
|
||
return r.URL.Query().Get(envParam) == envAll
|
||
}
|
||
|
||
// EnvExtra is the Extra a billing ListNav carries so the all state
|
||
// survives every link the scaffold builds. It is nil in the ordinary
|
||
// state, which keeps the canonical view's URLs canonical.
|
||
func EnvExtra(all bool) url.Values {
|
||
if !all {
|
||
return nil
|
||
}
|
||
return url.Values{envParam: []string{envAll}}
|
||
}
|
||
|
||
// EnvURL is this list's URL in the other environment state, from page one:
|
||
// the absence line's switch. A state change re-windows the whole set, so
|
||
// it resets the page the way a facet or page-size change does, and keeps
|
||
// the search and the facet.
|
||
func (n ListNav) EnvURL(all bool) string {
|
||
extra := url.Values{}
|
||
for k, vals := range n.Extra {
|
||
if k == envParam {
|
||
continue
|
||
}
|
||
extra[k] = vals
|
||
}
|
||
if all {
|
||
extra.Set(envParam, envAll)
|
||
}
|
||
n.Extra = extra
|
||
return n.url(n.Q, n.Facet, 1, n.PerPage)
|
||
}
|
||
|
||
// 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 From–To 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 X–Y 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)
|
||
}
|