Private
Public Access
Phase 2: private rooms, room roles, and invites (backend only)
Adds room_invites table + migration, owner/admin/member role enforcement (require_room_role), and endpoints for private room creation, room management (update/delete/leave/transfer-ownership/change-role/remove-member), and the invite lifecycle (create/list/accept/decline/revoke). Registration stays invite-only via the CLI from Phase 1 — this is a separate, room-level invite system for adding existing users to private rooms. Frontend is untouched: the UI redesign is happening separately, so this phase is backend + tests only (35 passing). Verified no regressions in the Phase 1 open-room/WebSocket flow via manual smoke test. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,12 +1,12 @@
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.models import Room, RoomMembership, RoomRole
|
||||
from app.schemas.room import RoomCreate
|
||||
from app.models import Message, Room, RoomInvite, RoomMembership, RoomRole
|
||||
from app.schemas.room import RoomCreate, RoomUpdate
|
||||
|
||||
|
||||
class DuplicateRoomError(Exception):
|
||||
@@ -21,8 +21,29 @@ class RoomIsPrivateError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class MembershipNotFoundError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class CannotRemoveOwnerError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class InsufficientRoleError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class OwnerMustTransferError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
async def create_room(db: AsyncSession, owner_id: uuid.UUID, data: RoomCreate) -> Room:
|
||||
room = Room(name=data.name, description=data.description, owner_id=owner_id)
|
||||
room = Room(
|
||||
name=data.name,
|
||||
description=data.description,
|
||||
is_private=data.is_private,
|
||||
owner_id=owner_id,
|
||||
)
|
||||
db.add(room)
|
||||
try:
|
||||
await db.flush()
|
||||
@@ -49,6 +70,16 @@ 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]]:
|
||||
result = await db.execute(
|
||||
select(Room, RoomMembership.role)
|
||||
.join(RoomMembership, RoomMembership.room_id == Room.id)
|
||||
.where(RoomMembership.user_id == user_id)
|
||||
.order_by(Room.created_at)
|
||||
)
|
||||
return [(room, role) for room, role 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:
|
||||
@@ -75,3 +106,105 @@ async def join_room(db: AsyncSession, room_id: uuid.UUID, user_id: uuid.UUID) ->
|
||||
await db.commit()
|
||||
await db.refresh(membership)
|
||||
return membership
|
||||
|
||||
|
||||
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(RoomInvite).where(RoomInvite.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 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()
|
||||
|
||||
Reference in New Issue
Block a user