"""Global search: Cmd/K modal, tsvector full-text search across all entities.""" from fastapi import APIRouter, Request, Depends, Query from fastapi.templating import Jinja2Templates from fastapi.responses import JSONResponse from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import text from typing import Optional from core.database import get_db from core.sidebar import get_sidebar_data router = APIRouter(prefix="/search", tags=["search"]) templates = Jinja2Templates(directory="templates") # Entity search configs: (table, title_col, subtitle_query, url_pattern, icon) SEARCH_ENTITIES = [ { "type": "tasks", "label": "Tasks", "query": """ SELECT t.id, t.title as name, t.status, d.name as domain_name, p.name as project_name, ts_rank(t.search_vector, websearch_to_tsquery('english', :q)) as rank FROM tasks t LEFT JOIN domains d ON t.domain_id = d.id LEFT JOIN projects p ON t.project_id = p.id WHERE t.is_deleted = false AND t.search_vector @@ websearch_to_tsquery('english', :q) ORDER BY rank DESC LIMIT :lim """, "url": "/tasks/{id}", "icon": "task", }, { "type": "projects", "label": "Projects", "query": """ SELECT p.id, p.name, p.status, d.name as domain_name, NULL as project_name, ts_rank(p.search_vector, websearch_to_tsquery('english', :q)) as rank FROM projects p LEFT JOIN domains d ON p.domain_id = d.id WHERE p.is_deleted = false AND p.search_vector @@ websearch_to_tsquery('english', :q) ORDER BY rank DESC LIMIT :lim """, "url": "/projects/{id}", "icon": "project", }, { "type": "notes", "label": "Notes", "query": """ SELECT n.id, n.title as name, NULL as status, d.name as domain_name, p.name as project_name, ts_rank(n.search_vector, websearch_to_tsquery('english', :q)) as rank FROM notes n LEFT JOIN domains d ON n.domain_id = d.id LEFT JOIN projects p ON n.project_id = p.id WHERE n.is_deleted = false AND n.search_vector @@ websearch_to_tsquery('english', :q) ORDER BY rank DESC LIMIT :lim """, "url": "/notes/{id}", "icon": "note", }, { "type": "contacts", "label": "Contacts", "query": """ SELECT c.id, (c.first_name || ' ' || coalesce(c.last_name, '')) as name, NULL as status, c.company as domain_name, NULL as project_name, ts_rank(c.search_vector, websearch_to_tsquery('english', :q)) as rank FROM contacts c WHERE c.is_deleted = false AND c.search_vector @@ websearch_to_tsquery('english', :q) ORDER BY rank DESC LIMIT :lim """, "url": "/contacts/{id}", "icon": "contact", }, { "type": "links", "label": "Links", "query": """ SELECT l.id, l.label as name, NULL as status, d.name as domain_name, p.name as project_name, ts_rank(l.search_vector, websearch_to_tsquery('english', :q)) as rank FROM links l LEFT JOIN domains d ON l.domain_id = d.id LEFT JOIN projects p ON l.project_id = p.id WHERE l.is_deleted = false AND l.search_vector @@ websearch_to_tsquery('english', :q) ORDER BY rank DESC LIMIT :lim """, "url": "/links", "icon": "link", }, { "type": "lists", "label": "Lists", "query": """ SELECT l.id, l.name, NULL as status, d.name as domain_name, p.name as project_name, ts_rank(l.search_vector, websearch_to_tsquery('english', :q)) as rank FROM lists l LEFT JOIN domains d ON l.domain_id = d.id LEFT JOIN projects p ON l.project_id = p.id WHERE l.is_deleted = false AND l.search_vector @@ websearch_to_tsquery('english', :q) ORDER BY rank DESC LIMIT :lim """, "url": "/lists/{id}", "icon": "list", }, { "type": "meetings", "label": "Meetings", "query": """ SELECT m.id, m.title as name, m.status, NULL as domain_name, NULL as project_name, ts_rank(m.search_vector, websearch_to_tsquery('english', :q)) as rank FROM meetings m WHERE m.is_deleted = false AND m.search_vector @@ websearch_to_tsquery('english', :q) ORDER BY rank DESC LIMIT :lim """, "url": "/meetings/{id}", "icon": "meeting", }, { "type": "decisions", "label": "Decisions", "query": """ SELECT d.id, d.title as name, d.status, NULL as domain_name, NULL as project_name, ts_rank(d.search_vector, websearch_to_tsquery('english', :q)) as rank FROM decisions d WHERE d.is_deleted = false AND d.search_vector @@ websearch_to_tsquery('english', :q) ORDER BY rank DESC LIMIT :lim """, "url": "/decisions/{id}", "icon": "decision", }, { "type": "weblinks", "label": "Weblinks", "query": """ SELECT w.id, w.label as name, NULL as status, NULL as domain_name, NULL as project_name, ts_rank(w.search_vector, websearch_to_tsquery('english', :q)) as rank FROM weblinks w WHERE w.is_deleted = false AND w.search_vector @@ websearch_to_tsquery('english', :q) ORDER BY rank DESC LIMIT :lim """, "url": "/weblinks", "icon": "weblink", }, { "type": "appointments", "label": "Appointments", "query": """ SELECT a.id, a.title as name, NULL as status, a.location as domain_name, NULL as project_name, ts_rank(a.search_vector, websearch_to_tsquery('english', :q)) as rank FROM appointments a WHERE a.is_deleted = false AND a.search_vector @@ websearch_to_tsquery('english', :q) ORDER BY rank DESC LIMIT :lim """, "url": "/appointments/{id}", "icon": "appointment", }, ] @router.get("/api") async def search_api( q: str = Query("", min_length=1), entity_type: Optional[str] = None, limit: int = Query(5, ge=1, le=20), db: AsyncSession = Depends(get_db), ): """JSON search endpoint for the Cmd/K modal.""" if not q or not q.strip(): return JSONResponse({"results": [], "query": q}) results = [] entities = SEARCH_ENTITIES if entity_type: entities = [e for e in entities if e["type"] == entity_type] for entity in entities: try: result = await db.execute(text(entity["query"]), {"q": q.strip(), "lim": limit}) rows = [dict(r._mapping) for r in result] for row in rows: results.append({ "type": entity["type"], "type_label": entity["label"], "id": str(row["id"]), "name": row["name"], "status": row.get("status"), "context": " / ".join(filter(None, [row.get("domain_name"), row.get("project_name")])), "url": entity["url"].format(id=row["id"]), "rank": float(row.get("rank", 0)), "icon": entity["icon"], }) except Exception: # Table might not have search_vector yet, skip silently continue # Sort all results by rank descending results.sort(key=lambda r: r["rank"], reverse=True) return JSONResponse({"results": results[:20], "query": q}) @router.get("/") async def search_page( request: Request, q: str = "", db: AsyncSession = Depends(get_db), ): """Full search page (fallback for non-JS).""" sidebar = await get_sidebar_data(db) results = [] if q and q.strip(): for entity in SEARCH_ENTITIES: try: result = await db.execute(text(entity["query"]), {"q": q.strip(), "lim": 10}) rows = [dict(r._mapping) for r in result] for row in rows: results.append({ "type": entity["type"], "type_label": entity["label"], "id": str(row["id"]), "name": row["name"], "status": row.get("status"), "context": " / ".join(filter(None, [row.get("domain_name"), row.get("project_name")])), "url": entity["url"].format(id=row["id"]), "icon": entity["icon"], }) except Exception: continue return templates.TemplateResponse("search.html", { "request": request, "sidebar": sidebar, "results": results, "query": q, "page_title": "Search", "active_nav": "search", })