# Global Search Completeness

**Spec:** 37
**Date:** 2026-05-31
**Status:** Draft
**Depends on:** `app-shell`, `foundation-auth-rbac`, `module-management`, `foundation-design-system`
**Referenced by:** `app-shell` (cmdk palette), `tasks-board-engine`, `projects-module`, `customers-module`, `invoices-core`, `expenses-module`, `kb-module`, `crm-support-center`

---

## Overview

This spec fully defines the search system for Zync.is: the `⌘K` / `Ctrl+K` command palette, the full-page search results view at `/search`, and the two API endpoints that back them. It extends the partial search described in the app-shell spec (which covered only tasks, projects, customers) to all fourteen searchable entity types listed in §1.

Search is implemented with Postgres full-text search (`tsvector` / `tsquery`). No external search service is required. All results are module-gated server-side and filtered by the requesting user's permissions.

RTL language: Hebrew. Placeholder: "חפש..." All result labels and UI text render right-to-left.

---

## 1. Searchable Entities

| Entity | `module_id` gate | Fields searched | Result label |
|--------|-----------------|----------------|--------------|
| Tasks | `tasks` | title, description | Task: {title} |
| Projects | `projects` | name, description | Project: {name} |
| Customers | `customers` | name, email, phone | Customer: {name} |
| Invoices | `invoices` | invoice_number, customer name | Invoice #{number} |
| Expenses | `expenses` | vendor_name, description, amount, date | Expense: {description} |
| Vendors | `vendors` | name, email, phone, tax_id | Vendor: {name} |
| Receipts | `invoices` | receipt_number, customer name | Receipt #{number} |
| Leads | `marketing` | name, company, email | Lead: {name} |
| Proposals | `proposals` | name, customer name (JOIN) | Proposal: {name} |
| Contracts | `contracts` | title, customer name (JOIN) | Contract: {title} |
| Contractors | `contractors` | name, tax_id, email | Contractor: {name} |
| KB Articles | `kb` | title, content excerpt | Article: {title} |
| Support Tickets | `crm` | subject | Ticket: {subject} |
| Team Members | `system` (always on) | name, email | Member: {name} |

**System module** (`system`) is never stored in `tenant_modules`; it is treated as always-enabled by application logic.

---

## 2. cmdk Palette Behavior

### 2.1 Trigger

| Input | Action |
|-------|--------|
| `⌘K` / `Ctrl+K` | Open palette |
| `Escape` | Close palette |
| `↑` / `↓` | Navigate results |
| `Enter` | Navigate to selected result |
| `Tab` | Move between groups |

Palette opens as a modal overlay with `role="dialog"`, `aria-modal="true"`. Focus is trapped inside while open.

### 2.2 Empty-query state (palette just opened)

When the palette opens with no query:

1. **Recent searches** — last 5 queries stored in `localStorage` key `zync:recent_searches` (array of strings, most-recent first). Each item shown as a row with a clock icon. Clicking an item populates the input and fires the search.
2. **Quick actions** — module-gated shortcuts shown below recent searches:

| Action | Module gate | Route |
|--------|------------|-------|
| New Task | `tasks` | `/tasks/new` |
| New Invoice | `invoices` | `/invoices/new` |
| New Customer | `customers` | `/customers/new` |

Quick actions always appear if the module is enabled, regardless of query. They use the `+` icon and the `--accent` color for the icon.

If no recent searches exist, the recent searches section is omitted entirely — no empty-state message.

### 2.3 Querying state

- Input is debounced 200 ms before firing `GET /api/search?q={query}&limit=5`.
- Minimum query length: 2 characters. Below 2 characters, show empty-query state.
- While loading: show a skeleton row per previously-known group (or a single centered spinner on first load).
- Quick actions remain visible above the results when a query is active.

### 2.4 Result display

Results are grouped by entity type. Each group has a header label in `--ink-faint` color, 12px, uppercase. Within each group, up to **5 results** are shown.

A maximum of **3 groups** are shown before a "Show more" row. If more than 3 groups have results, the groups shown are determined by total result count (highest count first). Ties broken by the entity priority order: Tasks > Projects > Customers > Invoices > Receipts > Expenses > Vendors > Leads > Proposals > Contracts > Contractors > KB Articles > Support Tickets > Team Members.

