Global search — design
Audit row 2.2; Figma header search pill 77:4078 in frame 123:7183: a white pill, radius 50, h34, w389, 8px inset, 20px search icon, 12px placeholder “Search for anything”, centred in the top bar between the breadcrumb (left) and the user chip (right).
Decisions
Section titled “Decisions”- One endpoint,
GET /search?q=&limit=, returning four groups:claims,spends,topups(petty-cash requests),advances. Each hit is{id, reference, text, amount, currency, supplier}—supplieris set only on a claim found by a line’s supplier rather than its reference or title, and the UI shows it as the subline —textis the claim title, spend description, top-up purpose or advance purpose, matched as stored (record language). Money is a string.qis trimmed, 2–100 characters (422 otherwise);limitis per group, 1–20, default 5. - A search never shows what the list would not. Each group is the union of the scopes its
own list endpoint would serve the caller, using that list’s own queries:
- claims:
expenses.claim.read, thenscope=allifread_all, elsemine∪to_act(ListClaims/ToActFiltered). Claims also match a line’ssupplier_name— an opt-in flag on the filter, so the claims list’s ownqis unchanged. - spends / top-ups:
pettycash.read, then withread_allorfloat.managethescope=allanswer (CanReadSpend/CanReadTopUpper row), else own ∪ theto_actqueue. - advances:
advances.advance.read, thenscope=allifread_all, elsemine∪ the unfilteredto_actqueue filtered in memory (the queue takes noq, and searching it in SQL could reach past the window the list shows). A group whose read permission is missing is empty, not an error. Each module exports its group from acontract.go(AGENTS.md: cross-module access iscontract.go); a newmodules/searchcomposes them.
- claims:
- Permission
search.use, so the route ishttpx.Protectedlike every other, held by everyone (controller ruling): 00268 granted it to Owners, 00269 to every other role; new organizations’ seeded roles get it from provisioning (EveryRolePermissions, on creation only, so an administrator’s removal stands), and the Employee role preset includes it. It widens no one’s sight: each group is still its own list’s visibility. - Match rule: reference by prefix, text (and claim supplier) anywhere, case-insensitive
ILIKE, LIKE metacharacters escaped. No trigram index in v1: every query is already bounded byorg_idand the groups are small. Addpg_trgmGIN indexes when a large tenant is slow. - People and pages are client-side. People come from the member directory the app already
loads (
/org/members, open to every member), shown only when the caller can open Team → Members, which is where a person result goes. Pages are the sidebar and user-menu links the caller’s permissions show. Neither needs the backend, and a member withoutsearch.usestill gets them. - UI. Typing debounces 250 ms; results appear in a listbox grouped Pages, Claims, Petty
cash, Advances, People. ↑/↓ move, Enter opens, Esc closes, ⌘K / Ctrl+K focuses. A claim opens
/claims/$id, a spend/petty-cash/spends/$id, a top-up/petty-cash/topups/$id, an advance/advances/$id. Below 768 px the pill is a search icon that opens a full-width sheet with the same box. Strings in en / ms / zh.