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

131 lines
4.6 KiB
Go

package server
import (
"database/sql"
"log/slog"
"net/http"
"github.com/google/uuid"
"git.coopcloud.tech/wiki-cafe/member-console/internal/identity"
"git.coopcloud.tech/wiki-cafe/member-console/internal/systemtenant"
)
// PersonListViewModel represents one row of the People directory
// (GET /operator/persons). Distinct from PersonViewModel (the resolved
// person-detail projection): this carries only what the directory row
// needs, plus the email's verified state and the system-identity marker.
type PersonListViewModel struct {
PersonID string
// DisplayName and Email are the directory's search-matched fields
// (operator-list-scale: "people: display name and email").
DisplayName string
Email string
EmailVerified bool
Status string
// JoinedAt is CreatedAt formatted like every other operator list date
// ("Jan 2, 2006").
JoinedAt string
}
// PersonsData is the body data for operator_persons.html.
type PersonsData struct {
Persons []PersonListViewModel
Error string
// Nav is the list-controls view model (operator-list-scale): search
// and the true-total pager. People has no status facet, so Nav.Facet /
// FacetOptions stay unset. Its Filtered() decides whether an empty
// Persons slice renders the no-match state or the directory's true-
// empty state.
Nav ListNav
}
// systemPersonID resolves the person_id of the reserved system person —
// the owner of the System tenant (systemtenant.OrgType). The directory
// EXCLUDES that person (maintainer decision 2026-08-23): it is
// infrastructure that never "joined" — a synthetic non-loginable user
// (urn: OIDC subject no IdP can issue) with a non-routable .invalid email,
// existing only because organizations.owner_person_id is NOT NULL and the
// System tenant needs an owner — so listing it among members would pollute
// the newest-joined view. It stays visible in the organizations context as
// the System tenant's owner. Detected structurally — the person is the
// owner_person_id of the organization whose org_type is the reserved
// systemtenant.OrgType — never by matching name or email. Returns ""
// (excludes nothing) when the System tenant hasn't been provisioned yet or
// the lookup fails — degrading to an unfiltered directory rather than
// failing the page.
func (h *OperatorPartialsHandler) systemPersonID(r *http.Request) string {
orgs, err := h.OrgQ.ListOrganizationsByType(r.Context(), systemtenant.OrgType)
if err != nil || len(orgs) == 0 {
return ""
}
return orgs[0].OwnerPersonID
}
// GetPersonsPage handles GET /operator/persons — the People directory
// (operator-people-directory D6): a searchable, paginated list of every
// person except the reserved system person, newest-joined first, governed
// by the operator-list-scale contract. Rows link to the existing person
// detail at /operator/persons/{personID}.
func (h *OperatorPartialsHandler) GetPersonsPage(w http.ResponseWriter, r *http.Request) {
params := ParseListParams(r, "") // People has no status facet.
bodyData := PersonsData{}
var excludeID uuid.NullUUID
if sysPersonID := h.systemPersonID(r); sysPersonID != "" {
if id, pErr := uuid.Parse(sysPersonID); pErr == nil {
excludeID = uuid.NullUUID{UUID: id, Valid: true}
}
}
rows, total, err := FetchPage(&params, func(limit, offset int32) ([]identity.ListPersonsPageRow, int64, error) {
page, pErr := h.IdentityQ.ListPersonsPage(r.Context(), identity.ListPersonsPageParams{
Q: sql.NullString{String: params.Q, Valid: params.Q != ""},
ExcludePersonID: excludeID,
PageLimit: limit,
PageOffset: offset,
})
if pErr != nil {
return nil, 0, pErr
}
var total int64
if len(page) > 0 {
total = page[0].TotalCount
}
return page, total, nil
})
if err != nil {
h.Logger.Error("failed to list persons", slog.Any("error", err))
bodyData.Error = "Failed to retrieve persons"
} else {
vms := make([]PersonListViewModel, len(rows))
for i, p := range rows {
vms[i] = PersonListViewModel{
PersonID: p.PersonID,
DisplayName: p.DisplayName,
Email: p.PrimaryEmail,
EmailVerified: p.PrimaryEmailVerified,
Status: p.Status,
JoinedAt: p.CreatedAt.Format("Jan 2, 2006"),
}
}
bodyData.Persons = vms
}
bodyData.Nav = ListNav{
BasePath: "/operator/persons",
SearchPlaceholder: "Search by name or email",
Q: params.Q,
Page: params.Page,
Total: total,
}
page := h.buildOperatorPageData(r)
page.IAPosition = "runtime:persons"
page.ActiveCapability = "persons"
page.BodyTemplate = "operator_persons.html"
page.BodyData = bodyData
h.Templates.Render(w, "operator.html", page)
}