diff --git a/.gitignore b/.gitignore index 7752923..9ec227f 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ __pycache__/ test.db web/node_modules web/dist +web/tsconfig.tsbuildinfo diff --git a/app/users/repository.py b/app/users/repository.py index 080e5fd..48bc83b 100644 --- a/app/users/repository.py +++ b/app/users/repository.py @@ -1,4 +1,4 @@ -from sqlalchemy import select +from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from app.users.models import User @@ -31,3 +31,19 @@ async def list_users_by_ids(db: AsyncSession, user_ids: list[int]) -> list[User] return [] result = await db.execute(select(User).where(User.id.in_(user_ids))) return list(result.scalars().all()) + + +async def search_users_by_username( + db: AsyncSession, + *, + query: str, + limit: int = 20, + exclude_user_id: int | None = None, +) -> list[User]: + normalized = query.lower().strip().lstrip("@") + stmt = select(User).where(func.lower(User.username).like(f"%{normalized}%")) + if exclude_user_id is not None: + stmt = stmt.where(User.id != exclude_user_id) + stmt = stmt.order_by(User.username.asc()).limit(limit) + result = await db.execute(stmt) + return list(result.scalars().all()) diff --git a/app/users/router.py b/app/users/router.py index 60d176b..ed2353b 100644 --- a/app/users/router.py +++ b/app/users/router.py @@ -4,8 +4,8 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.auth.service import get_current_user from app.database.session import get_db from app.users.models import User -from app.users.schemas import UserProfileUpdate, UserRead -from app.users.service import get_user_by_id, get_user_by_username, update_user_profile +from app.users.schemas import UserProfileUpdate, UserRead, UserSearchRead +from app.users.service import get_user_by_id, get_user_by_username, search_users_by_username, update_user_profile router = APIRouter(prefix="/users", tags=["users"]) @@ -15,6 +15,24 @@ async def read_me(current_user: User = Depends(get_current_user)) -> UserRead: return current_user +@router.get("/search", response_model=list[UserSearchRead]) +async def search_users( + query: str, + limit: int = 20, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +) -> list[UserSearchRead]: + if len(query.strip().lstrip("@")) < 2: + return [] + users = await search_users_by_username( + db, + query=query, + limit=limit, + exclude_user_id=current_user.id, + ) + return users + + @router.get("/{user_id}", response_model=UserRead) async def read_user(user_id: int, db: AsyncSession = Depends(get_db), _current_user: User = Depends(get_current_user)) -> UserRead: user = await get_user_by_id(db, user_id) diff --git a/app/users/schemas.py b/app/users/schemas.py index 7245b02..d64ddbb 100644 --- a/app/users/schemas.py +++ b/app/users/schemas.py @@ -25,3 +25,11 @@ class UserRead(UserBase): class UserProfileUpdate(BaseModel): username: str | None = Field(default=None, min_length=3, max_length=50) avatar_url: str | None = Field(default=None, max_length=512) + + +class UserSearchRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + username: str + avatar_url: str | None = None diff --git a/app/users/service.py b/app/users/service.py index 36857e5..5b77da3 100644 --- a/app/users/service.py +++ b/app/users/service.py @@ -16,6 +16,22 @@ async def get_user_by_username(db: AsyncSession, username: str) -> User | None: return await repository.get_user_by_username(db, username) +async def search_users_by_username( + db: AsyncSession, + *, + query: str, + limit: int = 20, + exclude_user_id: int | None = None, +) -> list[User]: + safe_limit = max(1, min(limit, 50)) + return await repository.search_users_by_username( + db, + query=query, + limit=safe_limit, + exclude_user_id=exclude_user_id, + ) + + async def update_user_profile( db: AsyncSession, user: User, diff --git a/web/src/api/chats.ts b/web/src/api/chats.ts index 38d163b..dd430c6 100644 --- a/web/src/api/chats.ts +++ b/web/src/api/chats.ts @@ -1,5 +1,5 @@ import { http } from "./http"; -import type { Chat, Message, MessageType } from "../chat/types"; +import type { Chat, ChatType, Message, MessageType } from "../chat/types"; export async function getChats(): Promise { const { data } = await http.get("/chats"); @@ -7,10 +7,14 @@ export async function getChats(): Promise { } export async function createPrivateChat(memberId: number): Promise { + return createChat("private", null, [memberId]); +} + +export async function createChat(type: ChatType, title: string | null, memberIds: number[] = []): Promise { const { data } = await http.post("/chats", { - type: "private", - title: null, - member_ids: [memberId] + type, + title, + member_ids: memberIds }); return data; } diff --git a/web/src/api/users.ts b/web/src/api/users.ts new file mode 100644 index 0000000..279b776 --- /dev/null +++ b/web/src/api/users.ts @@ -0,0 +1,9 @@ +import { http } from "./http"; +import type { UserSearchItem } from "../chat/types"; + +export async function searchUsers(query: string, limit = 20): Promise { + const { data } = await http.get("/users/search", { + params: { query, limit } + }); + return data; +} diff --git a/web/src/chat/types.ts b/web/src/chat/types.ts index 231a720..9095afd 100644 --- a/web/src/chat/types.ts +++ b/web/src/chat/types.ts @@ -33,3 +33,9 @@ export interface TokenPair { refresh_token: string; token_type: string; } + +export interface UserSearchItem { + id: number; + username: string; + avatar_url: string | null; +} diff --git a/web/src/components/ChatList.tsx b/web/src/components/ChatList.tsx index af5d990..4cf2654 100644 --- a/web/src/components/ChatList.tsx +++ b/web/src/components/ChatList.tsx @@ -1,4 +1,5 @@ import { useChatStore } from "../store/chatStore"; +import { NewChatPanel } from "./NewChatPanel"; export function ChatList() { const chats = useChatStore((s) => s.chats); @@ -8,6 +9,7 @@ export function ChatList() { return (