diff --git a/alembic/versions/0010_blocked_users.py b/alembic/versions/0010_blocked_users.py new file mode 100644 index 0000000..48c23a3 --- /dev/null +++ b/alembic/versions/0010_blocked_users.py @@ -0,0 +1,46 @@ +"""add blocked users table + +Revision ID: 0010_blocked_users +Revises: 0009_chat_notification_settings +Create Date: 2026-03-08 14:25:00.000000 +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = "0010_blocked_users" +down_revision: Union[str, Sequence[str], None] = "0009_chat_notification_settings" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "blocked_users", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("user_id", sa.Integer(), nullable=False), + sa.Column("blocked_user_id", sa.Integer(), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + sa.ForeignKeyConstraint(["user_id"], ["users.id"], name=op.f("fk_blocked_users_user_id_users"), ondelete="CASCADE"), + sa.ForeignKeyConstraint( + ["blocked_user_id"], + ["users.id"], + name=op.f("fk_blocked_users_blocked_user_id_users"), + ondelete="CASCADE", + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_blocked_users")), + sa.UniqueConstraint("user_id", "blocked_user_id", name="uq_blocked_users_user_target"), + ) + op.create_index(op.f("ix_blocked_users_id"), "blocked_users", ["id"], unique=False) + op.create_index(op.f("ix_blocked_users_user_id"), "blocked_users", ["user_id"], unique=False) + op.create_index(op.f("ix_blocked_users_blocked_user_id"), "blocked_users", ["blocked_user_id"], unique=False) + + +def downgrade() -> None: + op.drop_index(op.f("ix_blocked_users_blocked_user_id"), table_name="blocked_users") + op.drop_index(op.f("ix_blocked_users_user_id"), table_name="blocked_users") + op.drop_index(op.f("ix_blocked_users_id"), table_name="blocked_users") + op.drop_table("blocked_users") diff --git a/app/chats/service.py b/app/chats/service.py index 7d55788..59f5515 100644 --- a/app/chats/service.py +++ b/app/chats/service.py @@ -22,7 +22,7 @@ from app.messages.repository import ( list_chat_message_ids, ) from app.realtime.presence import get_users_online_map -from app.users.repository import get_user_by_id +from app.users.repository import get_user_by_id, has_block_relation_between_users async def serialize_chat_for_user(db: AsyncSession, *, user_id: int, chat: Chat) -> ChatRead: @@ -103,6 +103,11 @@ async def create_chat_for_user(db: AsyncSession, *, creator_id: int, payload: Ch detail="Private chat requires exactly one target user.", ) if payload.type == ChatType.PRIVATE: + if await has_block_relation_between_users(db, user_a_id=creator_id, user_b_id=member_ids[0]): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Cannot create private chat due to block settings", + ) existing_chat = await repository.find_private_chat_between_users( db, user_a_id=creator_id, diff --git a/app/messages/service.py b/app/messages/service.py index ff2e28e..c1dc49c 100644 --- a/app/messages/service.py +++ b/app/messages/service.py @@ -10,6 +10,7 @@ from app.messages.models import Message from app.messages.spam_guard import enforce_message_spam_policy from app.messages.schemas import MessageCreateRequest, MessageForwardRequest, MessageStatusUpdateRequest, MessageUpdateRequest from app.notifications.service import dispatch_message_notifications +from app.users.repository import has_block_relation_between_users async def create_chat_message(db: AsyncSession, *, sender_id: int, payload: MessageCreateRequest) -> Message: @@ -22,6 +23,10 @@ async def create_chat_message(db: AsyncSession, *, sender_id: int, payload: Mess raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="You are not a member of this chat") if chat.type == ChatType.CHANNEL and membership.role == ChatMemberRole.MEMBER: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Only admins can post in channels") + if chat.type == ChatType.PRIVATE: + counterpart_id = await chats_repository.get_private_counterpart_user_id(db, chat_id=payload.chat_id, user_id=sender_id) + if counterpart_id and await has_block_relation_between_users(db, user_a_id=sender_id, user_b_id=counterpart_id): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Cannot send message due to block settings") if payload.reply_to_message_id is not None: reply_to = await repository.get_message_by_id(db, payload.reply_to_message_id) if not reply_to or reply_to.chat_id != payload.chat_id: diff --git a/app/users/models.py b/app/users/models.py index 53806ac..51ecc74 100644 --- a/app/users/models.py +++ b/app/users/models.py @@ -1,7 +1,7 @@ from datetime import datetime from typing import TYPE_CHECKING -from sqlalchemy import Boolean, DateTime, String, func +from sqlalchemy import Boolean, DateTime, ForeignKey, String, UniqueConstraint, func from sqlalchemy.orm import Mapped, mapped_column, relationship from app.database.base import Base @@ -42,3 +42,13 @@ class User(Base): back_populates="user", cascade="all, delete-orphan", ) + + +class BlockedUser(Base): + __tablename__ = "blocked_users" + __table_args__ = (UniqueConstraint("user_id", "blocked_user_id", name="uq_blocked_users_user_target"),) + + 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) + 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) diff --git a/app/users/repository.py b/app/users/repository.py index 0a9329c..9d1c562 100644 --- a/app/users/repository.py +++ b/app/users/repository.py @@ -1,9 +1,9 @@ from datetime import datetime, timezone -from sqlalchemy import func, select +from sqlalchemy import and_, func, or_, select from sqlalchemy.ext.asyncio import AsyncSession -from app.users.models import User +from app.users.models import BlockedUser, User async def create_user(db: AsyncSession, *, email: str, name: str, username: str, password_hash: str) -> User: @@ -58,3 +58,52 @@ async def update_user_last_seen_now(db: AsyncSession, *, user_id: int) -> User | user.last_seen_at = datetime.now(timezone.utc) await db.flush() return user + + +async def block_user(db: AsyncSession, *, user_id: int, blocked_user_id: int) -> BlockedUser: + existing = await get_block_relation(db, user_id=user_id, blocked_user_id=blocked_user_id) + if existing: + return existing + relation = BlockedUser(user_id=user_id, blocked_user_id=blocked_user_id) + db.add(relation) + await db.flush() + return relation + + +async def unblock_user(db: AsyncSession, *, user_id: int, blocked_user_id: int) -> None: + relation = await get_block_relation(db, user_id=user_id, blocked_user_id=blocked_user_id) + if relation: + await db.delete(relation) + + +async def get_block_relation(db: AsyncSession, *, user_id: int, blocked_user_id: int) -> BlockedUser | None: + result = await db.execute( + select(BlockedUser).where( + BlockedUser.user_id == user_id, + BlockedUser.blocked_user_id == blocked_user_id, + ) + ) + return result.scalar_one_or_none() + + +async def has_block_relation_between_users(db: AsyncSession, *, user_a_id: int, user_b_id: int) -> bool: + result = await db.execute( + select(BlockedUser.id).where( + or_( + and_(BlockedUser.user_id == user_a_id, BlockedUser.blocked_user_id == user_b_id), + and_(BlockedUser.user_id == user_b_id, BlockedUser.blocked_user_id == user_a_id), + ) + ).limit(1) + ) + return result.scalar_one_or_none() is not None + + +async def list_blocked_users(db: AsyncSession, *, user_id: int) -> list[User]: + stmt = ( + select(User) + .join(BlockedUser, BlockedUser.blocked_user_id == User.id) + .where(BlockedUser.user_id == user_id) + .order_by(User.username.asc()) + ) + result = await db.execute(stmt) + return list(result.scalars().all()) diff --git a/app/users/router.py b/app/users/router.py index c3a77b9..0fef359 100644 --- a/app/users/router.py +++ b/app/users/router.py @@ -5,7 +5,15 @@ 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.service import get_user_by_id, get_user_by_username, search_users_by_username, update_user_profile +from app.users.service import ( + block_user, + get_user_by_id, + get_user_by_username, + list_blocked_users, + search_users_by_username, + unblock_user, + update_user_profile, +) router = APIRouter(prefix="/users", tags=["users"]) @@ -61,3 +69,36 @@ async def update_profile( avatar_url=payload.avatar_url, ) return updated + + +@router.get("/blocked", response_model=list[UserSearchRead]) +async def read_blocked_users( + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +) -> list[UserSearchRead]: + return await list_blocked_users(db, user_id=current_user.id) + + +@router.post("/{user_id}/block", status_code=status.HTTP_204_NO_CONTENT) +async def block_user_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 block 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") + await block_user(db, user_id=current_user.id, blocked_user_id=user_id) + + +@router.delete("/{user_id}/block", status_code=status.HTTP_204_NO_CONTENT) +async def unblock_user_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 unblock_user(db, user_id=current_user.id, blocked_user_id=user_id) diff --git a/app/users/service.py b/app/users/service.py index 5f9ff30..5f160fa 100644 --- a/app/users/service.py +++ b/app/users/service.py @@ -52,3 +52,21 @@ async def update_user_profile( await db.commit() await db.refresh(user) return user + + +async def block_user(db: AsyncSession, *, user_id: int, blocked_user_id: int) -> None: + await repository.block_user(db, user_id=user_id, blocked_user_id=blocked_user_id) + await db.commit() + + +async def unblock_user(db: AsyncSession, *, user_id: int, blocked_user_id: int) -> None: + await repository.unblock_user(db, user_id=user_id, blocked_user_id=blocked_user_id) + await db.commit() + + +async def has_block_relation_between_users(db: AsyncSession, *, user_a_id: int, user_b_id: int) -> bool: + return await repository.has_block_relation_between_users(db, user_a_id=user_a_id, user_b_id=user_b_id) + + +async def list_blocked_users(db: AsyncSession, *, user_id: int) -> list[User]: + return await repository.list_blocked_users(db, user_id=user_id) diff --git a/web/src/api/users.ts b/web/src/api/users.ts index f3606d1..44f0a93 100644 --- a/web/src/api/users.ts +++ b/web/src/api/users.ts @@ -24,3 +24,16 @@ export async function getUserById(userId: number): Promise { const { data } = await http.get(`/users/${userId}`); return data; } + +export async function listBlockedUsers(): Promise { + const { data } = await http.get("/users/blocked"); + return data; +} + +export async function blockUser(userId: number): Promise { + await http.post(`/users/${userId}/block`); +} + +export async function unblockUser(userId: number): Promise { + await http.delete(`/users/${userId}/block`); +} diff --git a/web/src/components/ChatInfoPanel.tsx b/web/src/components/ChatInfoPanel.tsx index 2aaffb1..0ad7877 100644 --- a/web/src/components/ChatInfoPanel.tsx +++ b/web/src/components/ChatInfoPanel.tsx @@ -11,7 +11,7 @@ import { updateChatMemberRole, updateChatTitle } from "../api/chats"; -import { getUserById, searchUsers } from "../api/users"; +import { blockUser, getUserById, listBlockedUsers, searchUsers, unblockUser } from "../api/users"; import type { AuthUser, ChatDetail, ChatMember, UserSearchItem } from "../chat/types"; import { useAuthStore } from "../store/authStore"; import { useChatStore } from "../store/chatStore"; @@ -37,6 +37,8 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) { const [searchResults, setSearchResults] = useState([]); const [muted, setMuted] = useState(false); const [savingMute, setSavingMute] = useState(false); + const [counterpartBlocked, setCounterpartBlocked] = useState(false); + const [savingBlock, setSavingBlock] = useState(false); const myRole = useMemo(() => members.find((m) => m.user_id === me?.id)?.role, [members, me?.id]); const isGroupLike = chat?.type === "group" || chat?.type === "channel"; @@ -73,6 +75,14 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) { if (!cancelled) { setMuted(notificationSettings.muted); } + if (detail.type === "private" && !detail.is_saved && detail.counterpart_user_id) { + const blocked = await listBlockedUsers(); + if (!cancelled) { + setCounterpartBlocked(blocked.some((u) => u.id === detail.counterpart_user_id)); + } + } else if (!cancelled) { + setCounterpartBlocked(false); + } await refreshMembers(chatId); } catch { if (!cancelled) setError("Failed to load chat info"); @@ -282,6 +292,31 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) { ) : null} + {chat.type === "private" && !chat.is_saved && chat.counterpart_user_id ? ( + + ) : null} + {showMembersSection && (chat.type === "group" || chat.type === "channel") ? (