```
┌─────────────────────────────────────────────────────────┐
│  חפש...                                            ⌘K   │
├─────────────────────────────────────────────────────────┤
│  + משימה חדשה             + חשבונית חדשה   + לקוח חדש  │
├─────────────────────────────────────────────────────────┤
│  TASKS                                                   │
│  ▸  Design homepage mockup          Project Alpha   ○   │
│  ▸  Fix login bug                   Internal        ●   │
│  ▸  Write API docs                  Project Beta    ○   │
├─────────────────────────────────────────────────────────┤
│  PROJECTS                                                │
│  ▸  Alpha Rebrand                   Acme Corp           │
│  ▸  Alpha Mobile App                Acme Corp           │
├─────────────────────────────────────────────────────────┤
│  CUSTOMERS                                               │
│  ▸  Acme Corporation                acme@example.com    │
├─────────────────────────────────────────────────────────┤
│  Show all results for "alpha" →                         │
└─────────────────────────────────────────────────────────┘
```

"Show more" row is always the last item in the list when there are results. Activating it (click or `Enter` when focused) navigates to `/search?q={query}`. If the total result count across all groups is 0, show "Nothing matched that." (no icon, no CTA) in place of all groups.

### 2.5 Recent searches update

On every successful search (query ≥ 2 chars, API returned), prepend the query to `zync:recent_searches` in localStorage. Deduplicate (remove earlier occurrence of same string). Cap at 5 entries. Do not store on navigation to "Show more" — only store when the user types a query themselves.

### 2.6 Result row anatomy

```
┌──────────────────────────────────────────────────────────┐
│  [icon]  Primary label           Secondary label   [badge]│
└──────────────────────────────────────────────────────────┘
```

- Icon: entity-type icon, 16×16, `--ink-faint`.
- Primary label: entity title. Font: 14px regular, `--ink`.
- Secondary label: contextual info (see per-entity table in §2.7). Font: 12px, `--ink-faint`. Truncated at 200px.
- Badge: optional status badge (tasks, invoices, tickets). 12px pill.
- Row height: 40px. Hover + focus background: `--surface`. Selected row background: `oklch(91% 0.012 58)` (one step darker than surface).

### 2.7 Per-entity secondary labels

| Entity | Secondary label | Badge |
|--------|----------------|-------|
| Task | Project name | Status (To Do / In Progress / Done) |
| Project | Customer name | — |
| Customer | Email address | — |
| Invoice | Amount formatted (₪N,NNN) | Status (Draft / Sent / Paid / Overdue) |
| Expense | Vendor name | — |
| Vendor | Email / phone | YTD spend (₪N) |
| Receipt | Customer name | Type (קבלה / חשבונית מס/קבלה) |
| Lead | Company name | Stage (New / Contacted / Qualified / Proposal) |
| Proposal | Customer name | Status (Draft / Sent / Accepted / Rejected) |
| Contract | Customer name | Status (Draft / Sent / Signed / Voided) |
| Contractor | Company / email | Active projects count |
| KB Article | Section / category name | — |
| Support Ticket | Customer name | Status (Open / In Progress / Closed) |
| Team Member | Email address | Role chip (OWNER / ADMIN / MEMBER / VIEWER / CONTRACTOR) |

### 2.8 Per-entity result destination (`url`)

The `url` field on each `SearchResultItem` (§4.1) resolves as follows:

| Entity | Destination `url` |
|--------|-------------------|
| Task | `/tasks/{id}` |
| Project | `/projects/{id}` |
| Customer | `/customers/{id}` |
| Invoice | `/invoices/{id}` |
| Receipt | `/receipts/{id}` |
| Expense | `/expenses/{id}` |
| Vendor | `/vendors/{id}` |
| Lead | `/crm/leads/{id}` |
| Proposal | `/proposals/{id}` |
| Contract | `/contracts/{id}` |
| Contractor | `/contractors/{id}` |
| KB Article | `/kb/{id}` |
| Support Ticket | `/support/{id}` |
| Team Member | `/settings/users?highlight={id}` |

