# The Hub (v0)

**Status:** in production, deployed
**Lives at:** `/hub` + `/hub/{projects,organizations,people,tenants,accounts}`
(SPA), `GET /api/hub/*`; `app/services/hub.py`, `app/routers/hub.py`
(registered in `app/main.py`), the tenant-exempt prefix in
`app/routers/deps.py`; `frontend/src/features/hub/HubPage.tsx` + `Hub.css`,
the `HUB_GROUP` nav in `frontend/src/features/workspace/profiles.ts`, the
capability fold-in in `frontend/src/components/AppShell.tsx`.
**Spec:** new (the seed of the future hub surface). Cross-tenant read
boundary decided in ADR #026.

## Summary

A super-admin-only, cross-tenant, **read-only** data browser: five
spreadsheet-style tables (Projects, Organizations, People, Tenants,
Accounts) that show EVERY tenant's rows at a glance — every column each
underlying model actually carries, not just derived counts — so rian can
audit the whole data landscape in one place (this is literally how the
PlusROI sheet-import bugs were confirmed: the duplicated-project and
missing-contacts findings became visible as row/column counts here before
any fix was written). No writes, no schema change, no new domain (viewable
at `with.bowden.works/hub` for now).

## Why

2026-07-17 — rian wants a "what do we have?" view across all the books at
once. Every other surface in the app is tenant-scoped by design (a partner
never sees another tenant's row); the hub is the deliberate exception —
the owner's bird's-eye view of the shared entity graph. This v0 is the
seed the eventual richer hub grows from.

## Behavior

Five list `GET` endpoints, each returning `{rows: [...]}`, all cross-tenant
(they intentionally do NOT call `authz.scope()` / apply the tenant
filter), all money-free (structure, not amounts — so no `can_see_income`
concerns). Each surfaces **every column the underlying model(s) carry**,
not just derived counts — the whole point is a spreadsheet-style audit
view, so a field missing here is a field rian can't see at all:

- **`/api/hub/projects`** — every `projects` row + resolved names: `id`,
  `name`, `operator` (operator party name), `client` (client party name),
  `tenant` (owning tenant's org name), `status`, `notes`, `created_at`,
  `updated_at`, `worker_count` (distinct `worker_party_id` on its time
  entries), `invoice_count` (distinct invoices reached via its entries'
  `invoice_line_id` lock). Ordered by tenant, then name. Deliberately
  excludes `income`/`billout_adjustment_*` — those are money, and Hub v0
  stays money-free by design; revisit if that line should move.
- **`/api/hub/organizations`** — every `parties.kind='org'` column
  (`id`, `name`, `slug`, `email`, `phone`, `address`, `notes`,
  `is_active`, `created_at`, `updated_at`) **LEFT JOINed** with its 1:1
  `organizations` detail row (`legal_name`, `currency`,
  `drive_folder_url`, `mosiah_folder`) — LEFT, not INNER, so an org party
  missing its detail row (a 1:1-invariant violation) shows up as nulls
  here rather than silently vanishing; that's itself a useful audit
  signal. Plus `is_tenant` (backs a tenant), `project_count` (as client),
  `member_count` (distinct people with an active affiliation). Ordered by
  name.
- **`/api/hub/people`** — every `parties.kind='person'` column (`id`,
  `name`, `slug`, `email`, `phone`, `address`, `notes`, `is_active`,
  `created_at`, `updated_at`; the `people` detail table itself has no
  fields of its own yet). Plus `has_account` (a `users` row links here),
  `affiliation_count` (active affiliations), `is_worker` (holds an active
  engagement as `party_b`). Ordered by name.
- **`/api/hub/tenants`** — every `tenants` row + its backing party's
  identity fields: `id`, `name` (org), `slug`, `email`, `phone`,
  `address`, `notes`, `is_active`, `created_at`, `updated_at`,
  `books_owner` (effective, derived from `settings` —
  `app`/`sheet`/`null`), `settings` (the raw JSON blob — may carry other
  keys, e.g. legacy `parallel_run`), `member_count` (distinct people with
  an active engagement in it), `project_count`. Ordered by name.
- **`/api/hub/accounts`** — every `users` row: `id`, `idauth_username`,
  `email`, `person_id` (linked person party's id, or null), `person_name`
  (linked person party's name, or null), `is_super_admin`, `preferences`
  (raw JSON), `created_at`, `tenant_memberships` (slugs of tenants the
  person has active engagements in). Ordered by username.

Plus one **drill-down** endpoint, added the same session once rian ran
into the exact ambiguity it exists to resolve (a shared org's per-tenant
commercial rows don't fit a one-row-per-org table):

- **`/api/hub/organizations/{party_id}/detail`** — everything
  tenant-scoped hung off ONE org party. `client_profiles`: one row per
  tenant it's a client of (`tenant`, `tenant_slug`, `currency`, `terms`,
  `invoice_timing`, `default_commission_pct`, `commission_party`,
  `credit_limit`, `contacts`, `archived`, `notes`, `created_at`,
  `updated_at`) — an org that's a client of N tenants correctly returns N
  rows here, each with that tenant's own terms; that multiplicity is the
  signal, not a bug (Copernic-style cross-tenant sharing shows up as 2
  rows, not a blank column). `tenant_vendors`: one row per tenant it's a
  listed vendor of (`tenant`, `tenant_slug`, `default_category`,
  `default_paid_by`). Ordered by tenant name in both lists.

Each SQL statement is a single grouped query (subquery counts, no N+1).

The SPA renders each list as a neutral, **spreadsheet-style** `DataTable`:
column headers show the raw API field id (e.g. `project_count`, not
"Projects (as client)") since this is a technical data-review surface, not
a designed one — and every column is sortable and filterable (a per-column
free-text substring box, case-insensitive, `filterValue` falling back to
`sortValue`; booleans filter on `yes`/`no` rather than `1`/`0`). The
per-column filter is a new opt-in `filterable` prop + `Column.filterValue`
on the shared `DataTable`/`useTableFilter` (off by default — every other
consumer, `/entries` `/projects` CC batches, is unaffected). A `/hub` index
links the five. Read-only — no row actions, no forms. The pages force the
**default/neutral theme** while mounted (`useNeutralTheme`) so the hub is
tenant-agnostic (never the PlusROI skin), restoring the tenant-mode theme
on unmount.

**Organizations rows are expandable** (▶, the same `DataTable`
`renderExpanded`/`expandedKeys`/`onToggleExpand` mechanism the live
`/projects` page already uses for its description breakdown): expanding a
row lazy-fetches `/api/hub/organizations/{id}/detail` and renders its
`client_profiles`/`tenant_vendors` rows as small sub-tables
(`OrganizationDetailBreakdown` in `HubPage.tsx`). This was chosen over a
"select a tenant, columns repopulate" lens explicitly BECAUSE it makes
multi-tenant spread visible rather than hiding it behind an unselected
tenant — the exact failure mode being audited for (Second Wind-style
duplication). A per-tenant viewing lens might still be worth adding later
as a complementary mode once the data is trusted; this is the
audit-first mechanism.

## Constraints & edge cases (the security boundary)

**This is the one thing that cannot be wrong.** Because the endpoints are
cross-tenant, they are gated to **super admins only**:

- `services/hub.require_super_admin(actor)` runs at the TOP of every
  service function, BEFORE any query. It requires
  `actor.is_super_admin && not actor.is_viewing_as` — the exact idiom
  `scope()`/`policy()`/`comments()` use. A non-super-admin
  (rob/danielle/adi) gets **403 FORBIDDEN and zero rows**. A super admin
  currently **viewing-as anyone** (even another super admin) is ALSO
  refused — impersonation must never open a cross-tenant surface (under
  view-as `actor.is_super_admin` is the target's flag and `is_viewing_as`
  is true, so both cases are closed).
- The router is a thin HTTP layer: it only maps `ServiceError` → the
  structured `ApiError` triple. The gate lives in the service.
- `/api/hub` is added to `deps._TENANT_EXEMPT_PREFIXES` so a cross-tenant
  read never trips the `NO_TENANT` gate (hub data is not tenant-scoped,
  and a super admin with zero tenants must still read it). The consequence
  is that a non-super-admin always fails with `FORBIDDEN`, never
  `NO_TENANT` — one clean refusal.
- The nav "Hub" group is hidden from non-super-admins: it's appended to
  BOTH workspace profiles with every item gated on the synthetic
  `is_super_admin` capability, and `AppShell` folds `me.is_super_admin`
  into the capability map `resolveNav` reads. No `if (isSuperAdmin)` lives
  in any component — the SAME declarative mechanism `can_see_income` uses.
  The page-level guards (a "super admins only" notice) are
  belt-and-suspenders for a deep-link; the server 403 is the real control.

## Permissions

Super-admin only, cross-tenant. This is the deliberate exception to the
tenant-isolation model ([tenancy.md](tenancy.md)): every other list is
`scope()`-narrowed to the current tenant; the hub is not scoped at all,
which is exactly why it is owner-only. No capability grid entry — super
admin drives it directly (`actor.is_super_admin && !is_viewing_as`).

## Open considerations

- v0 is read-only; the future hub grows more drill-downs, links into the
  tenant surfaces, and richer facets from here.
- `invoice_count` on a project is defined as distinct invoices reached
  through the project's time entries' `invoice_line_id` lock — the
  structural entries→invoice link. Milestone-generated invoices are not
  yet counted (no time entries); revisit when the hub gains a per-project
  drill-down.
- `member_count`/`tenant_memberships` count any person with an active
  engagement (matching the tenancy membership shape) — not
  role-filtered; a role facet is a later slice.
- **`client_profiles`/`tenant_vendors` are surfaced via the Organizations
  expand-a-row drill-down** (`/api/hub/organizations/{id}/detail`), not as
  columns on the Organizations table itself — right call, since it's a
  many-per-org relation (PK `tenant_id, client_party_id` — the same org
  legitimately has different terms per tenant) that doesn't fit a
  one-row-per-org shape without either fanning out rows or denormalizing
  into per-tenant columns. The same drill-down mechanism is the template
  for extending expand-a-row to Projects (`project_billing` +
  `project_milestones`) and People (`party_affiliations` + `engagements`)
  if/when those get audited next — not yet built, deliberately deferred
  until there's a concrete case driving it (same discipline as this one).
- A separate, complementary idea surfaced but NOT built: a generic
  "browse any table" view (pick any of the ~20 tables not in the hub at
  all — `engagements`, `compensation_terms`, `invoices`, `expenses`,
  `time_entries`, the CC tables, `comments`, `saved_views`, `audit_log`…
  — see every raw row, sort/filter, independent of any specific org).
  Useful for sanity-checking a whole table on its own rather than
  drilling in from one entity. Revisit if the expand-a-row drill-downs
  don't cover enough ground.
- Money fields on `projects` (`income`, `billout_adjustment_pct`,
  `billout_adjustment_amount`) are deliberately still excluded — say the
  word if the money-free line should move for this owner-only surface.

## Tests

- `tests/hub/test_hub_service.py` — shapes + counts on a two-tenant ORM
  fixture (cross-tenant projects, order, worker/invoice counts, is_tenant,
  has_account/is_worker/affiliations, books_owner, memberships), the new
  field coverage (party + org-detail fields on Copernic; Acme deliberately
  has NO `organizations` detail row, proving the LEFT JOIN survives a
  1:1-invariant violation as nulls rather than crashing or dropping the
  row; the raw `settings` JSON carries every key, not just the derived
  `books_owner`; money fields absent from `projects` rows), AND the gate:
  every service function raises 403 `FORBIDDEN` for a non-super actor and
  for a viewing-as-super actor (parametrized over all five).
- `tests/hub/test_hub_routes.py` — the end-to-end proof through the real
  app: super admin gets rows on every endpoint; **every endpoint returns
  403 `FORBIDDEN` with no rows for a non-super-admin** (the critical
  test); a super admin viewing-as a non-super-admin is also 403; a super
  admin's projects span both tenants.
- `frontend/src/features/workspace/profiles.test.ts` — the Hub nav group
  is hidden from a non-super-admin and shown (with all six items) to a
  super admin, in both workspace modes.
- `frontend/src/components/DataTable.test.tsx` — the new `filterable`
  opt-in: no filter inputs render unless passed; a per-column substring
  filter narrows rows case-insensitively; filters across columns combine
  as AND, with a distinct "no rows match the current filters" empty state.
- `tests/hub/test_hub_service.py` / `test_hub_routes.py` —
  `organization_detail`: Copernic (a client of BOTH tenants in the
  fixture) returns TWO `client_profiles` rows with each tenant's own
  terms/currency/commission — the multi-tenant signal, proven directly;
  an org with no tenant-scoped rows at all comes back
  `{client_profiles: [], tenant_vendors: []}`, not an error; the
  super-admin gate (403 for non-super and for viewing-as) proven both at
  the service layer and through the real HTTP route.

## Changelog

- 2026-07-17 — Feature created. The Hub v0: five super-admin-only,
  cross-tenant, read-only endpoints (`app/services/hub.py` +
  `app/routers/hub.py`, tenant-exempt), the `/hub` SPA area (neutral
  DataTables + index), and the super-admin-gated nav group. No schema
  change. ADR #026.
- 2026-07-17 — **Full field expansion + per-column filtering** (rian's
  first hands-on review pass, starting with Organizations then applying
  the same treatment to all five). Every endpoint now returns every
  column its underlying model(s) carry (see Behavior above) instead of
  just the original derived counts; column headers show the raw field id
  instead of a designed label (a technical-review surface, not a
  polished one); every column is sortable AND filterable via a new opt-in
  `filterable` prop + `Column.filterValue` on the shared `DataTable`
  (`frontend/src/components/DataTable.tsx`) — off by default, so
  `/entries`/`/projects`/CC batches are unaffected. No schema change; no
  new ADR (no lock/permission/precedence rule changed). Money fields
  stay deliberately excluded from `projects` (flagged for rian to weigh
  in on later, not silently added).
- 2026-07-17 — **Organizations expand-a-row drill-down** (v0.21.0). Rian
  hit the exact confusion the design anticipated — discovering
  `client_profiles` lives on a separate table felt like a gap in his
  understanding of the data, and he asked for a way to see it in context
  without losing track of multiplicity across tenants. New
  `GET /api/hub/organizations/{party_id}/detail` (`hub.organization_detail`,
  same super-admin gate) returns that org's `client_profiles` (one row per
  tenant it's a client of, WITH that tenant's own terms — an org spread
  across N tenants correctly returns N rows, which is the audit signal,
  not a bug) and `tenant_vendors`. Wired via the same expand-row mechanism
  the live `/projects` page already uses (`DataTable`'s
  `renderExpanded`/`expandedKeys`/`onToggleExpand`, lazy-fetched on
  expand) — chosen explicitly over a "select a tenant, columns
  repopulate" lens because that would hide the very cross-tenant spread
  being audited for. No schema change; no new ADR.
