From cbd1b008bb1fc19d225bfbcb1ef03a176417718c Mon Sep 17 00:00:00 2001 From: benya Date: Sun, 8 Mar 2026 11:51:02 +0300 Subject: [PATCH] feat(contacts): switch contacts UX to email-first flow - expose email in user search/contact responses - add add-contact-by-email API endpoint - show email in contacts/search and add contact form by email in Contacts tab --- app/users/repository.py | 7 ++++- app/users/router.py | 20 ++++++++++++++- app/users/schemas.py | 5 ++++ web/src/api/users.ts | 4 +++ web/src/chat/types.ts | 1 + web/src/components/ChatList.tsx | 45 ++++++++++++++++++++++++++++++--- 6 files changed, 77 insertions(+), 5 deletions(-) diff --git a/app/users/repository.py b/app/users/repository.py index 8381e79..ee9c076 100644 --- a/app/users/repository.py +++ b/app/users/repository.py @@ -43,7 +43,12 @@ async def search_users_by_username( exclude_user_id: int | None = None, ) -> list[User]: normalized = query.lower().strip().lstrip("@") - stmt = select(User).where(func.lower(User.username).like(f"%{normalized}%")) + stmt = select(User).where( + or_( + func.lower(User.username).like(f"%{normalized}%"), + func.lower(User.email).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) diff --git a/app/users/router.py b/app/users/router.py index 7a1f996..ba33576 100644 --- a/app/users/router.py +++ b/app/users/router.py @@ -4,11 +4,12 @@ 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, UserSearchRead +from app.users.schemas import ContactByEmailRequest, UserProfileUpdate, UserRead, UserSearchRead from app.users.service import ( add_contact, block_user, get_user_by_id, + get_user_by_email, get_user_by_username, list_blocked_users, list_contacts, @@ -101,6 +102,23 @@ async def add_contact_endpoint( await add_contact(db, user_id=current_user.id, contact_user_id=user_id) +@router.post("/contacts/by-email", status_code=status.HTTP_204_NO_CONTENT) +async def add_contact_by_email_endpoint( + payload: ContactByEmailRequest, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +) -> None: + target = await get_user_by_email(db, payload.email) + if not target: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found") + if target.id == current_user.id: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Cannot add yourself") + is_blocked = await has_block_relation_between_users(db, user_a_id=current_user.id, user_b_id=target.id) + if is_blocked: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Cannot add contact while blocked") + await add_contact(db, user_id=current_user.id, contact_user_id=target.id) + + @router.delete("/{user_id}/contacts", status_code=status.HTTP_204_NO_CONTENT) async def remove_contact_endpoint( user_id: int, diff --git a/app/users/schemas.py b/app/users/schemas.py index 8e18a7c..c3ef7c3 100644 --- a/app/users/schemas.py +++ b/app/users/schemas.py @@ -40,4 +40,9 @@ class UserSearchRead(BaseModel): id: int name: str username: str + email: EmailStr avatar_url: str | None = None + + +class ContactByEmailRequest(BaseModel): + email: EmailStr diff --git a/web/src/api/users.ts b/web/src/api/users.ts index e102ac0..05414d3 100644 --- a/web/src/api/users.ts +++ b/web/src/api/users.ts @@ -48,6 +48,10 @@ export async function addContact(userId: number): Promise { await http.post(`/users/${userId}/contacts`); } +export async function addContactByEmail(email: string): Promise { + await http.post("/users/contacts/by-email", { email }); +} + export async function removeContact(userId: number): Promise { await http.delete(`/users/${userId}/contacts`); } diff --git a/web/src/chat/types.ts b/web/src/chat/types.ts index 0c6854f..f2cdd92 100644 --- a/web/src/chat/types.ts +++ b/web/src/chat/types.ts @@ -100,6 +100,7 @@ export interface UserSearchItem { id: number; name: string; username: string; + email?: string; avatar_url: string | null; } diff --git a/web/src/components/ChatList.tsx b/web/src/components/ChatList.tsx index 2b0bcfd..b3132a6 100644 --- a/web/src/components/ChatList.tsx +++ b/web/src/components/ChatList.tsx @@ -3,7 +3,7 @@ import { createPortal } from "react-dom"; import { archiveChat, clearChat, createPrivateChat, deleteChat, getChats, getSavedMessagesChat, joinChat, pinChat, unarchiveChat, unpinChat } from "../api/chats"; import { globalSearch } from "../api/search"; import type { DiscoverChat, Message, UserSearchItem } from "../chat/types"; -import { addContact, listContacts, removeContact, updateMyProfile } from "../api/users"; +import { addContact, addContactByEmail, listContacts, removeContact, updateMyProfile } from "../api/users"; import { useAuthStore } from "../store/authStore"; import { useChatStore } from "../store/chatStore"; import { NewChatPanel } from "./NewChatPanel"; @@ -27,6 +27,8 @@ export function ChatList() { const [archivedLoading, setArchivedLoading] = useState(false); const [contactsLoading, setContactsLoading] = useState(false); const [contacts, setContacts] = useState([]); + const [contactEmail, setContactEmail] = useState(""); + const [contactEmailError, setContactEmailError] = useState(null); const [ctxChatId, setCtxChatId] = useState(null); const [ctxPos, setCtxPos] = useState<{ x: number; y: number } | null>(null); const [deleteModalChatId, setDeleteModalChatId] = useState(null); @@ -331,7 +333,7 @@ export function ChatList() { type="button" >

{user.name}

-

@{user.username}

+

{user.email || `@${user.username}`}

+ + {contactEmailError ?

{contactEmailError}

: null} + + ) : null} {tab === "contacts" && !contactsLoading && contacts.length === 0 ? (

No contacts yet

) : null} @@ -451,7 +490,7 @@ export function ChatList() { type="button" >

{user.name}

-

@{user.username}

+

{user.email || `@${user.username}`}