Team Members have no standalone detail route; the result links to the users-settings list with a `?highlight={id}` anchor so the target member's row is scrolled into view and briefly highlighted. The `/settings/users` page (spec 25, `settings-module`) must honor the `?highlight=` query param.

---

## 3. Full Search Results Page (`/search`)

### 3.1 Route & access

Route: `/search?q={query}` (optionally `&type={entityType}` to pre-select a filter tab).

Accessible to all authenticated users. Module-disabled entity tabs are hidden. No tab is shown for Team Members (always-on, but not shown as a separate tab — included in "All" only).

### 3.2 Page layout (ASCII wireframe)

```
┌──────────────────────────────────────────────────────────────────────────┐
│  Header (app shell)                                                       │
├──────────────────────────────────────────────────────────────────────────┤
│                                                                           │
│  חפש: [  alpha                                              ] [x]         │
│                                                                           │
│  [All] [Tasks] [Projects] [Customers] [Invoices] [Receipts] [Expenses]   │
│        [Vendors] [Leads] [Proposals] [Contracts] [Contractors]           │
│        [Articles] [Tickets]                                               │
│                                                                           │
│ ─────────────────────────────────────────────────────────────────────    │
│                                                                           │
│  TASKS  (12)                                                              │
│  ┌─────────────────────────────────────────────────────────────────┐     │
│  │  [icon]  Design homepage mockup           Project Alpha   ○      │     │
│  │  [icon]  Fix login bug                    Internal        ●      │     │
│  │  ...                                                             │     │
│  │  [icon]  (result 20 of 12 — all shown)                          │     │
│  └─────────────────────────────────────────────────────────────────┘     │
│                                                                           │
│  PROJECTS  (4)                                                            │
│  ┌─────────────────────────────────────────────────────────────────┐     │
│  │  [icon]  Alpha Rebrand                    Acme Corp             │     │
│  │  ...                                                             │     │
│  └─────────────────────────────────────────────────────────────────┘     │
│                                                                           │
│  (more groups below)                                                      │
│                                                                           │
└──────────────────────────────────────────────────────────────────────────┘
```

### 3.3 Layout details

- Page max-width: 800px, centered.
- Search input at top: full-width, 48px tall, `--surface` background, `--radius` 4px border, border `var(--line)`. Placeholder: "חפש...". Clearing the input shows all recent searches (reuses recent-search logic). Input is always focused on page load.
- Type filter tabs: horizontal scrollable row, 32px tall pills. Active tab: `--accent` background, white text. Inactive: `--surface` border, `--ink` text.
- "All" tab is always first. Module-disabled entity tabs are hidden. Team Members is never shown as its own tab — results appear only under "All".
- Each group renders as a card (`--surface` background, 1px border `var(--line)`, `--radius`). Group header: uppercase label + count in parens, 12px `--ink-faint`, 16px padding.
- Up to **20 results** per group in "All" view. When a specific type tab is active, all results for that type are shown with cursor-based pagination (load-more button at the bottom of the list, not infinite scroll).
- Pagination load-more button: secondary variant, label "טען עוד" (Load more), 32px tall, centered.
- Empty state (no results for any entity): centered "Nothing matched that." in `--ink-faint`, 16px, no icon, no CTA.
- Empty state (a specific type tab active, no results): same "Nothing matched that." within the tab content area.
- Page title: `חיפוש: "{query}" — Zync`.

### 3.4 URL state

The query (`q=`) and active tab (`type=`) are stored in the URL query string. Navigating back/forward via browser history restores the state. Changing the input updates `q=` with `replaceState`.

---

## 4. API

### 4.1 Palette search endpoint

```
GET /api/search?q={query}&limit={n}
```

- `q`: required. URL-encoded search string. 2–200 characters.
- `limit`: optional. Default 5. Max 10. Applied per entity group.
- Auth: required (Bearer JWT). Tenant extracted from JWT.
- Returns grouped results, only for enabled modules and permitted entities.

**Response type:**

