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) }