From ea8a50ee05be9d198f0a2c954818bca875e1fea5 Mon Sep 17 00:00:00 2001 From: benya Date: Sun, 8 Mar 2026 02:17:09 +0300 Subject: [PATCH] feat(notifications): per-chat mute settings - add chat_notification_settings table and migration - add chat notifications API (get/update muted) - skip message notifications for muted recipients - add mute/unmute control in chat info panel --- .../0009_chat_notification_settings.py | 52 +++++++++++++++++++ app/chats/models.py | 16 ++++++ app/chats/repository.py | 37 ++++++++++++- app/chats/router.py | 28 ++++++++++ app/chats/schemas.py | 10 ++++ app/chats/service.py | 49 ++++++++++++++++- app/notifications/service.py | 4 +- web/src/api/chats.ts | 16 ++++++ web/src/components/ChatInfoPanel.tsx | 29 +++++++++++ 9 files changed, 238 insertions(+), 3 deletions(-) create mode 100644 alembic/versions/0009_chat_notification_settings.py diff --git a/alembic/versions/0009_chat_notification_settings.py b/alembic/versions/0009_chat_notification_settings.py new file mode 100644 index 0000000..b6a6e18 --- /dev/null +++ b/alembic/versions/0009_chat_notification_settings.py @@ -0,0 +1,52 @@ +"""add chat notification settings + +Revision ID: 0009_chat_notification_settings +Revises: 0008_user_last_seen_presence +Create Date: 2026-03-08 14:00:00.000000 +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = "0009_chat_notification_settings" +down_revision: Union[str, Sequence[str], None] = "0008_user_last_seen_presence" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "chat_notification_settings", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("chat_id", sa.Integer(), nullable=False), + sa.Column("user_id", sa.Integer(), nullable=False), + sa.Column("muted", sa.Boolean(), nullable=False, server_default=sa.text("false")), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + sa.ForeignKeyConstraint(["chat_id"], ["chats.id"], name=op.f("fk_chat_notification_settings_chat_id_chats"), ondelete="CASCADE"), + sa.ForeignKeyConstraint(["user_id"], ["users.id"], name=op.f("fk_chat_notification_settings_user_id_users"), ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id", name=op.f("pk_chat_notification_settings")), + sa.UniqueConstraint("chat_id", "user_id", name="uq_chat_notification_settings_chat_user"), + ) + op.create_index(op.f("ix_chat_notification_settings_id"), "chat_notification_settings", ["id"], unique=False) + op.create_index( + op.f("ix_chat_notification_settings_chat_id"), + "chat_notification_settings", + ["chat_id"], + unique=False, + ) + op.create_index( + op.f("ix_chat_notification_settings_user_id"), + "chat_notification_settings", + ["user_id"], + unique=False, + ) + + +def downgrade() -> None: + op.drop_index(op.f("ix_chat_notification_settings_user_id"), table_name="chat_notification_settings") + op.drop_index(op.f("ix_chat_notification_settings_chat_id"), table_name="chat_notification_settings") + op.drop_index(op.f("ix_chat_notification_settings_id"), table_name="chat_notification_settings") + op.drop_table("chat_notification_settings") diff --git a/app/chats/models.py b/app/chats/models.py index 8f16f10..2b937f0 100644 --- a/app/chats/models.py +++ b/app/chats/models.py @@ -57,3 +57,19 @@ class ChatMember(Base): chat: Mapped["Chat"] = relationship(back_populates="members") user: Mapped["User"] = relationship(back_populates="memberships") + + +class ChatNotificationSetting(Base): + __tablename__ = "chat_notification_settings" + __table_args__ = (UniqueConstraint("chat_id", "user_id", name="uq_chat_notification_settings_chat_user"),) + + id: Mapped[int] = mapped_column(primary_key=True, index=True) + chat_id: Mapped[int] = mapped_column(ForeignKey("chats.id", ondelete="CASCADE"), nullable=False, index=True) + user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True) + muted: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false") + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + onupdate=func.now(), + nullable=False, + ) diff --git a/app/chats/repository.py b/app/chats/repository.py index 01040b6..789b935 100644 --- a/app/chats/repository.py +++ b/app/chats/repository.py @@ -2,7 +2,7 @@ from sqlalchemy import Select, String, func, or_, select from sqlalchemy.orm import aliased from sqlalchemy.ext.asyncio import AsyncSession -from app.chats.models import Chat, ChatMember, ChatMemberRole, ChatType +from app.chats.models import Chat, ChatMember, ChatMemberRole, ChatNotificationSetting, ChatType from app.messages.models import Message, MessageHidden, MessageReceipt @@ -195,3 +195,38 @@ async def get_unread_count_for_chat(db: AsyncSession, *, chat_id: int, user_id: ) result = await db.execute(stmt) return int(result.scalar_one() or 0) + + +async def get_chat_notification_setting( + db: AsyncSession, *, chat_id: int, user_id: int +) -> ChatNotificationSetting | None: + result = await db.execute( + select(ChatNotificationSetting).where( + ChatNotificationSetting.chat_id == chat_id, + ChatNotificationSetting.user_id == user_id, + ) + ) + return result.scalar_one_or_none() + + +async def upsert_chat_notification_setting( + db: AsyncSession, + *, + chat_id: int, + user_id: int, + muted: bool, +) -> ChatNotificationSetting: + setting = await get_chat_notification_setting(db, chat_id=chat_id, user_id=user_id) + if setting: + setting.muted = muted + await db.flush() + return setting + setting = ChatNotificationSetting(chat_id=chat_id, user_id=user_id, muted=muted) + db.add(setting) + await db.flush() + return setting + + +async def is_chat_muted_for_user(db: AsyncSession, *, chat_id: int, user_id: int) -> bool: + setting = await get_chat_notification_setting(db, chat_id=chat_id, user_id=user_id) + return bool(setting and setting.muted) diff --git a/app/chats/router.py b/app/chats/router.py index f8192ac..6e660c4 100644 --- a/app/chats/router.py +++ b/app/chats/router.py @@ -10,6 +10,8 @@ from app.chats.schemas import ( ChatMemberAddRequest, ChatMemberRead, ChatMemberRoleUpdateRequest, + ChatNotificationSettingsRead, + ChatNotificationSettingsUpdate, ChatPinMessageRequest, ChatRead, ChatTitleUpdateRequest, @@ -22,6 +24,7 @@ from app.chats.service import ( discover_public_chats_for_user, ensure_saved_messages_chat, get_chat_for_user, + get_chat_notification_settings_for_user, get_chats_for_user, join_public_chat_for_user, leave_chat_for_user, @@ -30,6 +33,7 @@ from app.chats.service import ( serialize_chat_for_user, serialize_chats_for_user, update_chat_member_role_for_user, + update_chat_notification_settings_for_user, update_chat_title_for_user, ) from app.database.session import get_db @@ -200,3 +204,27 @@ async def pin_chat_message( ) -> ChatRead: chat = await pin_chat_message_for_user(db, chat_id=chat_id, user_id=current_user.id, payload=payload) return await serialize_chat_for_user(db, user_id=current_user.id, chat=chat) + + +@router.get("/{chat_id}/notifications", response_model=ChatNotificationSettingsRead) +async def get_chat_notifications( + chat_id: int, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +) -> ChatNotificationSettingsRead: + return await get_chat_notification_settings_for_user(db, chat_id=chat_id, user_id=current_user.id) + + +@router.put("/{chat_id}/notifications", response_model=ChatNotificationSettingsRead) +async def update_chat_notifications( + chat_id: int, + payload: ChatNotificationSettingsUpdate, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +) -> ChatNotificationSettingsRead: + return await update_chat_notification_settings_for_user( + db, + chat_id=chat_id, + user_id=current_user.id, + payload=payload, + ) diff --git a/app/chats/schemas.py b/app/chats/schemas.py index 4e90326..4d3ea77 100644 --- a/app/chats/schemas.py +++ b/app/chats/schemas.py @@ -74,3 +74,13 @@ class ChatDeleteRequest(BaseModel): class ChatDiscoverRead(ChatRead): is_member: bool + + +class ChatNotificationSettingsRead(BaseModel): + chat_id: int + user_id: int + muted: bool + + +class ChatNotificationSettingsUpdate(BaseModel): + muted: bool diff --git a/app/chats/service.py b/app/chats/service.py index 4c3fe30..7d55788 100644 --- a/app/chats/service.py +++ b/app/chats/service.py @@ -4,7 +4,16 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.chats import repository from app.chats.models import Chat, ChatMember, ChatMemberRole, ChatType -from app.chats.schemas import ChatCreateRequest, ChatDeleteRequest, ChatDiscoverRead, ChatPinMessageRequest, ChatRead, ChatTitleUpdateRequest +from app.chats.schemas import ( + ChatCreateRequest, + ChatDeleteRequest, + ChatDiscoverRead, + ChatNotificationSettingsRead, + ChatNotificationSettingsUpdate, + ChatPinMessageRequest, + ChatRead, + ChatTitleUpdateRequest, +) from app.messages.repository import ( delete_messages_in_chat, get_hidden_message, @@ -420,3 +429,41 @@ async def clear_chat_for_user(db: AsyncSession, *, chat_id: int, user_id: int) - continue await hide_message_for_user(db, message_id=message_id, user_id=user_id) await db.commit() + + +async def get_chat_notification_settings_for_user( + db: AsyncSession, + *, + chat_id: int, + user_id: int, +) -> ChatNotificationSettingsRead: + await ensure_chat_membership(db, chat_id=chat_id, user_id=user_id) + setting = await repository.get_chat_notification_setting(db, chat_id=chat_id, user_id=user_id) + return ChatNotificationSettingsRead( + chat_id=chat_id, + user_id=user_id, + muted=bool(setting and setting.muted), + ) + + +async def update_chat_notification_settings_for_user( + db: AsyncSession, + *, + chat_id: int, + user_id: int, + payload: ChatNotificationSettingsUpdate, +) -> ChatNotificationSettingsRead: + await ensure_chat_membership(db, chat_id=chat_id, user_id=user_id) + setting = await repository.upsert_chat_notification_setting( + db, + chat_id=chat_id, + user_id=user_id, + muted=payload.muted, + ) + await db.commit() + await db.refresh(setting) + return ChatNotificationSettingsRead( + chat_id=chat_id, + user_id=user_id, + muted=setting.muted, + ) diff --git a/app/notifications/service.py b/app/notifications/service.py index 79a2604..11f478b 100644 --- a/app/notifications/service.py +++ b/app/notifications/service.py @@ -3,7 +3,7 @@ import re from sqlalchemy.ext.asyncio import AsyncSession -from app.chats.repository import list_chat_members +from app.chats.repository import is_chat_muted_for_user, list_chat_members from app.messages.models import Message from app.notifications.repository import create_notification_log, list_user_notifications from app.notifications.schemas import NotificationRead, NotificationRequest @@ -44,6 +44,8 @@ async def dispatch_message_notifications(db: AsyncSession, message: Message) -> sender_name = sender_users[0].username if sender_users else "Someone" for recipient in users: + if await is_chat_muted_for_user(db, chat_id=message.chat_id, user_id=recipient.id): + continue base_payload = { "chat_id": message.chat_id, "message_id": message.id, diff --git a/web/src/api/chats.ts b/web/src/api/chats.ts index 510975e..d65c6e6 100644 --- a/web/src/api/chats.ts +++ b/web/src/api/chats.ts @@ -2,6 +2,12 @@ import { http } from "./http"; import type { Chat, ChatDetail, ChatMember, ChatMemberRole, ChatType, DiscoverChat, Message, MessageType } from "../chat/types"; import axios from "axios"; +export interface ChatNotificationSettings { + chat_id: number; + user_id: number; + muted: boolean; +} + export async function getChats(query?: string): Promise { const { data } = await http.get("/chats", { params: query?.trim() ? { query: query.trim() } : undefined @@ -207,3 +213,13 @@ export async function removeChatMember(chatId: number, userId: number): Promise< export async function leaveChat(chatId: number): Promise { await http.post(`/chats/${chatId}/leave`); } + +export async function getChatNotificationSettings(chatId: number): Promise { + const { data } = await http.get(`/chats/${chatId}/notifications`); + return data; +} + +export async function updateChatNotificationSettings(chatId: number, muted: boolean): Promise { + const { data } = await http.put(`/chats/${chatId}/notifications`, { muted }); + return data; +} diff --git a/web/src/components/ChatInfoPanel.tsx b/web/src/components/ChatInfoPanel.tsx index 20ee345..2aaffb1 100644 --- a/web/src/components/ChatInfoPanel.tsx +++ b/web/src/components/ChatInfoPanel.tsx @@ -2,10 +2,12 @@ import { useEffect, useMemo, useState } from "react"; import { createPortal } from "react-dom"; import { addChatMember, + getChatNotificationSettings, getChatDetail, leaveChat, listChatMembers, removeChatMember, + updateChatNotificationSettings, updateChatMemberRole, updateChatTitle } from "../api/chats"; @@ -33,6 +35,8 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) { const [savingTitle, setSavingTitle] = useState(false); const [searchQuery, setSearchQuery] = useState(""); const [searchResults, setSearchResults] = useState([]); + const [muted, setMuted] = useState(false); + const [savingMute, setSavingMute] = 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"; @@ -65,6 +69,10 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) { if (cancelled) return; setChat(detail); setTitleDraft(detail.title ?? ""); + const notificationSettings = await getChatNotificationSettings(chatId); + if (!cancelled) { + setMuted(notificationSettings.muted); + } await refreshMembers(chatId); } catch { if (!cancelled) setError("Failed to load chat info"); @@ -107,6 +115,27 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) { {chat ? ( <>
+
+

Notifications

+ +
+

{muted ? "Chat notifications are muted." : "Chat notifications are enabled."}

Type

{chat.type}

Title