Private
Public Access
Add per-room opt-in email notifications on mention (#67)
Lets a member subscribe to email when they're @mentioned in a room while offline, alongside #66's always-on DM email. Debounced the same way #66 is (one email per unread burst, not one per mention), and deliberately scoped to regular rooms only -- DMs already have #66's automatic offline email with no separate toggle needed. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -2,7 +2,7 @@ import enum
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, Enum, ForeignKey, PrimaryKeyConstraint, func
|
||||
from sqlalchemy import Boolean, DateTime, Enum, ForeignKey, PrimaryKeyConstraint, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.models.base import Base
|
||||
@@ -42,6 +42,13 @@ class RoomMembership(Base):
|
||||
# arrives in the room, or when find_or_create_dm resolves back to it --
|
||||
# both count as the conversation being active again.
|
||||
hidden_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
# #67: opt-in, per-member -- email when a message in this room mentions
|
||||
# them while they're offline. Deliberately separate from #66's DM
|
||||
# emails (always-on, no toggle) rather than a shared flag, since DMs
|
||||
# are explicitly out of scope for this setting (see message_events.py).
|
||||
email_notifications: Mapped[bool] = mapped_column(
|
||||
Boolean, default=False, server_default="false", nullable=False
|
||||
)
|
||||
|
||||
room = relationship("Room", back_populates="memberships")
|
||||
user = relationship("User")
|
||||
|
||||
@@ -25,6 +25,7 @@ from app.schemas.room import (
|
||||
RoomMemberAdd,
|
||||
RoomMemberRead,
|
||||
RoomMemberRoleUpdate,
|
||||
RoomNotificationSettingsUpdate,
|
||||
RoomRead,
|
||||
RoomUpdate,
|
||||
StartDmRequest,
|
||||
@@ -72,6 +73,7 @@ from app.services.room_service import (
|
||||
list_room_members,
|
||||
mark_room_read,
|
||||
remove_member,
|
||||
set_room_email_notifications,
|
||||
transfer_ownership,
|
||||
update_room,
|
||||
)
|
||||
@@ -179,6 +181,7 @@ async def list_my_rooms_endpoint(
|
||||
role=role,
|
||||
has_unread=has_unread,
|
||||
has_mention=has_mention,
|
||||
email_notifications=email_notifications,
|
||||
dm_partner=(
|
||||
DmPartnerInfo(
|
||||
user_id=partner.id,
|
||||
@@ -191,7 +194,7 @@ async def list_my_rooms_endpoint(
|
||||
else None
|
||||
),
|
||||
)
|
||||
for room, role, has_unread, has_mention, partner in rooms
|
||||
for room, role, has_unread, has_mention, email_notifications, partner in rooms
|
||||
]
|
||||
|
||||
|
||||
@@ -293,6 +296,20 @@ async def mark_room_read_endpoint(
|
||||
await mark_room_read(db, room_id, current_user.id)
|
||||
|
||||
|
||||
@router.patch("/{room_id}/notifications", status_code=204)
|
||||
async def update_room_notifications_endpoint(
|
||||
room_id: uuid.UUID,
|
||||
data: RoomNotificationSettingsUpdate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
await require_room_member(room_id, current_user, db)
|
||||
try:
|
||||
await set_room_email_notifications(db, room_id, current_user.id, data.email_notifications)
|
||||
except CannotModifyDmError:
|
||||
raise HTTPException(status_code=400, detail="Email notifications aren't available for DMs")
|
||||
|
||||
|
||||
def _member_status(user: User, online_ids: set[uuid.UUID]) -> str:
|
||||
# appear_offline always wins, regardless of actual connection -- that's
|
||||
# the whole point of the override (lurking in a room undetected).
|
||||
|
||||
@@ -63,12 +63,20 @@ class MyRoomItem(RoomRead):
|
||||
# avatar, not this room's internal `name`) without a second fetch per
|
||||
# row. None for a regular room.
|
||||
dm_partner: DmPartnerInfo | None = None
|
||||
# #67: this viewer's own opt-in for "email me when mentioned here while
|
||||
# offline" -- always false for a DM (see message_events.py, deliberately
|
||||
# out of scope; DMs already get #66's automatic offline email).
|
||||
email_notifications: bool = False
|
||||
|
||||
|
||||
class StartDmRequest(BaseModel):
|
||||
other_user_id: uuid.UUID
|
||||
|
||||
|
||||
class RoomNotificationSettingsUpdate(BaseModel):
|
||||
email_notifications: bool
|
||||
|
||||
|
||||
class RoomMemberRead(BaseModel):
|
||||
user_id: uuid.UUID
|
||||
username: str
|
||||
|
||||
@@ -249,6 +249,79 @@ async def _maybe_email_dm_notification(
|
||||
)
|
||||
|
||||
|
||||
async def _maybe_email_room_mention_notifications(
|
||||
db: AsyncSession,
|
||||
global_presence: GlobalPresence,
|
||||
base_url: str,
|
||||
room_id: uuid.UUID,
|
||||
sender: User,
|
||||
message: Message,
|
||||
) -> None:
|
||||
"""#67: opt-in, per-room email on mention -- deliberately scoped to
|
||||
regular rooms only (see set_room_email_notifications: DMs already get
|
||||
#66's always-on offline email, no separate toggle). Debounced the same
|
||||
shape as #66 -- only the first unread mention in this room emails, not
|
||||
one per mention while the recipient is away.
|
||||
"""
|
||||
room = await db.get(Room, room_id)
|
||||
if room is None or room.is_dm:
|
||||
return
|
||||
|
||||
result = await db.execute(
|
||||
select(MessageMention.user_id).where(MessageMention.message_id == message.id)
|
||||
)
|
||||
mentioned_ids = {row[0] for row in result.all()} - {sender.id}
|
||||
if not mentioned_ids:
|
||||
return
|
||||
|
||||
for user_id in mentioned_ids:
|
||||
membership_result = await db.execute(
|
||||
select(RoomMembership).where(
|
||||
RoomMembership.room_id == room_id, RoomMembership.user_id == user_id
|
||||
)
|
||||
)
|
||||
membership = membership_result.scalar_one_or_none()
|
||||
if membership is None or not membership.email_notifications:
|
||||
continue
|
||||
|
||||
recipient = await db.get(User, user_id)
|
||||
if recipient is None:
|
||||
continue
|
||||
# appear_offline always wins here too, same as _maybe_email_dm_notification.
|
||||
if not recipient.appear_offline and await global_presence.is_online(recipient.id):
|
||||
continue
|
||||
|
||||
already_unread_mention = await db.execute(
|
||||
select(Message.id)
|
||||
.join(MessageMention, MessageMention.message_id == Message.id)
|
||||
.where(
|
||||
MessageMention.user_id == user_id,
|
||||
Message.room_id == room_id,
|
||||
Message.id != message.id,
|
||||
Message.created_at > membership.last_read_at,
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
if already_unread_mention.scalar_one_or_none() is not None:
|
||||
continue
|
||||
|
||||
body_line = (
|
||||
f"{sender.username} mentioned you: {message.content[:200]}"
|
||||
if message.content
|
||||
else f"{sender.username} mentioned you"
|
||||
)
|
||||
link = f"{base_url.rstrip('/')}/rooms/{room_id}"
|
||||
await send_email(
|
||||
db,
|
||||
recipient.email,
|
||||
f"New mention in #{room.name}",
|
||||
[body_line],
|
||||
cta_label="Open room",
|
||||
cta_url=link,
|
||||
theme_user=recipient,
|
||||
)
|
||||
|
||||
|
||||
async def broadcast_new_message(
|
||||
db: AsyncSession,
|
||||
broadcaster: Broadcaster,
|
||||
@@ -288,6 +361,7 @@ async def broadcast_new_message(
|
||||
)
|
||||
await _notify_offline_members(db, broadcaster, presence, focus_presence, room_id, sender, message)
|
||||
await _maybe_email_dm_notification(db, global_presence, base_url, room_id, sender, message)
|
||||
await _maybe_email_room_mention_notifications(db, global_presence, base_url, room_id, sender, message)
|
||||
await dispatch_event(db, "message.created", room_id, payload)
|
||||
_maybe_fetch_link_preview(broadcaster, room_id, message)
|
||||
|
||||
|
||||
@@ -182,7 +182,7 @@ async def list_open_rooms(db: AsyncSession, user_id: uuid.UUID) -> list[tuple[Ro
|
||||
|
||||
async def list_member_rooms(
|
||||
db: AsyncSession, user_id: uuid.UUID
|
||||
) -> list[tuple[Room, RoomRole, bool, bool, User | None]]:
|
||||
) -> list[tuple[Room, RoomRole, bool, bool, bool, User | None]]:
|
||||
last_message_at = (
|
||||
select(func.max(Message.created_at))
|
||||
.where(Message.room_id == Room.id)
|
||||
@@ -205,7 +205,12 @@ async def list_member_rooms(
|
||||
)
|
||||
result = await db.execute(
|
||||
select(
|
||||
Room, RoomMembership.role, RoomMembership.last_read_at, last_message_at, has_unread_mention
|
||||
Room,
|
||||
RoomMembership.role,
|
||||
RoomMembership.last_read_at,
|
||||
last_message_at,
|
||||
has_unread_mention,
|
||||
RoomMembership.email_notifications,
|
||||
)
|
||||
.join(RoomMembership, RoomMembership.room_id == Room.id)
|
||||
.where(RoomMembership.user_id == user_id, RoomMembership.hidden_at.is_(None))
|
||||
@@ -238,9 +243,10 @@ async def list_member_rooms(
|
||||
role,
|
||||
last_message_at is not None and last_message_at > last_read_at,
|
||||
has_mention,
|
||||
email_notifications,
|
||||
partners_by_room.get(room.id),
|
||||
)
|
||||
for room, role, last_read_at, last_message_at, has_mention in rows
|
||||
for room, role, last_read_at, last_message_at, has_mention, email_notifications in rows
|
||||
]
|
||||
|
||||
|
||||
@@ -486,6 +492,22 @@ async def mark_room_read(db: AsyncSession, room_id: uuid.UUID, user_id: uuid.UUI
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def set_room_email_notifications(
|
||||
db: AsyncSession, room_id: uuid.UUID, user_id: uuid.UUID, enabled: bool
|
||||
) -> None:
|
||||
"""#67: DMs are deliberately excluded -- they already get #66's
|
||||
automatic offline email with no opt-in needed, and this setting only
|
||||
makes sense for a regular room's mention-based notifications."""
|
||||
room = await db.get(Room, room_id)
|
||||
if room is None:
|
||||
raise RoomNotFoundError()
|
||||
if room.is_dm:
|
||||
raise CannotModifyDmError()
|
||||
membership = await _get_membership(db, room_id, user_id)
|
||||
membership.email_notifications = enabled
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def leave_room(db: AsyncSession, room_id: uuid.UUID, user_id: uuid.UUID) -> None:
|
||||
membership = await _get_membership(db, room_id, user_id)
|
||||
if membership.role == RoomRole.owner:
|
||||
|
||||
Reference in New Issue
Block a user