2026-07-17 23:20:34 +02:00
|
|
|
"""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
|
2026-09-25 20:17:37 +02:00
|
|
|
from app.api.threads import _summary, thread_senders
|
2026-07-17 23:20:34 +02:00
|
|
|
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),
|
2026-10-08 21:15:06 +02:00
|
|
|
filter: str | None = Query(None, pattern="^(unread|pinned)$"),
|
2026-07-17 23:20:34 +02:00
|
|
|
) -> SearchOut:
|
2026-10-08 21:15:06 +02:00
|
|
|
"""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."""
|
2026-07-17 23:20:34 +02:00
|
|
|
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)
|
2026-10-08 21:15:06 +02:00
|
|
|
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)
|
2026-07-17 23:20:34 +02:00
|
|
|
|
|
|
|
|
# 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:
|
2026-10-08 21:15:06 +02:00
|
|
|
return SearchOut(threads=[], cursor=next_cursor, total=total)
|
2026-07-17 23:20:34 +02:00
|
|
|
|
|
|
|
|
threads = {
|
|
|
|
|
t.id: t
|
|
|
|
|
for t in await db.scalars(
|
|
|
|
|
select(Thread).where(Thread.id.in_(order), Thread.user_id == user.id)
|
|
|
|
|
)
|
|
|
|
|
}
|
2026-09-25 20:17:37 +02:00
|
|
|
senders = await thread_senders(db, user.id, list(threads))
|
2026-07-17 23:20:34 +02:00
|
|
|
results = [
|
2026-09-25 20:17:37 +02:00
|
|
|
SearchThreadHit(
|
|
|
|
|
thread=_summary(threads[tid], senders=senders.get(tid)),
|
|
|
|
|
highlights=highlights.get(tid, {}),
|
|
|
|
|
)
|
2026-07-17 23:20:34 +02:00
|
|
|
for tid in order
|
|
|
|
|
if tid in threads
|
2026-10-08 21:15:06 +02:00
|
|
|
and (
|
|
|
|
|
filter is None
|
|
|
|
|
or (threads[tid].unread_count > 0 if filter == "unread" else threads[tid].pinned)
|
|
|
|
|
)
|
2026-07-17 23:20:34 +02:00
|
|
|
]
|
2026-10-08 21:15:06 +02:00
|
|
|
return SearchOut(threads=results, cursor=next_cursor, total=total)
|