```typescript
type EntityType =
  | 'task'
  | 'project'
  | 'customer'
  | 'invoice'
  | 'receipt'
  | 'expense'
  | 'vendor'
  | 'lead'
  | 'proposal'
  | 'contract'
  | 'contractor'
  | 'kb_article'
  | 'support_ticket'
  | 'team_member';

interface SearchResultItem {
  id: string;                    // entity UUID
  type: EntityType;
  label: string;                 // formatted: "Task: {title}", "Invoice #INV-001", etc.
  primary: string;               // e.g. task title, customer name
  secondary: string | null;      // project name, email, vendor, etc.
  badge: string | null;          // status string if applicable
  url: string;                   // in-app navigation path e.g. "/tasks/uuid"
  highlight: string | null;      // snippet with matched term wrapped in <mark>…</mark>
}

interface SearchResultGroup {
  type: EntityType;
  label: string;                 // human display name: "Tasks", "Customers", etc.
  items: SearchResultItem[];
  total: number;                 // total matches (may exceed items.length)
}

interface SearchResponse {
  query: string;
  groups: SearchResultGroup[];   // max 14 entity types; ordered by result count desc
  total_groups: number;          // total groups with results (may exceed groups.length)
}
```

**Error responses:**

| Status | `error` code | Condition |
|--------|-------------|-----------|
| 400 | `query_too_short` | `q` < 2 chars |
| 400 | `query_too_long` | `q` > 200 chars |
| 401 | `unauthenticated` | Missing or invalid JWT |
| 429 | `rate_limited` | >60 requests/minute per user |

### 4.2 Full search results endpoint

```
GET /api/search/full?q={query}&type={entityType}&cursor={cursor}&limit={n}
```

- `q`: required. 2–200 characters.
- `type`: optional. One of the `EntityType` values. If omitted, returns all entity types.
- `cursor`: optional. Opaque pagination cursor (base64-encoded `{entity_type}:{offset}` string). Absent on first page.
- `limit`: optional. Default 20. Max 50. Applied per entity group (when `type` is omitted) or to the single entity (when `type` is set).
- Auth: required.

**Response type:**

```typescript
interface FullSearchResponse {
  query: string;
  type: EntityType | null;        // echoes the requested type filter
  groups: SearchResultGroup[];    // each group has items[] up to `limit`
  next_cursor: string | null;     // null when no more results
  has_more: boolean;
}
```

When `type` is set, `groups` contains exactly one group (or zero if no results). When `type` is omitted, `groups` contains all entity types that have results, each with up to `limit` items. Cursor-based pagination applies per-group only when `type` is set; in "All" mode pagination is not supported (all groups return their first `limit` results and the client uses per-type tabs to paginate).

---

## 5. Postgres Schema — Search Vectors

Each searchable entity table gets a generated `search_vector` column (type `tsvector`) and a GIN index. Language config: `'hebrew'` where Hebrew text is primary; `'simple'` as fallback for fields that are always Latin (email, invoice_number, phone).

### 5.1 `tasks` table

```sql
ALTER TABLE tasks
  ADD COLUMN search_vector tsvector
    GENERATED ALWAYS AS (
      setweight(to_tsvector('hebrew', coalesce(title, '')), 'A') ||
      setweight(to_tsvector('hebrew', coalesce(description, '')), 'B')
    ) STORED;

CREATE INDEX idx_tasks_search ON tasks USING GIN (search_vector);
```

### 5.2 `projects` table

```sql
ALTER TABLE projects
  ADD COLUMN search_vector tsvector
    GENERATED ALWAYS AS (
      setweight(to_tsvector('hebrew', coalesce(name, '')), 'A') ||
      setweight(to_tsvector('hebrew', coalesce(description, '')), 'B')
    ) STORED;

CREATE INDEX idx_projects_search ON projects USING GIN (search_vector);
```

### 5.3 `customers` table

```sql
ALTER TABLE customers
  ADD COLUMN search_vector tsvector
    GENERATED ALWAYS AS (
      setweight(to_tsvector('hebrew', coalesce(name, '')), 'A') ||
      setweight(to_tsvector('simple', coalesce(email, '')), 'B') ||
      setweight(to_tsvector('simple', coalesce(phone, '')), 'C')
    ) STORED;

CREATE INDEX idx_customers_search ON customers USING GIN (search_vector);
```

