"""Search endpoint (SPEC §9.1 Search, §8). Results grouped by thread (FR-28).""" from __future__ import annotations import uuid from typing import Annotated from fastapi import APIRouter, Depends, Query from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.api.schemas import SearchOut, SearchThreadHit from app.api.threads import _summary, thread_senders from app.db import get_session from app.identity.deps import CurrentUser from app.models import Thread from app.search.factory import get_search_backend from app.search.queryparser import to_search_query router = APIRouter(prefix="/search", tags=["search"]) DbSession = Annotated[AsyncSession, Depends(get_session)] @router.get("", response_model=SearchOut) async def search( user: CurrentUser, db: DbSession, q: str = Query(""), cursor: str | None = None, limit: int = Query(50, ge=1, le=200), filter: str | None = Query(None, pattern="^(unread|pinned)$"), ) -> SearchOut: """One page of matching messages, grouped into conversations. ``filter`` keeps conversations with unread mail / pinned ones (the list's Unread and Pinned tabs). The first page also says how many conversations match in all, where the search engine can count them.""" if not q.strip(): return SearchOut(threads=[], cursor=None) backend = get_search_backend() query = to_search_query(q, user.id) hits, next_cursor = await backend.query(query, limit=limit, cursor=cursor) total: int | None = None count = getattr(backend, "count_threads", None) if cursor is None and count is not None: total = await count(query, thread_filter=filter) # Group hits by thread, preserving order and keeping the first highlight. order: list[uuid.UUID] = [] highlights: dict[uuid.UUID, dict[str, str]] = {} for hit in hits: if hit.thread_id not in highlights: order.append(hit.thread_id) highlights[hit.thread_id] = hit.highlights elif hit.highlights and not highlights[hit.thread_id]: highlights[hit.thread_id] = hit.highlights if not order: return SearchOut(threads=[], cursor=next_cursor, total=total) threads = { t.id: t for t in await db.scalars( select(Thread).where(Thread.id.in_(order), Thread.user_id == user.id) ) } senders = await thread_senders(db, user.id, list(threads)) results = [ SearchThreadHit( thread=_summary(threads[tid], senders=senders.get(tid)), highlights=highlights.get(tid, {}), ) for tid in order if tid in threads and ( filter is None or (threads[tid].unread_count > 0 if filter == "unread" else threads[tid].pinned) ) ] return SearchOut(threads=results, cursor=next_cursor, total=total)