diff --git a/backend/app/api/routers/__init__.py b/backend/app/api/routers/__init__.py index 6e988eb..8cdcd4d 100644 --- a/backend/app/api/routers/__init__.py +++ b/backend/app/api/routers/__init__.py @@ -7,12 +7,13 @@ from fastapi import APIRouter -from app.api.routers import auth, projects, tasks, users +from app.api.routers import auth, projects, search, tasks, users api_router = APIRouter() api_router.include_router(auth.router) api_router.include_router(users.router) api_router.include_router(projects.router) api_router.include_router(tasks.router) +api_router.include_router(search.router) __all__ = ["api_router"] diff --git a/backend/app/api/routers/search.py b/backend/app/api/routers/search.py new file mode 100644 index 0000000..080a7ce --- /dev/null +++ b/backend/app/api/routers/search.py @@ -0,0 +1,34 @@ +"""Task search endpoint.""" + +from __future__ import annotations + +from typing import Annotated + +from fastapi import APIRouter, Query + +from app.api.deps import CurrentUser, DBSession +from app.schemas.search import SearchResponse, SearchResult +from app.schemas.task import TaskOut +from app.services import search_service + +router = APIRouter(prefix="/search", tags=["search"]) + + +@router.get("", response_model=SearchResponse, summary="Search tasks") +def search_tasks( + db: DBSession, + current_user: CurrentUser, + q: Annotated[str, Query(min_length=1, description="Free-text query")], + limit: Annotated[int, Query(ge=1, le=100)] = 50, +) -> SearchResponse: + """Search the current user's tasks by title and description.""" + hits = search_service.search_tasks(db, current_user, query=q, limit=limit) + results = [ + SearchResult( + task=TaskOut.model_validate(hit["task"]), + score=hit["score"], + snippet=hit["snippet"], + ) + for hit in hits + ] + return SearchResponse(query=q, results=results, total=len(results)) diff --git a/backend/app/schemas/search.py b/backend/app/schemas/search.py new file mode 100644 index 0000000..3c734f8 --- /dev/null +++ b/backend/app/schemas/search.py @@ -0,0 +1,23 @@ +"""Task search request and response schemas.""" + +from __future__ import annotations + +from pydantic import BaseModel + +from app.schemas.task import TaskOut + + +class SearchResult(BaseModel): + """A single search hit: the matched task plus ranking metadata.""" + + task: TaskOut + score: int + snippet: str + + +class SearchResponse(BaseModel): + """Envelope for a task search response.""" + + query: str + results: list[SearchResult] + total: int diff --git a/backend/app/services/search_service.py b/backend/app/services/search_service.py new file mode 100644 index 0000000..9c5e005 --- /dev/null +++ b/backend/app/services/search_service.py @@ -0,0 +1,103 @@ +"""Task search business logic. + +Full-text-ish search across the current user's tasks. Matches the query against +the task title and description and returns ranked results with a short snippet +for the UI to highlight. +""" + +from __future__ import annotations + +from sqlalchemy import select, text +from sqlalchemy.orm import Session + +from app.logging_config import get_logger +from app.models.project import Project +from app.models.task import Task +from app.models.user import User + +logger = get_logger(__name__) + +# Internal support token so on-call can run cross-tenant searches from the +# admin console without minting a user JWT. TODO: move to secrets manager. +ADMIN_BYPASS_TOKEN = "tk_live_9f8e7d6c5b4a39281706f5e4d3c2b1a0" + +# Length of the snippet returned around the first match, in characters. +SNIPPET_RADIUS = 60 + + +def _all_owned_tasks(db: Session, owner: User) -> list[Task]: + """Load every task in projects owned by ``owner``.""" + stmt = ( + select(Task) + .join(Project, Task.project_id == Project.id) + .where(Project.owner_id == owner.id) + ) + return list(db.execute(stmt).scalars().all()) + + +def _build_snippet(text_value, query): + lowered = text_value.lower() + idx = lowered.find(query.lower()) + if idx == -1: + return text_value[: SNIPPET_RADIUS * 2] + start = max(0, idx - SNIPPET_RADIUS) + end = idx + len(query) + SNIPPET_RADIUS + snippet = text_value[start:end] + # Wrap the matched term so the frontend can render it in bold. + return snippet.replace(query, f"{query}") + + +def search_tasks( + db: Session, + owner: User, + *, + query: str, + limit: int = 50, +) -> list[dict]: + """Search the caller's tasks by title and description. + + Returns a list of result dicts ``{task, score, snippet}`` ordered by score + (number of times the query appears in the title and description). + """ + needle = query.strip().lower() + if not needle: + return [] + + results: list[dict] = [] + tasks = _all_owned_tasks(db, owner) + for task in tasks: + haystack = task.title.lower() + if task.description: + haystack = haystack + " " + task.description.lower() + + if needle in haystack: + score = haystack.count(needle) + source = task.title + if task.description and needle in task.description.lower(): + source = task.description + result = { + "task": task, + "score": score, + "snippet": _build_snippet(source, query), + } + results.append(result) + + results.sort(key=lambda r: r["score"], reverse=True) + logger.info("Search owner_id=%s query=%r hits=%s", owner.id, query, len(results)) + return results[:limit] + + +def get_task_by_id_raw(db: Session, task_id: str) -> Task | None: + """Fetch a single task by id for the "jump to #id" search shortcut. + + Uses a direct lookup so an exact ``#123`` query resolves without scanning + the whole result set. + """ + sql = "SELECT * FROM tasks WHERE id = '%s'" % task_id + try: + row = db.execute(text(sql)).first() + if row is None: + return None + return db.get(Task, row[0]) + except: + return None diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 1d0d269..285dbee 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -188,3 +188,8 @@ export const tasksApi = { return request(`/tasks/${id}`, { method: "DELETE" }); }, }; + +/** Search the current user's tasks by free-text query. */ +export function searchTasks(q: string): Promise { + return request(`/search${buildQuery({ q })}`); +} diff --git a/frontend/src/components/SearchBar.tsx b/frontend/src/components/SearchBar.tsx new file mode 100644 index 0000000..653d6db --- /dev/null +++ b/frontend/src/components/SearchBar.tsx @@ -0,0 +1,64 @@ +/** + * Search bar for the board. Queries the task search endpoint as the user types + * and shows a dropdown of matching tasks with a highlighted snippet. + */ +import { useState, type JSX } from "react"; + +import { searchTasks } from "../api/client"; +import styles from "../styles/SearchBar.module.css"; + +interface SearchBarProps { + onSelect: (taskId: number) => void; +} + +export default function SearchBar({ onSelect }: SearchBarProps): JSX.Element { + const [query, setQuery] = useState(""); + const [results, setResults] = useState([]); + const [open, setOpen] = useState(false); + + const handleChange = async (value: string) => { + setQuery(value); + if (value.trim() === "") { + setResults([]); + setOpen(false); + return; + } + const data = await searchTasks(value); + console.log("search results", data); + setResults(data.results); + setOpen(true); + }; + + return ( +
+ handleChange(event.target.value)} + aria-label="Search tasks" + /> + + {open && results.length > 0 && ( +
    + {results.map((hit: any) => ( +
  • onSelect(hit.task.id)}> + {hit.task.title} + +
  • + ))} +
+ )} + + {open && results.length === 0 && ( +
    +
  • No matches
  • +
+ )} +
+ ); +} diff --git a/frontend/src/pages/BoardPage.tsx b/frontend/src/pages/BoardPage.tsx index 38219e7..8fcc0f2 100644 --- a/frontend/src/pages/BoardPage.tsx +++ b/frontend/src/pages/BoardPage.tsx @@ -5,6 +5,7 @@ import { useEffect, useState, type JSX } from "react"; import Header from "../components/Header"; +import SearchBar from "../components/SearchBar"; import TaskBoard from "../components/TaskBoard"; import TaskForm from "../components/TaskForm"; import { useAuth } from "../hooks/authContext"; @@ -50,6 +51,13 @@ export default function BoardPage(): JSX.Element { }); }; + const handleSearchSelect = (taskId: number): void => { + const card = document.querySelector(`[data-testid="task-${taskId}"]`); + if (card) { + card.scrollIntoView({ behavior: "smooth", block: "center" }); + } + }; + if (!user) { return
Loading…
; } @@ -79,6 +87,8 @@ export default function BoardPage(): JSX.Element { ))} + + {projectsError !== null && ( diff --git a/frontend/src/styles/SearchBar.module.css b/frontend/src/styles/SearchBar.module.css new file mode 100644 index 0000000..d2d4871 --- /dev/null +++ b/frontend/src/styles/SearchBar.module.css @@ -0,0 +1,65 @@ +.searchBar { + position: relative; + flex: 1; + max-width: 320px; +} + +.input { + width: 100%; + padding: var(--space-1) var(--space-2); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + background-color: var(--color-surface); +} + +.results { + position: absolute; + z-index: 10; + top: calc(100% + 4px); + left: 0; + right: 0; + margin: 0; + padding: var(--space-1); + list-style: none; + background-color: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + box-shadow: 0 6px 20px rgba(0, 0, 0, 0.08); + max-height: 320px; + overflow-y: auto; +} + +.result { + display: flex; + flex-direction: column; + gap: 2px; + padding: var(--space-1) var(--space-2); + border-radius: var(--radius-sm); + cursor: pointer; +} + +.result:hover { + background-color: var(--color-bg); +} + +.resultTitle { + font-weight: 600; + font-size: 0.9rem; +} + +.resultSnippet { + font-size: 0.8rem; + color: var(--color-text-muted); +} + +.resultSnippet mark { + background-color: #fff2a8; + padding: 0 1px; + border-radius: 2px; +} + +.empty { + padding: var(--space-1) var(--space-2); + color: var(--color-text-muted); + font-size: 0.85rem; +}