### 5.4 `invoices` table

Customer name is joined at query time (not stored in invoices), so the vector covers invoice_number only; customer name match is handled via a JOIN.

```sql
ALTER TABLE invoices
  ADD COLUMN search_vector tsvector
    GENERATED ALWAYS AS (
      setweight(to_tsvector('simple', coalesce(invoice_number, '')), 'A')
    ) STORED;

CREATE INDEX idx_invoices_search ON invoices USING GIN (search_vector);
```

Search query also runs: `to_tsvector('hebrew', c.name) @@ query` via JOIN to `customers c` on `invoices.customer_id = c.id`.

### 5.5 `expenses` table

```sql
ALTER TABLE expenses
  ADD COLUMN search_vector tsvector
    GENERATED ALWAYS AS (
      setweight(to_tsvector('hebrew', coalesce(description, '')), 'A') ||
      setweight(to_tsvector('hebrew', coalesce(vendor, '')), 'B')
    ) STORED;

CREATE INDEX idx_expenses_search ON expenses USING GIN (search_vector);
```

### 5.6 `kb_articles` table

```sql
ALTER TABLE kb_articles
  ADD COLUMN search_vector tsvector
    GENERATED ALWAYS AS (
      setweight(to_tsvector('hebrew', coalesce(title, '')), 'A') ||
      setweight(to_tsvector('hebrew', coalesce(content_text, '')), 'B')
    ) STORED;

CREATE INDEX idx_kb_articles_search ON kb_articles USING GIN (search_vector);
```

`content_text` is a plain-text version of the article body (stripped of Markdown/HTML). It must be populated when articles are saved.

### 5.7 `support_tickets` table

```sql
ALTER TABLE support_tickets
  ADD COLUMN search_vector tsvector
    GENERATED ALWAYS AS (
      setweight(to_tsvector('hebrew', coalesce(subject, '')), 'A')
    ) STORED;

CREATE INDEX idx_support_tickets_search ON support_tickets USING GIN (search_vector);
```

### 5.8 `users` table (team members)

Team members are users who belong to the tenant. Search over `users` joined via `tenant_memberships`.

```sql
ALTER TABLE users
  ADD COLUMN search_vector tsvector
    GENERATED ALWAYS AS (
      setweight(to_tsvector('hebrew', coalesce(full_name, '')), 'A') ||
      setweight(to_tsvector('simple', coalesce(email, '')), 'B')
    ) STORED;

CREATE INDEX idx_users_search ON users USING GIN (search_vector);
```

Query joins `users u` to `tenant_memberships tm ON tm.user_id = u.id AND tm.tenant_id = $tenant_id`.

### 5.9 `vendors` table

```sql
ALTER TABLE vendors
  ADD COLUMN search_vector tsvector
    GENERATED ALWAYS AS (
      setweight(to_tsvector('hebrew', coalesce(name, '')), 'A') ||
      setweight(to_tsvector('simple', coalesce(tax_id, '')), 'B') ||
      setweight(to_tsvector('simple', coalesce(email, '')), 'C') ||
      setweight(to_tsvector('simple', coalesce(phone, '')), 'C')
    ) STORED;

CREATE INDEX idx_vendors_search ON vendors USING GIN (search_vector);
```

### 5.10 `receipts` table

Customer name is joined at query time (not stored in receipts), so the vector covers receipt_number only; customer name match is handled via a JOIN. `receipt_number` is NULL while a receipt is in `DRAFT`; `coalesce` keeps the generated column valid.

```sql
ALTER TABLE receipts
  ADD COLUMN search_vector tsvector
    GENERATED ALWAYS AS (
      setweight(to_tsvector('simple', coalesce(receipt_number, '')), 'A')
    ) STORED;

CREATE INDEX idx_receipts_search ON receipts USING GIN (search_vector);
```

Search query also runs: `to_tsvector('hebrew', c.name) @@ query` via JOIN to `customers c` on `receipts.customer_id = c.id`.

