diff --git a/alembic/versions/0003_search_indexes.py b/alembic/versions/0003_search_indexes.py new file mode 100644 index 0000000..34fa48e --- /dev/null +++ b/alembic/versions/0003_search_indexes.py @@ -0,0 +1,26 @@ +"""search indexes + +Revision ID: 0003_search_indexes +Revises: 0002_message_reliability_tables +Create Date: 2026-03-08 02:50:00.000000 +""" + +from typing import Sequence, Union + +from alembic import op + + +revision: str = "0003_search_indexes" +down_revision: Union[str, Sequence[str], None] = "0002_message_reliability_tables" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_index("ix_chats_title", "chats", ["title"], unique=False) + op.create_index("ix_messages_text", "messages", ["text"], unique=False) + + +def downgrade() -> None: + op.drop_index("ix_messages_text", table_name="messages") + op.drop_index("ix_chats_title", table_name="chats") diff --git a/app/chats/repository.py b/app/chats/repository.py index 03a412a..412df7f 100644 --- a/app/chats/repository.py +++ b/app/chats/repository.py @@ -1,4 +1,4 @@ -from sqlalchemy import Select, func, select +from sqlalchemy import Select, String, func, or_, select from sqlalchemy.orm import aliased from sqlalchemy.ext.asyncio import AsyncSession @@ -28,20 +28,31 @@ async def count_chat_members(db: AsyncSession, *, chat_id: int) -> int: return int(result.scalar_one()) -def _user_chats_query(user_id: int) -> Select[tuple[Chat]]: - return ( - select(Chat) - .join(ChatMember, ChatMember.chat_id == Chat.id) - .where(ChatMember.user_id == user_id) - .order_by(Chat.id.desc()) - ) +def _user_chats_query(user_id: int, query: str | None = None) -> Select[tuple[Chat]]: + stmt = select(Chat).join(ChatMember, ChatMember.chat_id == Chat.id).where(ChatMember.user_id == user_id) + if query and query.strip(): + q = f"%{query.strip()}%" + stmt = stmt.where( + or_( + Chat.title.ilike(q), + Chat.type.cast(String).ilike(q), + ) + ) + return stmt.order_by(Chat.id.desc()) -async def list_user_chats(db: AsyncSession, *, user_id: int, limit: int = 50, before_id: int | None = None) -> list[Chat]: - query = _user_chats_query(user_id).limit(limit) +async def list_user_chats( + db: AsyncSession, + *, + user_id: int, + limit: int = 50, + before_id: int | None = None, + query: str | None = None, +) -> list[Chat]: + query_stmt = _user_chats_query(user_id, query=query).limit(limit) if before_id is not None: - query = query.where(Chat.id < before_id) - result = await db.execute(query) + query_stmt = query_stmt.where(Chat.id < before_id) + result = await db.execute(query_stmt) return list(result.scalars().all()) diff --git a/app/chats/router.py b/app/chats/router.py index 1a8d606..c84b372 100644 --- a/app/chats/router.py +++ b/app/chats/router.py @@ -31,10 +31,11 @@ router = APIRouter(prefix="/chats", tags=["chats"]) async def list_chats( limit: int = 50, before_id: int | None = None, + query: str | None = None, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ) -> list[ChatRead]: - return await get_chats_for_user(db, user_id=current_user.id, limit=limit, before_id=before_id) + return await get_chats_for_user(db, user_id=current_user.id, limit=limit, before_id=before_id, query=query) @router.post("", response_model=ChatRead) diff --git a/app/chats/service.py b/app/chats/service.py index 61a0475..44a05e0 100644 --- a/app/chats/service.py +++ b/app/chats/service.py @@ -47,9 +47,16 @@ async def create_chat_for_user(db: AsyncSession, *, creator_id: int, payload: Ch return chat -async def get_chats_for_user(db: AsyncSession, *, user_id: int, limit: int = 50, before_id: int | None = None) -> list[Chat]: +async def get_chats_for_user( + db: AsyncSession, + *, + user_id: int, + limit: int = 50, + before_id: int | None = None, + query: str | None = None, +) -> list[Chat]: safe_limit = max(1, min(limit, 100)) - return await repository.list_user_chats(db, user_id=user_id, limit=safe_limit, before_id=before_id) + return await repository.list_user_chats(db, user_id=user_id, limit=safe_limit, before_id=before_id, query=query) async def get_chat_for_user(db: AsyncSession, *, chat_id: int, user_id: int) -> tuple[Chat, list]: diff --git a/app/messages/repository.py b/app/messages/repository.py index a0da9e0..9ff70ec 100644 --- a/app/messages/repository.py +++ b/app/messages/repository.py @@ -1,6 +1,7 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession +from app.chats.models import ChatMember from app.messages.models import Message, MessageIdempotencyKey, MessageReceipt, MessageType @@ -76,6 +77,31 @@ async def list_chat_messages( return list(result.scalars().all()) +async def search_messages( + db: AsyncSession, + *, + user_id: int, + query: str, + chat_id: int | None = None, + limit: int = 50, +) -> list[Message]: + stmt = ( + select(Message) + .join(ChatMember, ChatMember.chat_id == Message.chat_id) + .where( + ChatMember.user_id == user_id, + Message.text.is_not(None), + Message.text.ilike(f"%{query.strip()}%"), + ) + .order_by(Message.id.desc()) + .limit(limit) + ) + if chat_id is not None: + stmt = stmt.where(Message.chat_id == chat_id) + result = await db.execute(stmt) + return list(result.scalars().all()) + + async def delete_message(db: AsyncSession, message: Message) -> None: await db.delete(message) diff --git a/app/messages/router.py b/app/messages/router.py index 215b941..7ee7e3e 100644 --- a/app/messages/router.py +++ b/app/messages/router.py @@ -4,7 +4,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.auth.service import get_current_user from app.database.session import get_db from app.messages.schemas import MessageCreateRequest, MessageRead, MessageStatusUpdateRequest, MessageUpdateRequest -from app.messages.service import create_chat_message, delete_message, get_messages, update_message +from app.messages.service import create_chat_message, delete_message, get_messages, search_messages, update_message from app.realtime.schemas import MessageStatusPayload from app.realtime.service import realtime_gateway from app.users.models import User @@ -27,6 +27,17 @@ async def create_message( return message +@router.get("/search", response_model=list[MessageRead]) +async def search_messages_endpoint( + query: str, + chat_id: int | None = None, + limit: int = 50, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +) -> list[MessageRead]: + return await search_messages(db, user_id=current_user.id, query=query, chat_id=chat_id, limit=limit) + + @router.get("/{chat_id}", response_model=list[MessageRead]) async def list_messages( chat_id: int, diff --git a/app/messages/service.py b/app/messages/service.py index 2d58e38..bbd9ebd 100644 --- a/app/messages/service.py +++ b/app/messages/service.py @@ -76,6 +76,29 @@ async def get_messages( return await repository.list_chat_messages(db, chat_id, limit=safe_limit, before_id=before_id) +async def search_messages( + db: AsyncSession, + *, + user_id: int, + query: str, + chat_id: int | None = None, + limit: int = 50, +) -> list[Message]: + normalized = query.strip() + if len(normalized) < 2: + return [] + safe_limit = max(1, min(limit, 100)) + if chat_id is not None: + await ensure_chat_membership(db, chat_id=chat_id, user_id=user_id) + return await repository.search_messages( + db, + user_id=user_id, + query=normalized, + chat_id=chat_id, + limit=safe_limit, + ) + + async def update_message( db: AsyncSession, *, diff --git a/web/src/api/chats.ts b/web/src/api/chats.ts index fdcd38d..75b7d8f 100644 --- a/web/src/api/chats.ts +++ b/web/src/api/chats.ts @@ -2,8 +2,10 @@ import { http } from "./http"; import type { Chat, ChatType, Message, MessageType } from "../chat/types"; import axios from "axios"; -export async function getChats(): Promise { - const { data } = await http.get("/chats"); +export async function getChats(query?: string): Promise { + const { data } = await http.get("/chats", { + params: query?.trim() ? { query: query.trim() } : undefined + }); return data; } @@ -30,6 +32,16 @@ export async function getMessages(chatId: number, beforeId?: number): Promise { + const { data } = await http.get("/messages/search", { + params: { + query, + chat_id: chatId + } + }); + return data; +} + export async function sendMessage(chatId: number, text: string, type: MessageType = "text"): Promise { const { data } = await http.post("/messages", { chat_id: chatId, text, type }); return data; diff --git a/web/src/components/ChatList.tsx b/web/src/components/ChatList.tsx index ec822d1..e93c08d 100644 --- a/web/src/components/ChatList.tsx +++ b/web/src/components/ChatList.tsx @@ -1,3 +1,5 @@ +import { useEffect, useState } from "react"; +import { useAuthStore } from "../store/authStore"; import { useChatStore } from "../store/chatStore"; import { NewChatPanel } from "./NewChatPanel"; @@ -6,29 +8,69 @@ export function ChatList() { const messagesByChat = useChatStore((s) => s.messagesByChat); const activeChatId = useChatStore((s) => s.activeChatId); const setActiveChatId = useChatStore((s) => s.setActiveChatId); + const loadChats = useChatStore((s) => s.loadChats); + const me = useAuthStore((s) => s.me); + const [search, setSearch] = useState(""); + const [tab, setTab] = useState<"all" | "people" | "groups" | "channels">("all"); + + useEffect(() => { + const timer = setTimeout(() => { + void loadChats(search.trim() ? search : undefined); + }, 250); + return () => clearTimeout(timer); + }, [search, loadChats]); + + const filteredChats = chats.filter((chat) => { + if (tab === "people") { + return chat.type === "private"; + } + if (tab === "groups") { + return chat.type === "group"; + } + if (tab === "channels") { + return chat.type === "channel"; + } + return true; + }); + + const tabs: Array<{ id: "all" | "people" | "groups" | "channels"; label: string }> = [ + { id: "all", label: "All" }, + { id: "people", label: "Люди" }, + { id: "groups", label: "Groups" }, + { id: "channels", label: "Каналы" } + ]; return ( -