The admin zone used to be a set of dead ends: 13 of 15 admin pages had zero outbound links, no admin detail route existed at all, and the six overview KPIs were plain text. There was no page representing a partner organisation — the entity that opportunities, mdfRequests, partnerAgreements, partnerSessions, and activityLog all key on. The missing piece was a destination, not links; links are what you add once a destination exists.
Backed by real code:
apps/partners/app/(admin)/admin/partners/[orgId]/page.tsx.../[orgId]/TierControl.tsx, action setOrgTier in lib/actions/admin.tsapps/partners/lib/org-tier.tsapps/partners/lib/admin/kpi-targets.tsapps/partners/lib/admin/org-filter.tsapps/partners/lib/admin/org-names.tsapps/partners/lib/admin/deal-summary.tsapps/partners/scripts/backfill-org-tier.tsapps/partners/lib/admin/listing.tsapps/partners/components/patterns/{ListingFooter,ListingSearch,ListingChips,OrgFilterBanner}.tsx/admin/partners/[orgId]#Keyed on clerkOrgId, gated by the internal read tier like every other admin listing. Six panels, each summarising and linking to its filtered listing rather than duplicating it:
| Panel | Content | Links to |
|---|---|---|
| Identity | Company, tier, directory state, website, HQ, industry, SF account, contact | — (carries the actions) |
| Team | Members with email, account type, role | /admin/accounts |
| Deals | Count, pipeline ARR, status breakdown, 5 most recent | /admin/opportunities?org= |
| MDF | Count, requested vs approved totals, 5 most recent | /admin/mdf?org= |
| Agreements | Titles and upload dates | /admin/agreements?org= |
| Activity | 10 most recent entries | /admin/activity?org= |
404 rule. Profiles are created lazily — a partnerProfiles row appears only when the partner first saves /profile (or an admin assigns a tier). So an org with sessions but no profile renders, with the identity panel in an explicit empty state. Only an org with neither a profile nor any sessions is notFound().
Null ARR. Pipeline ARR goes through formatArrUsdCompact, so an org whose deals all have unknown budgets reads as an em dash, never $0k — "no deal has a known budget" and "the pipeline is worth zero" are different facts. summarizeDeals (pure, unit-tested) returns pipelineArrUsd: null only when no deal carries a known ARR.
Every control calls an existing write path, or the one this change establishes. No action introduces a second writer.
| Control | Writes | Gate |
|---|---|---|
| Tier | partnerProfiles.partnerTier via setOrgTier — the only writer of that column | admin write |
| Directory approval | Reuses toggleDirectoryApproval via the existing DirectoryToggle | admin write |
| Account type | Reuses assignAccountType via the existing AccountTypeSelect, rendered only for super admins | super admin |
setOrgTier validates the slug against the partner_tiers definitions table (never a hardcoded union), supports clearing to no-tier, upserts the profile row when the org never saved one, audits every change as partner.tier_changed with a { from, to } diff, and revalidates /tier-benefits and /directory — both driven by the tier.
"What tier is this org?" used to have three answers and no owner:
| Location | Grain | Status |
|---|---|---|
partnerSessions.partnerTier | per user per org | what every read path used |
partnerAccounts.partnerTier | per SF account | CSV-seeded, then never read |
partnerTiers | definitions | legitimate — the vocabulary, not a rival answer |
Tier is semantically an org property, but it lived on the per-member row: an org with five members held five copies that nothing kept consistent. Two live defects followed:
sendBroadcast selected recipients by member tier, so divergent rows meant some members of an org received a tier-targeted broadcast and others silently did not.resolveOrgTierSlug took whichever member row it found first (.limit(1), no ordering) on a comment asserting all members share the org's tier — while that function hard-blocks admin MDF approvals past mdfQuarterlyLimitUsd.The public directory carried a third copy of the workaround: its own highest-wins dedupe across divergent member rows.
partnerProfiles.partnerTier — one row per org (clerkOrgId is unique). Read through one function:
resolveOrgTier(clerkOrgId: string): Promise<string | null> // lib/org-tier.ts
Null means "no profile row or no tier assigned"; normalizeTier reads that as registered, unchanged from the old contract. Five read paths migrated: MDF quota, broadcast audience, the session gate (and through it the sidebar badge, /tier-benefits, and content visibility), the public directory badge, and the admin partners listing.
Do not resurrect the old columns.
partnerSessions.partnerTierstill exists as the Salesforce-sync landing field andpartnerAccounts.partnerTieras CSV-seed provenance — but nothing reads them, verified by grep at migration time. Reading either again reintroduces the divergence this closed.
lib/org-tier.ts sits at lib/ root, not lib/admin/, because five of its callers are not admin surfaces — and deliberately not in lib/tiers.ts, which stays pure vocabulary with no DB import so any consumer (including client components) can import it safely.
pnpm --filter @matters/partners backfill:org-tier collapses the legacy member values with pickOrgTier: highest tier wins where member rows disagree, because silently downgrading an org's entitlements mid-migration is the worse failure. Divergent orgs are printed, not merely counted. The script never overwrites a tier already set, so it is safely re-runnable after an admin changes one through the hub.
Between db:push (which adds the column) and the backfill, every org reads as tier-less — i.e. registered — so the two commands should run back-to-back (PENDING_TASKS.md §13).
The six cards on /admin and the Pipeline-by-Status rows are links. Targets live in OVERVIEW_KPIS (lib/admin/kpi-targets.ts) — a tested map, because a card pointing at the wrong filter is a silent wrong-answer bug, not a crash:
| Card | Target |
|---|---|
| Partner Orgs | /admin/partners |
| Partner Members | /admin/accounts |
| Total Opportunities | /admin/opportunities |
| Active Deals | /admin/opportunities?status=opportunity_active |
| Closed Won | /admin/opportunities?status=closed_won |
| Pipeline ARR | /admin/opportunities |
KpiCard gained an optional href; when absent it renders exactly as before, so other call sites are untouched.
Every listing's identifying cell links onward — never the whole row, because these rows carry their own action buttons (GrantExtensionButton, AccountTypeSelect, DirectoryToggle, RetrySyncButton) that a row-level anchor would swallow:
| Listing | Links to |
|---|---|
/admin/partners | the org hub |
/admin/opportunities | org → hub · company → /opportunities/[id] (existed, was unreachable from admin) |
/admin/mdf, /admin/agreements, /admin/sync-issues | org → hub |
/admin/accounts | group header → hub (plain text for the unassigned group) |
?org= filter contract#/admin/activity already accepted ?org=. /admin/opportunities only had ?q= — which substring-matches company name, customer name, and clerkOrgId together, so filtering by org worked by accident and could match any row whose company name contained the id.
orgFilterPredicate (lib/admin/org-filter.ts) is the shared exact-match predicate now used by opportunities, mdf, and agreements. It is verified against generated SQL, including that it stays inside the surrounding and() — the operator-precedence bug class that previously bit the completeness filter. ?q= keeps its fuzzy behaviour for human search; ?org= is what links use.
?org= is accepted by opportunities, activity, mdf, agreements, sync-issues, and accounts. content is deliberately excluded: content documents are global rather than org-scoped — the same reason the notification registry has no content.published audience.
Each filtered listing shows OrgFilterBanner naming the org with a clear-filter link, so a filtered view never masquerades as the whole list. The param survives search and pagination because every generated URL goes through buildListingUrl, and ListingSearch re-emits the other active params as hidden fields.
Every admin table follows the same three rules. They are enforced by lib/admin/listing.ts (pure, unit-tested) plus four components in components/patterns/, so a new listing gets correct behaviour by composition rather than by remembering.
| Param | Meaning |
|---|---|
q | free-text search, over the columns that listing actually selects |
org | exact org id (see the ?org= contract below) |
page | 1-based page number |
parseListingParams(params, pageSize?) reads all three. Page size defaults to 30; /admin/activity and /admin/sync-issues pass 50, and /admin/accounts passes 100 because it groups by org and a 30-row page would routinely split one org's members across pages — which reads as data loss.
buildListingUrl(basePath, current, overrides) rebuilds the URL preserving every param present, not a fixed list. This matters most on /admin/activity, which carries five of its own (orgScope, actor, action, from, to): each page's old hand-rolled builder enumerated a fixed set, so any param it did not know about was dropped on the next pager click. An override of "" removes a param — that is how every clear-filter link is built.
where#Every listing runs its row query and a count() over the same where constant, in one Promise.all. A count over a different predicate is not a fix, it is a new wrong answer.
Coerce every SUM(). The Neon HTTP driver returns count() as a number but SUM() as a string, regardless of the sql<number> type annotation — the annotation is a compile-time assertion, not a runtime cast. Verified against the live database: count() → 7, COALESCE(SUM(...), 0) → "0". So total - rejected silently becomes string concatenation the moment the operator is + rather than -. Wrap every aggregate in Number() at the point of read, as /admin/accounts and /admin/mdf do.
This is also why several headers changed. They reported rows.length — the truncated row count — so paging them would have turned a silent truncation into an active lie:
| Page | Was | Now |
|---|---|---|
/admin/partners | {partners.length} organisations (wrong twice: truncated, and the rows are per-member) | real total, labelled members |
/admin/content | {docs.length} documents | real total |
/admin/accounts | active/rejected from two JS filters | one filtered aggregate |
/admin/mdf | pending count + requested/approved money reduced in JS | SQL aggregates |
/admin/access-requests | pending/actioned/total from JS filters | two counts, actioned as the difference |
/admin/sync-issues, /admin/agreements | rows.length | real total |
/admin/access-requests and /admin/mdf both split their rows into sections. Paginate the read and keep the JavaScript filter and "Pending" quietly comes to mean pending within this page — an admin on page 2 sees an empty queue while requests wait. Worse than the truncation being fixed.
So access-requests moved the split into the query (status chips: pending / actioned / all, defaulting to the queue) and renders one list. mdf keeps its three visual sections but orders rows review-first in SQL, so page 1 always holds the requests awaiting a decision.
Do not reintroduce a row-count-based pager. It carries all three:
?page=abc was unrecoverable. Pages parsed Math.max(1, parseInt(raw, 10)), and Math.max(1, NaN) is NaN. That poisoned .offset() and was stamped into every generated link as page=NaN, so the page could not self-heal.rows.length === PAGE_SIZE, so the final page — the one you reach by paging forward — had no Prev, stranding the user.(2) and (3) are both artefacts of inferring pagination state from the row count. pageRange(total, page, pageSize) derives from/to/hasPrev/hasNext from the real total instead, and ListingFooter renders from that.
Not every admin surface needs this, and adding controls where they cannot apply is noise:
/admin/tiers — reads partner_tiers whole, with no limit. One row per tier, ordered by sortOrder; the set is bounded by the tier model itself./admin/feature-access — a KEYS × ACCOUNT_TYPES configuration matrix, not a row set./admin/notifications — the failures panel is capped at FAILURE_LIMIT = 20 by design, and its heading says Recent failures, which is what it shows.Six admin pages each carried a hand-rolled partnerProfiles lookup query plus map, and had drifted on the missing-profile fallback (agreements truncated the id to 16 chars, sync-issues showed an em dash, mdf showed the full id). resolveOrgNames + orgLabel (lib/admin/org-names.ts) is the single definition with one fallback: the raw org id, which is always known and strictly more informative than a dash.
/admin/activity was the sixth copy, migrated after the helper existed. It keeps one page-specific branch: a row with no org is a system actor and renders as System, not as an unknown id.
The partners listing keeps its own query deliberately — it needs directoryApproved and partnerTier too, so it was never a pure name lookup.