### 5.11 `contractors` table

```sql
ALTER TABLE contractors
  ADD COLUMN search_vector tsvector
    GENERATED ALWAYS AS (
      setweight(to_tsvector('hebrew', coalesce(name, '')), 'A') ||
      setweight(to_tsvector('simple', coalesce(tax_id, '')), 'B') ||
      setweight(to_tsvector('simple', coalesce(email, '')), 'C')
    ) STORED;

CREATE INDEX idx_contractors_search ON contractors USING GIN (search_vector);
```

### 5.12 `leads` table

```sql
ALTER TABLE leads
  ADD COLUMN search_vector tsvector
    GENERATED ALWAYS AS (
      setweight(to_tsvector('hebrew', coalesce(name, '')), 'A') ||
      setweight(to_tsvector('hebrew', coalesce(company, '')), 'B') ||
      setweight(to_tsvector('simple', coalesce(email, '')), 'C')
    ) STORED;

CREATE INDEX idx_leads_search ON leads USING GIN (search_vector);
```

### 5.13 `proposals` table

The proposal title is stored in `proposals.name`. Customer name is joined at query time (not stored in proposals), so the vector covers the title only; customer name match is handled via a JOIN.

```sql
ALTER TABLE proposals
  ADD COLUMN search_vector tsvector
    GENERATED ALWAYS AS (
      setweight(to_tsvector('hebrew', coalesce(name, '')), 'A')
    ) STORED;

CREATE INDEX idx_proposals_search ON proposals USING GIN (search_vector);
```

Search query also runs: `to_tsvector('hebrew', c.name) @@ query` via JOIN to `customers c` on `proposals.customer_id = c.id`.

### 5.14 `contracts` table

Customer name is joined at query time (not stored in contracts), so the vector covers the title only; customer name match is handled via a JOIN.

```sql
ALTER TABLE contracts
  ADD COLUMN search_vector tsvector
    GENERATED ALWAYS AS (
      setweight(to_tsvector('hebrew', coalesce(title, '')), 'A')
    ) STORED;

CREATE INDEX idx_contracts_search ON contracts USING GIN (search_vector);
```

Search query also runs: `to_tsvector('hebrew', c.name) @@ query` via JOIN to `customers c` on `contracts.customer_id = c.id`.

---

## 6. Server-Side Search Logic

### 6.1 Query construction

Input query is sanitized and converted to a `tsquery`:

```typescript
function buildTsquery(raw: string): string {
  // Remove characters special to tsquery: ! & | ( ) : *
  const safe = raw.replace(/[!&|():*'"\\]/g, ' ').trim();
  // Split on whitespace, build prefix-match query for each token
  const tokens = safe.split(/\s+/).filter(Boolean);
  return tokens.map(t => `${t}:*`).join(' & ');
}
```

Prefix matching (`:*` suffix on each token) enables partial-word matches, which is essential for real-time palette search.

### 6.2 Module gating

Before executing any entity query, the handler loads enabled modules:

```typescript
const enabledModules = await db
  .select({ module_id: tenant_modules.module_id })
  .from(tenant_modules)
  .where(
    and(
      eq(tenant_modules.tenant_id, tenantId),
      eq(tenant_modules.enabled, true)
    )
  );
const enabledSet = new Set(enabledModules.map(r => r.module_id));
enabledSet.add('system'); // always on
```

Entity queries are only run if their `module_id` is in `enabledSet`.

### 6.3 Permission filtering

Each entity query appends a `tenant_id = $tenantId` predicate. Additional per-entity predicates:

