---
id: p1-01
title: Dashboard search - lexical message search via Postgres FTS
priority: p1
status: done
scope: mac-sync
tags: [search, messages, fts, postgres]
owner: search
updated_at: "2026-04-24"
evidence:
  - src/server/src/entities/message/repo.ts:searchMessages — FTS with websearch_to_tsquery('english') + ts_rank + ts_headline (marker style, no sanitizer dep)
  - src/server/src/entities/message/types.ts:MessageSearchFilter + MessageSearchHit — typed filter + result shapes
  - src/server/src/surfaces/my/messages.ts:/search — GET /my/messages/search route with zod validation (1–256 chars, optional conversationId/deviceId/limit)
  - web/src/tabs/Messages/index.tsx — debounced search input wired to useMessageSearch
  - web/src/tabs/Messages/SearchResults.tsx — results list with snippet and conversation drill-down
  - web/src/hooks/useMessages.ts:useMessageSearch — React Query hook, enabled when query.trim().length >= 2
  - web/src/api/messages.ts:searchMessages — apiGet wrapper for the search route
blocked_by: []
---

## Summary

First-pass lexical search over iMessage bodies via Postgres full-text search. Scoped to the Messages tab in the mac-sync unified dashboard.

## Acceptance criteria

- [x] `GET /my/messages/search?q=…` returns `{ query, limit, results: Array<{ message, snippet, rank }> }` with ranked hits
- [x] `snippet` uses `«»` markers from `ts_headline` (no HTML sanitizer dependency)
- [x] Empty / whitespace-only `q` → 400; `q` > 256 chars → 400
- [x] Dashboard Messages tab has a debounced (200ms) search input that swaps the conversation list for a `SearchResults` component
- [x] Clicking a result opens that conversation in the thread view
- [x] Conversation list + thread view still function when search input is empty (regression check)
- [x] Repo test covers rank ordering (exact phrase > single-word hit)
- [x] Route test covers 200 + result shape, 400 on empty `q`, 400 on oversized `q`
