Files
ds-chat/backend/app/services/room_service.py
T
ksmithandClaude Sonnet 5 68e487e5ec Add unread message indicators for rooms (#38)
Shows a dot on rooms with unread messages in the sidebar, updated live
over WebSocket. Reuses the same offline-member audience computation
already used for push notifications: a member gets the real-time signal
whenever they aren't currently connected to that room's channel, which
correctly covers both "room not open" and "room open but tab
backgrounded" (the client leaves a room's channel while hidden).

Persisted server-side via a new room_memberships.last_read_at column so
state survives reload and stays consistent across devices, advanced by
an explicit mark-read call the frontend makes on room-open and on each
live message received while the room is genuinely visible -- gated on a
live visibility check, not a cached ref, so a backgrounded-but-open room
keeps accumulating unread instead of auto-marking-read the instant a
message arrives somewhere it can't be seen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 20:44:20 -06:00

271 lines
8.0 KiB
Python

import uuid
from sqlalchemy import delete, func, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.models import Message, Room, RoomMembership, RoomRole, User
from app.schemas.room import RoomCreate, RoomUpdate
from app.services.email_service import send_email
class DuplicateRoomError(Exception):
pass
class RoomNotFoundError(Exception):
pass
class RoomIsPrivateError(Exception):
pass
class MembershipNotFoundError(Exception):
pass
class CannotRemoveOwnerError(Exception):
pass
class InsufficientRoleError(Exception):
pass
class OwnerMustTransferError(Exception):
pass
class TargetUserNotFoundError(Exception):
pass
class AlreadyMemberError(Exception):
pass
async def create_room(db: AsyncSession, owner_id: uuid.UUID, data: RoomCreate) -> Room:
room = Room(
name=data.name,
description=data.description,
is_private=data.is_private,
owner_id=owner_id,
)
db.add(room)
try:
await db.flush()
except IntegrityError as exc:
await db.rollback()
raise DuplicateRoomError() from exc
db.add(RoomMembership(room_id=room.id, user_id=owner_id, role=RoomRole.owner))
await db.commit()
await db.refresh(room)
return room
async def list_open_rooms(db: AsyncSession, user_id: uuid.UUID) -> list[tuple[Room, bool]]:
result = await db.execute(
select(Room)
.where(Room.is_private.is_(False), Room.is_archived.is_(False))
.options(selectinload(Room.memberships))
.order_by(Room.created_at)
)
rooms = result.scalars().all()
return [
(room, any(m.user_id == user_id for m in room.memberships)) for room in rooms
]
async def list_member_rooms(
db: AsyncSession, user_id: uuid.UUID
) -> list[tuple[Room, RoomRole, bool]]:
last_message_at = (
select(func.max(Message.created_at))
.where(Message.room_id == Room.id)
.correlate(Room)
.scalar_subquery()
)
result = await db.execute(
select(Room, RoomMembership.role, RoomMembership.last_read_at, last_message_at)
.join(RoomMembership, RoomMembership.room_id == Room.id)
.where(RoomMembership.user_id == user_id)
.order_by(Room.created_at)
)
return [
(room, role, last_message_at is not None and last_message_at > last_read_at)
for room, role, last_read_at, last_message_at in result.all()
]
async def get_room(db: AsyncSession, room_id: uuid.UUID) -> Room:
room = await db.get(Room, room_id)
if room is None:
raise RoomNotFoundError()
return room
async def join_room(db: AsyncSession, room_id: uuid.UUID, user_id: uuid.UUID) -> RoomMembership:
room = await get_room(db, room_id)
if room.is_private:
raise RoomIsPrivateError()
result = await db.execute(
select(RoomMembership).where(
RoomMembership.room_id == room_id, RoomMembership.user_id == user_id
)
)
membership = result.scalar_one_or_none()
if membership is not None:
return membership
membership = RoomMembership(room_id=room_id, user_id=user_id, role=RoomRole.member)
db.add(membership)
await db.commit()
await db.refresh(membership)
return membership
async def add_member(
db: AsyncSession, room: Room, target_user_id: uuid.UUID, base_url: str
) -> RoomMembership:
target = await db.get(User, target_user_id)
if target is None:
raise TargetUserNotFoundError()
existing = await db.execute(
select(RoomMembership).where(
RoomMembership.room_id == room.id, RoomMembership.user_id == target_user_id
)
)
if existing.scalar_one_or_none() is not None:
raise AlreadyMemberError()
membership = RoomMembership(room_id=room.id, user_id=target_user_id, role=RoomRole.member)
db.add(membership)
await db.commit()
await send_email(
db,
target.email,
f"You've been added to #{room.name}",
f"You've been added to the #{room.name} room on DS Chat.\n\n"
f"Open the app: {base_url.rstrip('/')}",
)
result = await db.execute(
select(RoomMembership)
.where(RoomMembership.room_id == room.id, RoomMembership.user_id == target_user_id)
.options(selectinload(RoomMembership.user))
)
return result.scalar_one()
async def update_room(db: AsyncSession, room: Room, data: RoomUpdate) -> Room:
if data.name is not None:
room.name = data.name
if data.description is not None:
room.description = data.description
try:
await db.commit()
except IntegrityError as exc:
await db.rollback()
raise DuplicateRoomError() from exc
await db.refresh(room)
return room
async def delete_room(db: AsyncSession, room: Room) -> None:
# Explicit deletes rather than relying on ORM cascade + eager-loading —
# simpler and more predictable in async code.
await db.execute(delete(Message).where(Message.room_id == room.id))
await db.execute(delete(RoomMembership).where(RoomMembership.room_id == room.id))
await db.delete(room)
await db.commit()
async def list_room_members(db: AsyncSession, room_id: uuid.UUID) -> list[RoomMembership]:
result = await db.execute(
select(RoomMembership)
.where(RoomMembership.room_id == room_id)
.options(selectinload(RoomMembership.user))
.order_by(RoomMembership.joined_at)
)
return list(result.scalars().all())
async def _get_membership(
db: AsyncSession, room_id: uuid.UUID, user_id: uuid.UUID
) -> RoomMembership:
result = await db.execute(
select(RoomMembership).where(
RoomMembership.room_id == room_id, RoomMembership.user_id == user_id
)
)
membership = result.scalar_one_or_none()
if membership is None:
raise MembershipNotFoundError()
return membership
async def remove_member(
db: AsyncSession, room_id: uuid.UUID, target_user_id: uuid.UUID, acting_role: RoomRole
) -> None:
membership = await _get_membership(db, room_id, target_user_id)
if membership.role == RoomRole.owner:
raise CannotRemoveOwnerError()
if membership.role == RoomRole.admin and acting_role != RoomRole.owner:
raise InsufficientRoleError()
await db.delete(membership)
await db.commit()
async def change_member_role(
db: AsyncSession, room_id: uuid.UUID, target_user_id: uuid.UUID, new_role: RoomRole
) -> RoomMembership:
membership = await _get_membership(db, room_id, target_user_id)
if membership.role == RoomRole.owner or new_role == RoomRole.owner:
# Ownership changes only happen through transfer_ownership.
raise InsufficientRoleError()
membership.role = new_role
await db.commit()
result = await db.execute(
select(RoomMembership)
.where(RoomMembership.room_id == room_id, RoomMembership.user_id == target_user_id)
.options(selectinload(RoomMembership.user))
)
return result.scalar_one()
async def transfer_ownership(
db: AsyncSession, room: Room, current_owner_id: uuid.UUID, new_owner_user_id: uuid.UUID
) -> Room:
new_owner_membership = await _get_membership(db, room.id, new_owner_user_id)
current_owner_membership = await _get_membership(db, room.id, current_owner_id)
new_owner_membership.role = RoomRole.owner
current_owner_membership.role = RoomRole.admin
room.owner_id = new_owner_user_id
await db.commit()
await db.refresh(room)
return room
async def mark_room_read(db: AsyncSession, room_id: uuid.UUID, user_id: uuid.UUID) -> None:
membership = await _get_membership(db, room_id, user_id)
membership.last_read_at = func.now()
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:
raise OwnerMustTransferError()
await db.delete(membership)
await db.commit()