| Entity | Additional predicate |
|--------|---------------------|
| Tasks | `t.tenant_id = $tenantId` |
| Tasks (CONTRACTOR role) | `AND EXISTS (SELECT 1 FROM task_assignees ta WHERE ta.task_id = t.id AND ta.user_id = $userId)` |
| Projects | `p.tenant_id = $tenantId` |
| Customers | `c.tenant_id = $tenantId` |
| Invoices | `i.tenant_id = $tenantId` |
| Receipts | `r.tenant_id = $tenantId` |
| Expenses | `e.tenant_id = $tenantId` |
| Vendors | `v.tenant_id = $tenantId` |
| Leads | `l.tenant_id = $tenantId` |
| Proposals | `pr.tenant_id = $tenantId` |
| Contracts | `ct.tenant_id = $tenantId` |
| Contractors | `co.tenant_id = $tenantId` |
| KB Articles | `ka.tenant_id = $tenantId AND ka.published = true` (VIEWER/CONTRACTOR); no draft filter for OWNER/ADMIN/MEMBER |
| Support Tickets | `st.tenant_id = $tenantId` |
| Team Members | joined via `tenant_memberships.tenant_id = $tenantId` |

CONTRACTOR role is identified from `jwt.role === 'CONTRACTOR'`. The task-assignee subquery is always applied for CONTRACTORs regardless of module state. CONTRACTORs do not see invoices, receipts, expenses, vendors, leads, proposals, contracts, contractors, KB articles, support tickets, or team members in search results.

**CONTRACTOR entity visibility:**

| Entity | CONTRACTOR sees? |
|--------|-----------------|
| Tasks | Only assigned tasks |
| Projects | No |
| Customers | No |
| Invoices | No |
| Receipts | No |
| Expenses | No |
| Vendors | No |
| Leads | No |
| Proposals | No |
| Contracts | No |
| Contractors | No |
| KB Articles | No |
| Support Tickets | No |
| Team Members | No |

### 6.4 Ranking

Results within each group are ranked by `ts_rank_cd(search_vector, query)` descending. No cross-group ranking is performed; group ordering in the response is by `total` count descending.

### 6.5 Highlight generation

The `highlight` field is generated with Postgres `ts_headline`:

```sql
ts_headline(
  'hebrew',
  coalesce(title, ''),
  query,
  'StartSel=<mark>, StopSel=</mark>, MaxWords=10, MinWords=5, ShortWord=2'
)
```

For description/content fields, headline is drawn from those fields only if the title did not match.

---

## 7. Frontend Implementation Notes

### 7.1 Component tree

```
<CommandModal>                        // cmdk Command root, modal overlay
  <CommandInput />                    // controlled input, debounced
  <CommandList>
    <QuickActions />                  // module-gated, always shown
    <RecentSearches />                // shown when query < 2 chars
    <SearchGroups>                    // shown when query ≥ 2 chars
      <CommandGroup key={type}>       // one per entity group
        <CommandItem key={id} />      // result row
      </CommandGroup>
    </SearchGroups>
    <ShowMoreRow />                   // when total_groups > 3
    <EmptyState />                    // when groups is empty
  </CommandList>
</CommandModal>
```

### 7.2 Data fetching

Uses `@tanstack/react-query`. Query key: `['search', 'palette', query]`. `staleTime: 0`. `gcTime: 30_000`. Fetch only when `query.length >= 2`. No cache shared with full-page search.

```typescript
const { data, isLoading } = useQuery({
  queryKey: ['search', 'palette', debouncedQuery],
  queryFn: () => apiFetch<SearchResponse>(`/api/search?q=${encodeURIComponent(debouncedQuery)}&limit=5`),
  enabled: debouncedQuery.length >= 2,
  staleTime: 0,
});
```

### 7.3 Module awareness on client

The client reads enabled modules from the global module store (set during app boot, per `2026-05-31-module-management`). Quick actions are hidden client-side if the module is not enabled. This is a UX optimization only — the server enforces module gating independently.

### 7.4 Full search results page

Route: `apps/zync-app/src/routes/search.tsx`. Uses `useSearchParams` to read `q` and `type`. On mount, fires `GET /api/search/full?q=...` . Type-tab change fires a new request with `&type=...`. Load-more appends `&cursor=...`.

---

## 8. Design Tokens & Visual Spec

All colors use OKLCH design tokens defined in `foundation-design-system`.

