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.
131 lines
4.6 KiB
Go
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(¶ms, 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)
|
|
}
|