From 76ab9c72f5657d98c5871d9816cda20ab8c8f4a0 Mon Sep 17 00:00:00 2001 From: benya Date: Sun, 8 Mar 2026 02:56:58 +0300 Subject: [PATCH] feat(privacy): add private-message permission toggle --- .../0012_user_private_message_privacy.py | 28 +++++++++++++++++++ app/chats/service.py | 6 ++++ app/messages/service.py | 5 ++++ app/users/models.py | 1 + app/users/router.py | 1 + app/users/schemas.py | 2 ++ app/users/service.py | 3 ++ web/src/api/users.ts | 1 + web/src/chat/types.ts | 1 + web/src/components/ChatList.tsx | 13 ++++++++- web/tsconfig.tsbuildinfo | 2 +- 11 files changed, 61 insertions(+), 2 deletions(-) create mode 100644 alembic/versions/0012_user_private_message_privacy.py diff --git a/alembic/versions/0012_user_private_message_privacy.py b/alembic/versions/0012_user_private_message_privacy.py new file mode 100644 index 0000000..a7d29d8 --- /dev/null +++ b/alembic/versions/0012_user_private_message_privacy.py @@ -0,0 +1,28 @@ +"""add allow_private_messages setting + +Revision ID: 0012_user_private_message_privacy +Revises: 0011_chat_public_id +Create Date: 2026-03-08 16:00:00.000000 +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = "0012_user_private_message_privacy" +down_revision: Union[str, Sequence[str], None] = "0011_chat_public_id" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column( + "users", + sa.Column("allow_private_messages", sa.Boolean(), nullable=False, server_default=sa.text("true")), + ) + + +def downgrade() -> None: + op.drop_column("users", "allow_private_messages") diff --git a/app/chats/service.py b/app/chats/service.py index 0cbdb4f..bdcf2c0 100644 --- a/app/chats/service.py +++ b/app/chats/service.py @@ -104,6 +104,12 @@ 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: + target_user = await get_user_by_id(db, member_ids[0]) + if target_user and not target_user.allow_private_messages: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="User does not accept private messages", + ) 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, diff --git a/app/messages/service.py b/app/messages/service.py index c1dc49c..6542223 100644 --- a/app/messages/service.py +++ b/app/messages/service.py @@ -11,6 +11,7 @@ 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 +from app.users.service import get_user_by_id async def create_chat_message(db: AsyncSession, *, sender_id: int, payload: MessageCreateRequest) -> Message: @@ -27,6 +28,10 @@ async def create_chat_message(db: AsyncSession, *, sender_id: int, payload: Mess 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 counterpart_id: + counterpart = await get_user_by_id(db, counterpart_id) + if counterpart and not counterpart.allow_private_messages: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User does not accept private messages") 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 51ecc74..55addd0 100644 --- a/app/users/models.py +++ b/app/users/models.py @@ -23,6 +23,7 @@ class User(Base): avatar_url: Mapped[str | None] = mapped_column(String(512), nullable=True) bio: Mapped[str | None] = mapped_column(String(500), nullable=True) email_verified: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False, index=True) + allow_private_messages: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False, server_default="true") created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False) updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), diff --git a/app/users/router.py b/app/users/router.py index ccd9179..a8a0e5a 100644 --- a/app/users/router.py +++ b/app/users/router.py @@ -59,6 +59,7 @@ async def update_profile( username=payload.username, bio=payload.bio, avatar_url=payload.avatar_url, + allow_private_messages=payload.allow_private_messages, ) return updated diff --git a/app/users/schemas.py b/app/users/schemas.py index dba125b..e3fc2c9 100644 --- a/app/users/schemas.py +++ b/app/users/schemas.py @@ -20,6 +20,7 @@ class UserRead(UserBase): avatar_url: str | None = None bio: str | None = None email_verified: bool + allow_private_messages: bool created_at: datetime updated_at: datetime @@ -29,6 +30,7 @@ class UserProfileUpdate(BaseModel): username: str | None = Field(default=None, min_length=3, max_length=50) bio: str | None = Field(default=None, max_length=500) avatar_url: str | None = Field(default=None, max_length=512) + allow_private_messages: bool | None = None class UserSearchRead(BaseModel): diff --git a/app/users/service.py b/app/users/service.py index 5f160fa..fcf9cd4 100644 --- a/app/users/service.py +++ b/app/users/service.py @@ -40,6 +40,7 @@ async def update_user_profile( username: str | None = None, bio: str | None = None, avatar_url: str | None = None, + allow_private_messages: bool | None = None, ) -> User: if name is not None: user.name = name @@ -49,6 +50,8 @@ async def update_user_profile( user.bio = bio if avatar_url is not None: user.avatar_url = avatar_url + if allow_private_messages is not None: + user.allow_private_messages = allow_private_messages await db.commit() await db.refresh(user) return user diff --git a/web/src/api/users.ts b/web/src/api/users.ts index 44f0a93..e659557 100644 --- a/web/src/api/users.ts +++ b/web/src/api/users.ts @@ -13,6 +13,7 @@ interface UserProfileUpdatePayload { username?: string; bio?: string | null; avatar_url?: string | null; + allow_private_messages?: boolean; } export async function updateMyProfile(payload: UserProfileUpdatePayload): Promise { diff --git a/web/src/chat/types.ts b/web/src/chat/types.ts index 0d270d8..30a5283 100644 --- a/web/src/chat/types.ts +++ b/web/src/chat/types.ts @@ -66,6 +66,7 @@ export interface AuthUser { bio?: string | null; avatar_url: string | null; email_verified: boolean; + allow_private_messages: boolean; created_at: string; updated_at: string; } diff --git a/web/src/components/ChatList.tsx b/web/src/components/ChatList.tsx index f97f6b0..7f4499f 100644 --- a/web/src/components/ChatList.tsx +++ b/web/src/components/ChatList.tsx @@ -29,6 +29,7 @@ export function ChatList() { const [profileUsername, setProfileUsername] = useState(""); const [profileBio, setProfileBio] = useState(""); const [profileAvatarUrl, setProfileAvatarUrl] = useState(""); + const [profileAllowPrivateMessages, setProfileAllowPrivateMessages] = useState(true); const [profileError, setProfileError] = useState(null); const [profileSaving, setProfileSaving] = useState(false); const deleteModalChat = chats.find((chat) => chat.id === deleteModalChatId) ?? null; @@ -101,6 +102,7 @@ export function ChatList() { setProfileUsername(me.username || ""); setProfileBio(me.bio || ""); setProfileAvatarUrl(me.avatar_url || ""); + setProfileAllowPrivateMessages(me.allow_private_messages ?? true); }, [me]); const filteredChats = chats.filter((chat) => { @@ -323,6 +325,14 @@ export function ChatList() { setProfileUsername(e.target.value.replace("@", ""))} /> setProfileBio(e.target.value)} /> setProfileAvatarUrl(e.target.value)} /> + {profileError ?

{profileError}

: null}
@@ -337,7 +347,8 @@ export function ChatList() { name: profileName.trim() || undefined, username: profileUsername.trim() || undefined, bio: profileBio.trim() || null, - avatar_url: profileAvatarUrl.trim() || null + avatar_url: profileAvatarUrl.trim() || null, + allow_private_messages: profileAllowPrivateMessages }); useAuthStore.setState({ me: updated }); await loadChats(search.trim() ? search : undefined); diff --git a/web/tsconfig.tsbuildinfo b/web/tsconfig.tsbuildinfo index 3fb60f4..461625a 100644 --- a/web/tsconfig.tsbuildinfo +++ b/web/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/main.tsx","./src/vite-env.d.ts","./src/api/auth.ts","./src/api/chats.ts","./src/api/http.ts","./src/api/users.ts","./src/app/app.tsx","./src/chat/types.ts","./src/components/apperrorboundary.tsx","./src/components/authpanel.tsx","./src/components/chatinfopanel.tsx","./src/components/chatlist.tsx","./src/components/messagecomposer.tsx","./src/components/messagelist.tsx","./src/components/newchatpanel.tsx","./src/hooks/userealtime.ts","./src/pages/authpage.tsx","./src/pages/chatspage.tsx","./src/store/authstore.ts","./src/store/chatstore.ts","./src/utils/format.ts","./src/utils/ws.ts"],"version":"5.9.2"} \ No newline at end of file +{"root":["./src/main.tsx","./src/vite-env.d.ts","./src/api/auth.ts","./src/api/chats.ts","./src/api/http.ts","./src/api/notifications.ts","./src/api/users.ts","./src/app/app.tsx","./src/chat/types.ts","./src/components/apperrorboundary.tsx","./src/components/authpanel.tsx","./src/components/chatinfopanel.tsx","./src/components/chatlist.tsx","./src/components/messagecomposer.tsx","./src/components/messagelist.tsx","./src/components/newchatpanel.tsx","./src/hooks/userealtime.ts","./src/pages/authpage.tsx","./src/pages/chatspage.tsx","./src/store/authstore.ts","./src/store/chatstore.ts","./src/utils/format.ts","./src/utils/ws.ts"],"version":"5.9.2"} \ No newline at end of file