| Element | Token |
|---------|-------|
| Modal overlay background | `rgba(0,0,0,0.4)` |
| Modal surface | `var(--surface)` |
| Modal border | `var(--line)` (one step darker) |
| Modal border-radius | `var(--radius)` = 4px |
| Modal max-width | 600px |
| Modal max-height | 480px (scrollable CommandList) |
| Group header text | `var(--ink-faint)` |
| Result row height | 40px |
| Result row hover | `var(--surface)` darker step |
| Quick action icon | `var(--accent)` |
| Input placeholder | `var(--ink-faint)` |
| Input text | `var(--ink)` |
| `<mark>` highlight | background `oklch(90% 0.08 90)` (soft yellow), no border-radius |

Spacing uses 8px grid: 8px row horizontal padding, 8px between icon and text, 16px section padding, 16px between modal edge and CommandList.

---

## 9. Permissions Reference

The search handler uses the JWT `permissions[]` array to enforce read access. Required permissions per entity type:

| Entity | Required permission |
|--------|-------------------|
| Tasks | `tasks:read` |
| Projects | `projects:read` |
| Customers | `customers:read` |
| Invoices | `invoices:read` |
| Receipts | `invoices:read` |
| Expenses | `expenses:read` |
| Vendors | `expenses:read` |
| Leads | `marketing:read` |
| Proposals | `marketing:read` |
| Contracts | `contracts:read` |
| Contractors | `payouts:read` |
| KB Articles | `kb:read` |
| Support Tickets | `tickets:read` |
| Team Members | `users:read` |

Rationale (2026-07-13): use canonical registered RBAC keys already enforced by contractor, ticket, and user APIs; `contractors:read`, `crm:read`, and `team:read` do not exist in the permission registry.

If the user lacks a permission, that entity's query is skipped entirely (not a 403 — the group simply doesn't appear in the response).

---

## 10. Architecture Decisions

| Decision | Choice | Rationale |
|----------|--------|-----------|
| Search engine | Postgres FTS (`tsvector` / `tsquery`) | Eliminates external service dependency. At expected scale (<10M rows per tenant), Postgres FTS with GIN indexes handles sub-100ms queries. Avoids Elasticsearch/Typesense operational cost and sync complexity. |
| Language config | `'hebrew'` for user content, `'simple'` for structured fields (email, phone, invoice number) | Hebrew text dictionary handles morphological stemming. Structured fields like email are better served by prefix match without stemming; `'simple'` treats all words as-is. |
| Prefix matching (`:*` on tokens) | Enabled | Palette search fires on every keystroke after debounce. Users expect "inv" to match "Invoice". `:*` enables this without a separate n-gram approach. |
| Stored generated column | `GENERATED ALWAYS AS … STORED` | Avoid recomputing tsvector on every query. Write cost paid once at INSERT/UPDATE time. Read path is pure index scan. |
| No cross-entity ranking | Groups ranked by count; items by `ts_rank_cd` within group | Cross-entity ranking requires subjective weighting that is prone to surprising results. Group-by-type gives predictable UX and matches user mental model ("I'm looking for an invoice"). |
| Max 5 per group in palette | Hard limit | Palette is a quick-access tool; cognitive load above ~5 items is counterproductive. "Show more" handles discovery. |
| Max 3 groups before "Show more" | Hard limit | Avoids palette height overflow on standard 1080p screens. Three groups fit comfortably within the 480px max-height. |
| Recent searches in localStorage | Client-only, no server storage | Privacy-preserving. No backend cost. Resets per browser/device which is acceptable — these are convenience entries, not permanent history. |
| Quick actions always visible | Module-gated, always shown regardless of query | Quick actions are the fastest path to creation flows. Surfacing them persistently reduces navigation steps. |
| CONTRACTOR task restriction | Server-side subquery enforced | CONTRACTORs must never see tasks they are not assigned to. Client-side filtering would be bypassable. Server enforces it unconditionally. |
| No empty-state CTA on search results | "Nothing matched that." only | A CTA like "Create new task" is presumptuous — the user may have mistyped or be looking for something that exists elsewhere. Keep the empty state neutral. |
| `/search` page uses cursor pagination | Cursor over offset | Offset pagination is unstable when underlying data changes during browsing. Cursor pagination gives stable page slices. Cursor is scoped per entity type so per-type tabs can paginate independently. |
