diff --git a/backend/app/api/main.py b/backend/app/api/main.py index 09e0663..de2ba9f 100644 --- a/backend/app/api/main.py +++ b/backend/app/api/main.py @@ -1,9 +1,10 @@ from fastapi import APIRouter -from app.api.routes import items, login, users, utils +from app.api.routes import items, login, search, users, utils api_router = APIRouter() api_router.include_router(login.router, tags=["login"]) api_router.include_router(users.router, prefix="/users", tags=["users"]) api_router.include_router(utils.router, prefix="/utils", tags=["utils"]) api_router.include_router(items.router, prefix="/items", tags=["items"]) +api_router.include_router(search.router, prefix="/search", tags=["search"]) diff --git a/backend/app/api/routes/search.py b/backend/app/api/routes/search.py new file mode 100644 index 0000000..3dd278d --- /dev/null +++ b/backend/app/api/routes/search.py @@ -0,0 +1,54 @@ +from typing import Any + +from fastapi import APIRouter +from sqlalchemy import or_ +from sqlmodel import col, select + +from app.api.deps import CurrentUser, SessionDep +from app.models import Item, SearchResultsPublic, User + +router = APIRouter() + + +@router.get("/", response_model=SearchResultsPublic) +def search( + session: SessionDep, + current_user: CurrentUser, + q: str, + limit: int = 10, +) -> Any: + """ + Search visible items, and users when the requester is a superuser. + """ + query = q.strip() + if not query: + return SearchResultsPublic(items=[], users=[]) + + safe_limit = max(1, min(limit, 50)) + pattern = f"%{query}%" + item_statement = select(Item).where( + or_( + col(Item.title).ilike(pattern), + col(Item.description).ilike(pattern), + ) + ) + if not current_user.is_superuser: + item_statement = item_statement.where(Item.owner_id == current_user.id) + item_statement = item_statement.limit(safe_limit) + + items = list(session.exec(item_statement).all()) + users: list[User] = [] + if current_user.is_superuser: + user_statement = ( + select(User) + .where( + or_( + col(User.email).ilike(pattern), + col(User.full_name).ilike(pattern), + ) + ) + .limit(safe_limit) + ) + users = list(session.exec(user_statement).all()) + + return SearchResultsPublic(items=items, users=users) diff --git a/backend/app/models.py b/backend/app/models.py index 8e74fea..a761710 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -92,6 +92,11 @@ class ItemsPublic(SQLModel): count: int +class SearchResultsPublic(SQLModel): + items: list[ItemPublic] + users: list[UserPublic] + + # Generic message class Message(SQLModel): message: str diff --git a/backend/app/tests/api/routes/test_search.py b/backend/app/tests/api/routes/test_search.py new file mode 100644 index 0000000..05ac71a --- /dev/null +++ b/backend/app/tests/api/routes/test_search.py @@ -0,0 +1,83 @@ +from fastapi.testclient import TestClient +from sqlmodel import Session + +from app import crud +from app.core.config import settings +from app.models import ItemCreate, UserCreate +from app.tests.utils.user import user_authentication_headers +from app.tests.utils.utils import random_email, random_lower_string + + +def test_search_returns_visible_items_for_regular_users( + client: TestClient, db: Session +) -> None: + password = random_lower_string() + user = crud.create_user( + session=db, + user_create=UserCreate(email=random_email(), password=password), + ) + other_user = crud.create_user( + session=db, + user_create=UserCreate(email=random_email(), password=random_lower_string()), + ) + assert user.id is not None + assert other_user.id is not None + + visible_item = crud.create_item( + session=db, + owner_id=user.id, + item_in=ItemCreate(title="Global Search Match", description="visible"), + ) + hidden_item = crud.create_item( + session=db, + owner_id=other_user.id, + item_in=ItemCreate(title="Global Search Match", description="hidden"), + ) + headers = user_authentication_headers( + client=client, email=user.email, password=password + ) + + response = client.get( + f"{settings.API_V1_STR}/search/", + headers=headers, + params={"q": "global search"}, + ) + + assert response.status_code == 200 + content = response.json() + result_ids = {item["id"] for item in content["items"]} + assert visible_item.id in result_ids + assert hidden_item.id not in result_ids + assert content["users"] == [] + + +def test_search_returns_users_for_superusers( + client: TestClient, + superuser_token_headers: dict[str, str], + db: Session, +) -> None: + user = crud.create_user( + session=db, + user_create=UserCreate( + email="searchable-user@example.com", + full_name="Searchable Person", + password=random_lower_string(), + ), + ) + + response = client.get( + f"{settings.API_V1_STR}/search/", + headers=superuser_token_headers, + params={"q": "searchable"}, + ) + + assert response.status_code == 200 + content = response.json() + result_ids = {result["id"] for result in content["users"]} + assert user.id in result_ids + + +def test_search_rejects_unauthenticated_requests(client: TestClient) -> None: + response = client.get(f"{settings.API_V1_STR}/search/", params={"q": "anything"}) + + assert response.status_code == 401 diff --git a/frontend/src/client/models.ts b/frontend/src/client/models.ts index 6732a18..6e1a801 100644 --- a/frontend/src/client/models.ts +++ b/frontend/src/client/models.ts @@ -44,6 +44,12 @@ export type ItemsPublic = { }; +export type SearchResultsPublic = { + items: Array; + users: Array; +}; + + export type Message = { message: string; @@ -129,4 +135,3 @@ export type ValidationError = { msg: string; type: string; }; - diff --git a/frontend/src/client/services.ts b/frontend/src/client/services.ts index 4ace1a4..85863a2 100644 --- a/frontend/src/client/services.ts +++ b/frontend/src/client/services.ts @@ -2,7 +2,7 @@ import type { CancelablePromise } from './core/CancelablePromise'; import { OpenAPI } from './core/OpenAPI'; import { request as __request } from './core/request'; -import type { Body_login_login_access_token,Message,NewPassword,Token,UserPublic,UpdatePassword,UserCreate,UserRegister,UsersPublic,UserUpdate,UserUpdateMe,ItemCreate,ItemPublic,ItemsPublic,ItemUpdate } from './models'; +import type { Body_login_login_access_token,Message,NewPassword,Token,UserPublic,UpdatePassword,UserCreate,UserRegister,UsersPublic,UserUpdate,UserUpdateMe,ItemCreate,ItemPublic,ItemsPublic,ItemUpdate,SearchResultsPublic } from './models'; export type TDataLoginAccessToken = { formData: Body_login_login_access_token @@ -20,6 +20,38 @@ export type TDataRecoverPasswordHtmlContent = { email: string } +export type TDataSearch = { + q: string + limit?: number + + } + +export class SearchService { + + /** + * Search + * Search visible items, and users when the requester is a superuser. + * @returns SearchResultsPublic Successful Response + * @throws ApiError + */ + public static search(data: TDataSearch): CancelablePromise { + const { +q, +limit = 10, +} = data; + return __request(OpenAPI, { + method: 'GET', + url: '/api/v1/search/', + query: { + q, limit + }, + errors: { + 422: `Validation Error`, + }, + }); + } + +} export class LoginService { @@ -521,4 +553,4 @@ id, }); } -} \ No newline at end of file +} diff --git a/frontend/src/components/Common/SidebarItems.tsx b/frontend/src/components/Common/SidebarItems.tsx index 929e8f7..cf55a72 100644 --- a/frontend/src/components/Common/SidebarItems.tsx +++ b/frontend/src/components/Common/SidebarItems.tsx @@ -1,12 +1,19 @@ import { Box, Flex, Icon, Text, useColorModeValue } from "@chakra-ui/react" import { useQueryClient } from "@tanstack/react-query" import { Link } from "@tanstack/react-router" -import { FiBriefcase, FiHome, FiSettings, FiUsers } from "react-icons/fi" +import { + FiBriefcase, + FiHome, + FiSearch, + FiSettings, + FiUsers, +} from "react-icons/fi" import type { UserPublic } from "../../client" const items = [ { icon: FiHome, title: "Dashboard", path: "/" }, + { icon: FiSearch, title: "Search", path: "/search" }, { icon: FiBriefcase, title: "Items", path: "/items" }, { icon: FiSettings, title: "User Settings", path: "/settings" }, ] diff --git a/frontend/src/routeTree.gen.ts b/frontend/src/routeTree.gen.ts index 395866a..36f1a2f 100644 --- a/frontend/src/routeTree.gen.ts +++ b/frontend/src/routeTree.gen.ts @@ -17,6 +17,7 @@ import { Route as LoginImport } from './routes/login' import { Route as LayoutImport } from './routes/_layout' import { Route as LayoutIndexImport } from './routes/_layout/index' import { Route as LayoutSettingsImport } from './routes/_layout/settings' +import { Route as LayoutSearchImport } from './routes/_layout/search' import { Route as LayoutItemsImport } from './routes/_layout/items' import { Route as LayoutAdminImport } from './routes/_layout/admin' @@ -52,6 +53,11 @@ const LayoutSettingsRoute = LayoutSettingsImport.update({ getParentRoute: () => LayoutRoute, } as any) +const LayoutSearchRoute = LayoutSearchImport.update({ + path: '/search', + getParentRoute: () => LayoutRoute, +} as any) + const LayoutItemsRoute = LayoutItemsImport.update({ path: '/items', getParentRoute: () => LayoutRoute, @@ -90,6 +96,10 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof LayoutItemsImport parentRoute: typeof LayoutImport } + '/_layout/search': { + preLoaderRoute: typeof LayoutSearchImport + parentRoute: typeof LayoutImport + } '/_layout/settings': { preLoaderRoute: typeof LayoutSettingsImport parentRoute: typeof LayoutImport @@ -107,6 +117,7 @@ export const routeTree = rootRoute.addChildren([ LayoutRoute.addChildren([ LayoutAdminRoute, LayoutItemsRoute, + LayoutSearchRoute, LayoutSettingsRoute, LayoutIndexRoute, ]), diff --git a/frontend/src/routes/_layout/search.tsx b/frontend/src/routes/_layout/search.tsx new file mode 100644 index 0000000..a1a6af7 --- /dev/null +++ b/frontend/src/routes/_layout/search.tsx @@ -0,0 +1,143 @@ +import { + Alert, + AlertIcon, + Box, + Container, + Flex, + Heading, + Input, + InputGroup, + InputLeftElement, + Spinner, + Table, + TableContainer, + Tbody, + Td, + Text, + Th, + Thead, + Tr, +} from "@chakra-ui/react" +import { useQuery } from "@tanstack/react-query" +import { createFileRoute } from "@tanstack/react-router" +import { useState } from "react" +import { FiSearch } from "react-icons/fi" + +import { SearchService } from "../../client" + +export const Route = createFileRoute("/_layout/search")({ + component: Search, +}) + +function Search() { + const [query, setQuery] = useState("") + const normalizedQuery = query.trim() + const search = useQuery({ + queryKey: ["search", normalizedQuery], + queryFn: () => SearchService.search({ q: normalizedQuery, limit: 20 }), + enabled: normalizedQuery.length > 0, + }) + + const hasResults = + (search.data?.items.length || 0) > 0 || (search.data?.users.length || 0) > 0 + + return ( + + + Search + + + + + + + + setQuery(event.target.value)} + placeholder="Search items and users" + borderRadius="8px" + /> + + + + {search.isFetching && ( + + + Searching... + + )} + + {search.isError && ( + + + Search failed. Please try again. + + )} + + {normalizedQuery && search.isSuccess && !hasResults && ( + No results found. + )} + + {search.data?.items.length ? ( + + + Items + + + + + + + + + + + + {search.data.items.map((item) => ( + + + + + + ))} + +
IDTitleDescription
{item.id}{item.title} + {item.description || "N/A"} +
+
+
+ ) : null} + + {search.data?.users.length ? ( + + + Users + + + + + + + + + + + + {search.data.users.map((user) => ( + + + + + + ))} + +
IDNameEmail
{user.id} + {user.full_name || "N/A"} + {user.email}
+
+
+ ) : null} +
+ ) +}