Appearance
Admin Global Search โ Design โ
Date: 2026-06-22 ยท Surfaces: the design-system prototype (design-system/Dispatch Design System - v2/templates/admin/) as the behavioural spec, and the production Angular app (apps/web/src/app/features/admin/) + Go API (apps/api/) as the shipping target.
1. Scope & intent โ
Add a global search to the admin back-office: one box that finds any record across the whole dataset and jumps straight to it โ not the per-table filter that exists today.
The current topbar #search box (index.html:70-73) is a per-view table filter: filtered() (admin.js:472-478) reads it and narrows only DB[current] โ the entity whose table is on screen โ and it clears itself on every nav change (goTo, admin.js:437). Global search replaces filter-this-table with find-anything-anywhere-and-jump-to-it.
2. Confirmed decisions โ
| # | Decision | Choice |
|---|---|---|
| 1 | Interaction model | Command palette / omnibox โ floating, grouped, keyboard-driven dropdown under the existing topbar box. |
| 2 | Search breadth | Everything indexable โ every collection in DB plus nested user-skills. |
| 3 | Node-scope rules | Respect viewer scope โ a node lead only surfaces their own node's people; a Principal sees all. Reuses the existing inScope gate. |
| 4 | Old per-table filter | Replaced by global search. Per-column filter rows already cover in-table narrowing; one box does one job. |
| 5 | Result cap | 5 per group with an overflow row ("+N more in Members โ") that deep-links to the full filtered table. |
| 6 | Prototype matcher | Substring + ranking, no library, no fuzzy/typo layer. ~30 lines of vanilla JS. |
| 7 | Production engine | Hybrid via one /search endpoint. Lexical (pg_trgm) in the Go API now; a semantic tier is added later, served by the shared AI document-understanding component over one pgvector index โ plugged into the same endpoint. |
The architectural through-line (decision 7, expanded) โ
"One service owns search" is satisfied at the API surface, not the deployment topology. As long as everything searches through a single /search?q= seam, what sits behind it can evolve freely:
- Now: lexical only.
pg_trgmtrigram search in the Go API. Zero new infrastructure. Covers the known-item / "find the record" case that is ~90% of admin search. - When the AI document component ships (happening regardless): it brings the embedding pipeline + vector index. That same
/searchendpoint becomes a fusion point โ Go keeps serving the fast lexical tier and calls the Python AI component for the semantic tier, merges, returns. The frontend never changes.
The dedicated search service is therefore born with the AI component, because that is when it first has a reason to exist. Building it before then is a microservice wrapping SQL.
Nothing is thrown away by starting in Go. The lexical tier is not replaced by the AI component โ it becomes tier 1 of the hybrid and stays useful forever; semantic adds, it does not supersede. The endpoint contract (/search?q= โ grouped results) is stable across both phases.
The one invariant to hold: all search goes through the single /search endpoint. No ad-hoc query logic scattered around the app. Keep the seam; evolve what's behind it.
3. Prototype design (design-system v2) โ
The prototype demonstrates tier 1 only (lexical + ranking). None of the AI/vector infrastructure exists here; the prototype's job is to specify the behaviour and UX, which the Angular port follows.
3.1 Declarative search registry โ SEARCH_SOURCES โ
The admin is driven by one SCHEMA object ("add a field = edit SCHEMA only"). Search follows the same ethos: one SEARCH_SOURCES array is the single source of truth for what's searchable. Each source declares one collection and how to surface it:
js
{
key: 'users',
group:'Members',
coll: () => DB.users,
match:(r,q) => `${r.name} ${r.email} ${r.function} ${r.role}`.toLowerCase().includes(q),
title: r => r.name,
sub: r => `${r.role} ยท ${r.function||'โ'}`,
scope: r => inScope(r), // node-scope gate โ reuses existing predicate
open: r => { goTo('users'); openProfile(r.id); } // existing landing rail
}Adding a future entity to search = push one object. No engine changes.
Collections to register (decision 2 โ everything in DB, admin.js:380-394):
| Source | Group | Match fields | Scope | open landing rail |
|---|---|---|---|---|
orgs | Organizations | name, contract, type, notes | all | goTo('orgs') + pre-filter to row |
mandates | Mandates | title, org, assignee, scope | all | openMandatePage(id) |
tasks | Tasks | title, mandate, assignee, status | all | goTo('tasks') + pre-filter |
votes | Proposals | title, ref, detail | all | goTo('votes') + pre-filter |
users | Members | name, email, function, role | inScope(r) | goTo('users'); openProfile(id) |
skills (catalog) | Skills | name, category | all | goTo('skills') + pre-filter |
| user-skills (nested) | Member skills | skill tag + parent member name | parent inScope | goTo('users'); openProfile(parentId) |
candidates | Recruitment | name, email, position, referredBy | scoped to referrer's node | goTo('dashboard') โ member-admin intake, scroll to row |
referrals | Referrals | referrer, referred, sponsorMP | scoped to referred's node | goTo('dashboard') โ member-admin, scroll to row |
nodes | Nodes | name, region, lead | all | goTo('dashboard') โ Nodes panel |
admins | Admins | name, email | all | goTo('admins') |
Out of scope: "Knowledge" docs live in the desktop member app, not the admin, so they are not an admin-search source. (The production semantic tier in ยง4 does index documents โ see there.)
3.2 Search engine โ runSearch(query) โ
Pure function, no DOM (so it ports cleanly and is trivially testable). For each source:
- Skip if
queryis empty. - Apply
source.scopefirst โ a node lead never sees out-of-node people (decision 3). - Run
source.match(r, q). - Rank each group's hits so the best lands first:
- exact
titlematch โ top title.startsWith(q)โ nexttitle.includes(q)โ next- secondary-field match only โ last
- exact
- Cap at 5 (decision 5); if more, append an overflow row carrying the source
keyso the click deep-links to the full filtered table.
Returns an ordered list of {group, rows[], overflow}.
3.3 Omnibox UI โ
Repurpose the existing topbar #search box. Today its oninput calls render() (index.html:72); swap to oninput="openOmni()", which renders a floating panel anchored under the box. New .adm-omni* styles in bespoke.css (tokens only โ no raw hex/px, per the adherence ruleset). Behaviour:
- Grouped results; each row
onclickruns that source'sopen. โK/Ctrl-Kfocuses the box from anywhere;Esccloses;โ/โmove a highlight;Enteropens the highlighted row.- Click-outside closes. Selecting a result closes the panel and clears the box.
- Empty query โ panel hidden.
3.4 Removing the old per-table filter (decision 4) โ
filtered()'s query branch (admin.js:473,476) is removed; tables render unfiltered-by-search (column filter rows on Tasks/Users remain). The #search box's oninput and the goTo clear-on-nav line are repointed at the omnibox. Cache-busters bumped (admin.js, ds-base.js, CSS) per the repo convention; current values tracked in STATUS.md.
4. Production architecture (Angular + Go, later Python) โ
Mirrors the prototype's UX; swaps the in-memory matcher for the hybrid endpoint.
4.1 Where it lives โ
| Concern | Path | Notes |
|---|---|---|
| Omnibox component | apps/web/src/app/shared/ (new global-search/) | Standalone, signals; mounted in the admin topbar. State = a signal<SearchResults>. |
| Search API client | @core/api (generated) | New /search path in the OpenAPI spec โ regenerate via Orval; do not hand-write the model. |
| Lexical search (phase 1) | apps/api/ (Go) | GET /search?q= โ pg_trgm similarity queries across the searchable tables, scope-filtered by the caller's role/node claim, grouped + ranked. |
| Scope gating | libs/auth/ claims | Node-lead requests filter people sources to the caller's node; Principal sees all โ same rule as the prototype's inScope. |
4.2 Phase 1 โ lexical (ship with the feature) โ
- Add
pg_trgmextension + GIN trigram indexes on the searched columns. GET /search?q=runs%similarity /ILIKEacross orgs, mandates, tasks, votes, users, skills, candidates, referrals, nodes; ranks bysimilarity(); caps per group; returns{group, items[], overflow}JSON matching the prototype's shape.- Scope filter applied server-side from the JWT (
membership_role,node).
4.3 Phase 2 โ semantic tier (with the AI document component) โ
- The AI document-understanding component (Python / FastAPI) owns the embedding pipeline (on write) and a
pgvectorindex covering documents and entity records. /searchbecomes hybrid: Go serves tier 1 (lexical, instant) and, for phrase/question-shaped queries (or on Enter), fans out to the Python component for tier 2 (semantic), then fuses (reciprocal-rank fusion or grouped) and returns. UI shows two groups: "Records" (fast/exact) and "Across documents" (semantic).- Embedding cost is paid on write, batched + cached; query time is an I/O-bound vector lookup โ scales well past this co-op's data volumes.
4.4 Escalate-when (YAGNI guardrail) โ
Stay on Postgres (pg_trgm + pgvector). Reach for a dedicated engine (Typesense / Meilisearch / Elastic) only if relevance or volume outgrows Postgres โ recorded here as an explicit trigger, not built now.
5. Build sequence โ
- Prototype โ
SEARCH_SOURCESregistry +runSearch()+ omnibox UI + keyboard; remove the old per-table filter; bump cache-busters; updateSTATUS.md. (This is the behavioural spec.) - Angular โ
global-searchshared component wired to a stubbed/typed result shape, mounted in the admin topbar; same keyboard + grouping behaviour. - Go phase 1 โ OpenAPI
/searchpath โ Orval regen โpg_trgmimplementation with server-side scope gating. Replace the Angular stub with the generated client. - Phase 2 (deferred, with the AI component) โ hybrid fusion into the same endpoint. No frontend change.
6. Out of scope โ
- Knowledge-doc search in the admin (lives in the desktop member app).
- Fuzzy/typo tolerance in the prototype (production gets it free via
pg_trgmsimilarity / semantic tier). - A standalone search microservice before the AI component exists.
- Member-app (desktop/mobile) global search โ admin only.