Private
Public Access
Add direct messages (#52)
A DM is a Room with a new is_dm flag, not a separate model -- reuses all the membership/message/WS plumbing Room already has instead of duplicating it. The room's `name` (still required + globally unique) is an internal, never-displayed token derived deterministically from the two participants' sorted user IDs (dm_room_name), which makes find-or-create a single indexed lookup and gets free race-condition safety from the existing unique constraint -- a concurrent double- start from both people just hits the same IntegrityError->retry-as- lookup path create_room already established. Both participants get the plain 'member' role (no owner/admin distinction makes sense for a 1:1 DM), which incidentally reuses every existing role gate to block add-member, room-settings edits, and join-via-browse on a DM for free. update_room also gets an explicit is_dm guard independent of that, since renaming a DM isn't just a privacy concern -- it would silently corrupt the find-or-create invariant. DMs are excluded from both Browse Rooms and the admin portal's room listing (fully private, per scope). GET /api/rooms/mine precomputes each DM's other participant (name, avatar, presence) as dm_partner in one batched query, so the sidebar can render a DM row without a fetch per row. Frontend: a new "Direct Messages" sidebar section (searchable by partner name, not the internal room name), clicking someone in the People list starts or resumes a DM, and the chat header/composer/RoomInfoPanel all render the partner's identity instead of a room name where it's a DM. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -93,9 +93,14 @@ async def set_user_site_admin(
|
||||
|
||||
|
||||
async def list_rooms_admin(db: AsyncSession) -> list[tuple[Room, int]]:
|
||||
# #52: DMs are fully private, not just unlisted -- excluded here rather
|
||||
# than merely omitted from the response, so there's no admin-portal
|
||||
# surface (this list, or the audit log via anything that touches this
|
||||
# query) that reveals a DM even exists between two users.
|
||||
result = await db.execute(
|
||||
select(Room, func.count(RoomMembership.user_id))
|
||||
.outerjoin(RoomMembership, RoomMembership.room_id == Room.id)
|
||||
.where(Room.is_dm.is_(False))
|
||||
.group_by(Room.id)
|
||||
.order_by(Room.created_at)
|
||||
)
|
||||
|
||||
@@ -60,6 +60,25 @@ class AlreadyMemberError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class CannotDmSelfError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class CannotModifyDmError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def dm_room_name(user_a_id: uuid.UUID, user_b_id: uuid.UUID) -> str:
|
||||
"""Deterministic, internal-only name for the DM room between these two
|
||||
users -- same canonical string regardless of argument order, so
|
||||
find_or_create_dm can look up an existing DM with a single indexed
|
||||
query (Room.name is already unique+indexed) instead of a membership-set
|
||||
join. Never shown to a user -- the frontend renders a DM's dm_partner
|
||||
info instead of its `name` (see MyRoomItem)."""
|
||||
ids = sorted((str(user_a_id), str(user_b_id)))
|
||||
return f"dm:{ids[0]}:{ids[1]}"
|
||||
|
||||
|
||||
async def create_room(db: AsyncSession, owner_id: uuid.UUID, data: RoomCreate) -> Room:
|
||||
room = Room(
|
||||
name=data.name,
|
||||
@@ -80,10 +99,50 @@ async def create_room(db: AsyncSession, owner_id: uuid.UUID, data: RoomCreate) -
|
||||
return room
|
||||
|
||||
|
||||
async def find_or_create_dm(db: AsyncSession, user_id: uuid.UUID, other_user_id: uuid.UUID) -> Room:
|
||||
if user_id == other_user_id:
|
||||
raise CannotDmSelfError()
|
||||
other = await db.get(User, other_user_id)
|
||||
if other is None:
|
||||
raise TargetUserNotFoundError()
|
||||
|
||||
name = dm_room_name(user_id, other_user_id)
|
||||
result = await db.execute(select(Room).where(Room.name == name))
|
||||
room = result.scalar_one_or_none()
|
||||
if room is not None:
|
||||
return room
|
||||
|
||||
# is_private=True is belt-and-suspenders here -- list_open_rooms also
|
||||
# excludes is_dm directly -- but it's also just semantically correct: a
|
||||
# DM genuinely is a private room. Both participants get the plain
|
||||
# `member` role (there's no meaningful owner/admin distinction for a
|
||||
# 1:1 DM); `owner_id` still has to be someone to satisfy the column,
|
||||
# but nothing reads it as meaningful for a DM.
|
||||
room = Room(name=name, is_private=True, is_dm=True, owner_id=user_id)
|
||||
db.add(room)
|
||||
try:
|
||||
await db.flush()
|
||||
except IntegrityError:
|
||||
# Lost a race with a concurrent find_or_create_dm for the same pair
|
||||
# (e.g. both people click "message" on each other at once) -- the
|
||||
# unique constraint on `name` is exactly what caught it, same
|
||||
# pattern as create_room's DuplicateRoomError. The row that won the
|
||||
# race is the room we actually want.
|
||||
await db.rollback()
|
||||
result = await db.execute(select(Room).where(Room.name == name))
|
||||
return result.scalar_one()
|
||||
|
||||
db.add(RoomMembership(room_id=room.id, user_id=user_id, role=RoomRole.member))
|
||||
db.add(RoomMembership(room_id=room.id, user_id=other_user_id, role=RoomRole.member))
|
||||
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))
|
||||
.where(Room.is_private.is_(False), Room.is_archived.is_(False), Room.is_dm.is_(False))
|
||||
.options(selectinload(Room.memberships))
|
||||
.order_by(Room.created_at, Room.id)
|
||||
)
|
||||
@@ -95,7 +154,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]]:
|
||||
) -> list[tuple[Room, RoomRole, bool, bool, User | None]]:
|
||||
last_message_at = (
|
||||
select(func.max(Message.created_at))
|
||||
.where(Message.room_id == Room.id)
|
||||
@@ -130,9 +189,30 @@ async def list_member_rooms(
|
||||
# device's fetch and another's.
|
||||
.order_by(Room.created_at, Room.id)
|
||||
)
|
||||
rows = result.all()
|
||||
|
||||
# #52: one batched follow-up query for every DM room's *other*
|
||||
# participant, rather than a fetch per row -- a DM only ever has
|
||||
# exactly two members, so "the other one" is unambiguous.
|
||||
dm_room_ids = [room.id for room, *_ in rows if room.is_dm]
|
||||
partners_by_room: dict[uuid.UUID, User] = {}
|
||||
if dm_room_ids:
|
||||
partner_result = await db.execute(
|
||||
select(RoomMembership.room_id, User)
|
||||
.join(User, User.id == RoomMembership.user_id)
|
||||
.where(RoomMembership.room_id.in_(dm_room_ids), RoomMembership.user_id != user_id)
|
||||
)
|
||||
partners_by_room = {room_id: user for room_id, user in partner_result.all()}
|
||||
|
||||
return [
|
||||
(room, role, last_message_at is not None and last_message_at > last_read_at, has_mention)
|
||||
for room, role, last_read_at, last_message_at, has_mention in result.all()
|
||||
(
|
||||
room,
|
||||
role,
|
||||
last_message_at is not None and last_message_at > last_read_at,
|
||||
has_mention,
|
||||
partners_by_room.get(room.id),
|
||||
)
|
||||
for room, role, last_read_at, last_message_at, has_mention in rows
|
||||
]
|
||||
|
||||
|
||||
@@ -200,6 +280,14 @@ async def add_member(
|
||||
|
||||
|
||||
async def update_room(db: AsyncSession, room: Room, data: RoomUpdate) -> Room:
|
||||
# A DM's `name` is an internal token find_or_create_dm's lookup depends
|
||||
# on being stable -- renaming it (even via the #48 site-admin bypass in
|
||||
# the router) would silently orphan that invariant, not just leak a
|
||||
# detail that's supposed to stay private. Blocked here, not just in the
|
||||
# UI, since it's a correctness issue for every caller, not a permission
|
||||
# one.
|
||||
if room.is_dm:
|
||||
raise CannotModifyDmError()
|
||||
if data.name is not None:
|
||||
room.name = data.name
|
||||
if data.description is not None:
|
||||
|
||||
Reference in New Issue
Block a user