feat(contacts): add contacts module with backend APIs and web tab
Some checks failed
CI / test (push) Failing after 18s

- add user_contacts table and migration

- expose /users/contacts list/add/remove endpoints

- add Contacts tab in chat list with add/remove actions
This commit is contained in:
2026-03-08 11:38:11 +03:00
parent 39b61ec94b
commit da73b79ee7
8 changed files with 261 additions and 26 deletions

View File

@@ -0,0 +1,42 @@
"""add user contacts table
Revision ID: 0017_user_contacts
Revises: 0016_chat_invites
Create Date: 2026-03-08 23:10:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "0017_user_contacts"
down_revision: Union[str, Sequence[str], None] = "0016_chat_invites"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"user_contacts",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("user_id", sa.Integer(), nullable=False),
sa.Column("contact_user_id", sa.Integer(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], name=op.f("fk_user_contacts_user_id_users"), ondelete="CASCADE"),
sa.ForeignKeyConstraint(["contact_user_id"], ["users.id"], name=op.f("fk_user_contacts_contact_user_id_users"), ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id", name=op.f("pk_user_contacts")),
sa.UniqueConstraint("user_id", "contact_user_id", name="uq_user_contacts_pair"),
)
op.create_index(op.f("ix_user_contacts_id"), "user_contacts", ["id"], unique=False)
op.create_index(op.f("ix_user_contacts_user_id"), "user_contacts", ["user_id"], unique=False)
op.create_index(op.f("ix_user_contacts_contact_user_id"), "user_contacts", ["contact_user_id"], unique=False)
def downgrade() -> None:
op.drop_index(op.f("ix_user_contacts_contact_user_id"), table_name="user_contacts")
op.drop_index(op.f("ix_user_contacts_user_id"), table_name="user_contacts")
op.drop_index(op.f("ix_user_contacts_id"), table_name="user_contacts")
op.drop_table("user_contacts")

View File

@@ -4,7 +4,7 @@ from app.email.models import EmailLog
from app.media.models import Attachment from app.media.models import Attachment
from app.messages.models import Message, MessageHidden, MessageIdempotencyKey, MessageReaction, MessageReceipt from app.messages.models import Message, MessageHidden, MessageIdempotencyKey, MessageReaction, MessageReceipt
from app.notifications.models import NotificationLog from app.notifications.models import NotificationLog
from app.users.models import User from app.users.models import User, UserContact
__all__ = [ __all__ = [
"Attachment", "Attachment",
@@ -21,4 +21,5 @@ __all__ = [
"NotificationLog", "NotificationLog",
"PasswordResetToken", "PasswordResetToken",
"User", "User",
"UserContact",
] ]

View File

@@ -53,3 +53,13 @@ class BlockedUser(Base):
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True) user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
blocked_user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True) blocked_user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
class UserContact(Base):
__tablename__ = "user_contacts"
__table_args__ = (UniqueConstraint("user_id", "contact_user_id", name="uq_user_contacts_pair"),)
id: Mapped[int] = mapped_column(primary_key=True, index=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
contact_user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)

View File

@@ -3,7 +3,7 @@ from datetime import datetime, timezone
from sqlalchemy import and_, func, or_, select from sqlalchemy import and_, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.users.models import BlockedUser, User from app.users.models import BlockedUser, User, UserContact
async def create_user(db: AsyncSession, *, email: str, name: str, username: str, password_hash: str) -> User: async def create_user(db: AsyncSession, *, email: str, name: str, username: str, password_hash: str) -> User:
@@ -107,3 +107,40 @@ async def list_blocked_users(db: AsyncSession, *, user_id: int) -> list[User]:
) )
result = await db.execute(stmt) result = await db.execute(stmt)
return list(result.scalars().all()) return list(result.scalars().all())
async def add_contact(db: AsyncSession, *, user_id: int, contact_user_id: int) -> UserContact:
existing = await get_contact_relation(db, user_id=user_id, contact_user_id=contact_user_id)
if existing:
return existing
relation = UserContact(user_id=user_id, contact_user_id=contact_user_id)
db.add(relation)
await db.flush()
return relation
async def remove_contact(db: AsyncSession, *, user_id: int, contact_user_id: int) -> None:
relation = await get_contact_relation(db, user_id=user_id, contact_user_id=contact_user_id)
if relation:
await db.delete(relation)
async def get_contact_relation(db: AsyncSession, *, user_id: int, contact_user_id: int) -> UserContact | None:
result = await db.execute(
select(UserContact).where(
UserContact.user_id == user_id,
UserContact.contact_user_id == contact_user_id,
)
)
return result.scalar_one_or_none()
async def list_contacts(db: AsyncSession, *, user_id: int) -> list[User]:
stmt = (
select(User)
.join(UserContact, UserContact.contact_user_id == User.id)
.where(UserContact.user_id == user_id)
.order_by(User.username.asc())
)
result = await db.execute(stmt)
return list(result.scalars().all())

View File

@@ -6,10 +6,14 @@ from app.database.session import get_db
from app.users.models import User from app.users.models import User
from app.users.schemas import UserProfileUpdate, UserRead, UserSearchRead from app.users.schemas import UserProfileUpdate, UserRead, UserSearchRead
from app.users.service import ( from app.users.service import (
add_contact,
block_user, block_user,
get_user_by_id, get_user_by_id,
get_user_by_username, get_user_by_username,
list_blocked_users, list_blocked_users,
list_contacts,
has_block_relation_between_users,
remove_contact,
search_users_by_username, search_users_by_username,
unblock_user, unblock_user,
update_user_profile, update_user_profile,
@@ -72,6 +76,42 @@ async def read_blocked_users(
return await list_blocked_users(db, user_id=current_user.id) return await list_blocked_users(db, user_id=current_user.id)
@router.get("/contacts", response_model=list[UserSearchRead])
async def read_contacts(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> list[UserSearchRead]:
return await list_contacts(db, user_id=current_user.id)
@router.post("/{user_id}/contacts", status_code=status.HTTP_204_NO_CONTENT)
async def add_contact_endpoint(
user_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> None:
if user_id == current_user.id:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Cannot add yourself")
target = await get_user_by_id(db, user_id)
if not target:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
is_blocked = await has_block_relation_between_users(db, user_a_id=current_user.id, user_b_id=user_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=user_id)
@router.delete("/{user_id}/contacts", status_code=status.HTTP_204_NO_CONTENT)
async def remove_contact_endpoint(
user_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> None:
if user_id == current_user.id:
return
await remove_contact(db, user_id=current_user.id, contact_user_id=user_id)
@router.post("/{user_id}/block", status_code=status.HTTP_204_NO_CONTENT) @router.post("/{user_id}/block", status_code=status.HTTP_204_NO_CONTENT)
async def block_user_endpoint( async def block_user_endpoint(
user_id: int, user_id: int,

View File

@@ -73,3 +73,17 @@ async def has_block_relation_between_users(db: AsyncSession, *, user_a_id: int,
async def list_blocked_users(db: AsyncSession, *, user_id: int) -> list[User]: async def list_blocked_users(db: AsyncSession, *, user_id: int) -> list[User]:
return await repository.list_blocked_users(db, user_id=user_id) return await repository.list_blocked_users(db, user_id=user_id)
async def add_contact(db: AsyncSession, *, user_id: int, contact_user_id: int) -> None:
await repository.add_contact(db, user_id=user_id, contact_user_id=contact_user_id)
await db.commit()
async def remove_contact(db: AsyncSession, *, user_id: int, contact_user_id: int) -> None:
await repository.remove_contact(db, user_id=user_id, contact_user_id=contact_user_id)
await db.commit()
async def list_contacts(db: AsyncSession, *, user_id: int) -> list[User]:
return await repository.list_contacts(db, user_id=user_id)

View File

@@ -38,3 +38,16 @@ export async function blockUser(userId: number): Promise<void> {
export async function unblockUser(userId: number): Promise<void> { export async function unblockUser(userId: number): Promise<void> {
await http.delete(`/users/${userId}/block`); await http.delete(`/users/${userId}/block`);
} }
export async function listContacts(): Promise<UserSearchItem[]> {
const { data } = await http.get<UserSearchItem[]>("/users/contacts");
return data;
}
export async function addContact(userId: number): Promise<void> {
await http.post(`/users/${userId}/contacts`);
}
export async function removeContact(userId: number): Promise<void> {
await http.delete(`/users/${userId}/contacts`);
}

View File

@@ -3,7 +3,7 @@ import { createPortal } from "react-dom";
import { archiveChat, clearChat, createPrivateChat, deleteChat, getChats, getSavedMessagesChat, joinChat, pinChat, unarchiveChat, unpinChat } from "../api/chats"; import { archiveChat, clearChat, createPrivateChat, deleteChat, getChats, getSavedMessagesChat, joinChat, pinChat, unarchiveChat, unpinChat } from "../api/chats";
import { globalSearch } from "../api/search"; import { globalSearch } from "../api/search";
import type { DiscoverChat, Message, UserSearchItem } from "../chat/types"; import type { DiscoverChat, Message, UserSearchItem } from "../chat/types";
import { updateMyProfile } from "../api/users"; import { addContact, listContacts, removeContact, updateMyProfile } from "../api/users";
import { useAuthStore } from "../store/authStore"; import { useAuthStore } from "../store/authStore";
import { useChatStore } from "../store/chatStore"; import { useChatStore } from "../store/chatStore";
import { NewChatPanel } from "./NewChatPanel"; import { NewChatPanel } from "./NewChatPanel";
@@ -23,8 +23,10 @@ export function ChatList() {
const [messageResults, setMessageResults] = useState<Message[]>([]); const [messageResults, setMessageResults] = useState<Message[]>([]);
const [archivedChats, setArchivedChats] = useState<typeof chats>([]); const [archivedChats, setArchivedChats] = useState<typeof chats>([]);
const [searchLoading, setSearchLoading] = useState(false); const [searchLoading, setSearchLoading] = useState(false);
const [tab, setTab] = useState<"all" | "people" | "groups" | "channels" | "archived">("all"); const [tab, setTab] = useState<"all" | "contacts" | "people" | "groups" | "channels" | "archived">("all");
const [archivedLoading, setArchivedLoading] = useState(false); const [archivedLoading, setArchivedLoading] = useState(false);
const [contactsLoading, setContactsLoading] = useState(false);
const [contacts, setContacts] = useState<UserSearchItem[]>([]);
const [ctxChatId, setCtxChatId] = useState<number | null>(null); const [ctxChatId, setCtxChatId] = useState<number | null>(null);
const [ctxPos, setCtxPos] = useState<{ x: number; y: number } | null>(null); const [ctxPos, setCtxPos] = useState<{ x: number; y: number } | null>(null);
const [deleteModalChatId, setDeleteModalChatId] = useState<number | null>(null); const [deleteModalChatId, setDeleteModalChatId] = useState<number | null>(null);
@@ -74,6 +76,33 @@ export function ChatList() {
}; };
}, [tab, chats.length]); }, [tab, chats.length]);
useEffect(() => {
if (tab !== "contacts") {
return;
}
let cancelled = false;
setContactsLoading(true);
void (async () => {
try {
const rows = await listContacts();
if (!cancelled) {
setContacts(rows);
}
} catch {
if (!cancelled) {
setContacts([]);
}
} finally {
if (!cancelled) {
setContactsLoading(false);
}
}
})();
return () => {
cancelled = true;
};
}, [tab]);
useEffect(() => { useEffect(() => {
const term = search.trim(); const term = search.trim();
if (term.replace("@", "").length < 2) { if (term.replace("@", "").length < 2) {
@@ -162,8 +191,9 @@ export function ChatList() {
return true; return true;
}); });
const tabs: Array<{ id: "all" | "people" | "groups" | "channels" | "archived"; label: string }> = [ const tabs: Array<{ id: "all" | "contacts" | "people" | "groups" | "channels" | "archived"; label: string }> = [
{ id: "all", label: "All" }, { id: "all", label: "All" },
{ id: "contacts", label: "Contacts" },
{ id: "people", label: "Люди" }, { id: "people", label: "Люди" },
{ id: "groups", label: "Groups" }, { id: "groups", label: "Groups" },
{ id: "channels", label: "Каналы" }, { id: "channels", label: "Каналы" },
@@ -242,7 +272,7 @@ export function ChatList() {
<button className="block w-full rounded px-3 py-2 text-left text-sm hover:bg-slate-800" onClick={() => { void openSavedMessages(); setMenuOpen(false); }}> <button className="block w-full rounded px-3 py-2 text-left text-sm hover:bg-slate-800" onClick={() => { void openSavedMessages(); setMenuOpen(false); }}>
Saved Messages Saved Messages
</button> </button>
<button className="block w-full rounded px-3 py-2 text-left text-sm hover:bg-slate-800" onClick={() => { setTab("people"); setMenuOpen(false); }}> <button className="block w-full rounded px-3 py-2 text-left text-sm hover:bg-slate-800" onClick={() => { setTab("contacts"); setMenuOpen(false); }}>
Contacts Contacts
</button> </button>
<button className="block w-full rounded px-3 py-2 text-left text-sm hover:bg-slate-800" onClick={() => { setSettingsOpen(true); setMenuOpen(false); }}> <button className="block w-full rounded px-3 py-2 text-left text-sm hover:bg-slate-800" onClick={() => { setSettingsOpen(true); setMenuOpen(false); }}>
@@ -286,22 +316,36 @@ export function ChatList() {
<div className="mb-1"> <div className="mb-1">
<p className="px-2 py-1 text-[10px] uppercase tracking-wide text-slate-400">People</p> <p className="px-2 py-1 text-[10px] uppercase tracking-wide text-slate-400">People</p>
{userResults.slice(0, 5).map((user) => ( {userResults.slice(0, 5).map((user) => (
<button <div className="flex items-center gap-2 rounded-lg px-2 py-1.5 hover:bg-slate-800" key={`user-${user.id}`}>
className="block w-full rounded-lg px-2 py-1.5 text-left hover:bg-slate-800" <button
key={`user-${user.id}`} className="min-w-0 flex-1 text-left"
onClick={async () => { onClick={async () => {
const chat = await createPrivateChat(user.id); const chat = await createPrivateChat(user.id);
const updatedChats = await getChats(); const updatedChats = await getChats();
useChatStore.setState({ chats: updatedChats, activeChatId: chat.id }); useChatStore.setState({ chats: updatedChats, activeChatId: chat.id });
setSearch(""); setSearch("");
setUserResults([]); setUserResults([]);
setDiscoverResults([]); setDiscoverResults([]);
setMessageResults([]); setMessageResults([]);
}} }}
> type="button"
<p className="truncate text-xs font-semibold">{user.name}</p> >
<p className="truncate text-[11px] text-slate-400">@{user.username}</p> <p className="truncate text-xs font-semibold">{user.name}</p>
</button> <p className="truncate text-[11px] text-slate-400">@{user.username}</p>
</button>
<button
className="rounded bg-slate-700 px-2 py-1 text-[10px] hover:bg-slate-600"
onClick={async () => {
await addContact(user.id);
if (tab === "contacts") {
setContacts(await listContacts());
}
}}
type="button"
>
Add
</button>
</div>
))} ))}
</div> </div>
) : null} ) : null}
@@ -388,15 +432,49 @@ export function ChatList() {
{tab === "archived" && !archivedLoading && archivedChats.length === 0 ? ( {tab === "archived" && !archivedLoading && archivedChats.length === 0 ? (
<p className="px-4 py-3 text-xs text-slate-400">Archive is empty</p> <p className="px-4 py-3 text-xs text-slate-400">Archive is empty</p>
) : null} ) : null}
{tab === "contacts" && contactsLoading ? (
<p className="px-4 py-3 text-xs text-slate-400">Loading contacts...</p>
) : null}
{tab === "contacts" && !contactsLoading && contacts.length === 0 ? (
<p className="px-4 py-3 text-xs text-slate-400">No contacts yet</p>
) : null}
{tab === "contacts"
? contacts.map((user) => (
<div className="flex items-center gap-2 border-b border-slate-800/60 px-4 py-3" key={`contact-${user.id}`}>
<button
className="min-w-0 flex-1 text-left"
onClick={async () => {
const chat = await createPrivateChat(user.id);
const updatedChats = await getChats();
useChatStore.setState({ chats: updatedChats, activeChatId: chat.id });
}}
type="button"
>
<p className="truncate text-sm font-semibold">{user.name}</p>
<p className="truncate text-xs text-slate-400">@{user.username}</p>
</button>
<button
className="rounded bg-slate-700 px-2 py-1 text-[10px] text-red-200 hover:bg-slate-600"
onClick={async () => {
await removeContact(user.id);
setContacts((prev) => prev.filter((item) => item.id !== user.id));
}}
type="button"
>
Remove
</button>
</div>
))
: null}
{pinnedVisibleChats.length > 0 ? ( {tab !== "contacts" && pinnedVisibleChats.length > 0 ? (
<p className="px-4 py-2 text-[10px] uppercase tracking-wide text-slate-400">Pinned</p> <p className="px-4 py-2 text-[10px] uppercase tracking-wide text-slate-400">Pinned</p>
) : null} ) : null}
{pinnedVisibleChats.map(renderChatRow)} {tab !== "contacts" ? pinnedVisibleChats.map(renderChatRow) : null}
{regularVisibleChats.length > 0 ? ( {tab !== "contacts" && regularVisibleChats.length > 0 ? (
<p className="px-4 py-2 text-[10px] uppercase tracking-wide text-slate-400">Chats</p> <p className="px-4 py-2 text-[10px] uppercase tracking-wide text-slate-400">Chats</p>
) : null} ) : null}
{regularVisibleChats.map(renderChatRow)} {tab !== "contacts" ? regularVisibleChats.map(renderChatRow) : null}
</div> </div>
<NewChatPanel /> <NewChatPanel />