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:
2026-08-13 20:22:15 -06:00
co-authored by Claude Sonnet 5
parent 99aa029c0d
commit c79e96dd48
16 changed files with 1172 additions and 32 deletions
+155
View File
@@ -0,0 +1,155 @@
import secrets
import uuid
from datetime import datetime, timezone
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.models import InviteStatus, RoomInvite, RoomMembership, RoomRole, User
class TargetUserNotFoundError(Exception):
pass
class AlreadyMemberError(Exception):
pass
class DuplicateInviteError(Exception):
pass
class InviteNotFoundError(Exception):
pass
class WrongInviteTargetError(Exception):
pass
class InviteNotPendingError(Exception):
pass
class InviteExpiredError(Exception):
pass
async def create_invite(
db: AsyncSession, room_id: uuid.UUID, invited_by: uuid.UUID, target_username: str
) -> RoomInvite:
result = await db.execute(select(User).where(User.username == target_username))
target = result.scalar_one_or_none()
if target is None:
raise TargetUserNotFoundError()
existing_membership = await db.execute(
select(RoomMembership).where(
RoomMembership.room_id == room_id, RoomMembership.user_id == target.id
)
)
if existing_membership.scalar_one_or_none() is not None:
raise AlreadyMemberError()
existing_invite = await db.execute(
select(RoomInvite).where(
RoomInvite.room_id == room_id,
RoomInvite.target_user_id == target.id,
RoomInvite.status == InviteStatus.pending,
)
)
if existing_invite.scalar_one_or_none() is not None:
raise DuplicateInviteError()
invite = RoomInvite(
room_id=room_id,
invited_by=invited_by,
token=secrets.token_urlsafe(32),
target_user_id=target.id,
)
db.add(invite)
await db.commit()
await db.refresh(invite)
return invite
async def list_room_invites(db: AsyncSession, room_id: uuid.UUID) -> list[RoomInvite]:
result = await db.execute(
select(RoomInvite).where(
RoomInvite.room_id == room_id, RoomInvite.status == InviteStatus.pending
)
)
return list(result.scalars().all())
async def list_my_invites(db: AsyncSession, user_id: uuid.UUID) -> list[RoomInvite]:
result = await db.execute(
select(RoomInvite)
.where(
RoomInvite.target_user_id == user_id,
RoomInvite.status == InviteStatus.pending,
RoomInvite.expires_at > datetime.now(timezone.utc),
)
.options(selectinload(RoomInvite.room))
)
return list(result.scalars().all())
async def _get_invite(db: AsyncSession, invite_id: uuid.UUID) -> RoomInvite:
invite = await db.get(RoomInvite, invite_id)
if invite is None:
raise InviteNotFoundError()
return invite
async def accept_invite(db: AsyncSession, invite_id: uuid.UUID, user_id: uuid.UUID) -> RoomMembership:
invite = await _get_invite(db, invite_id)
if invite.target_user_id != user_id:
raise WrongInviteTargetError()
if invite.status != InviteStatus.pending:
raise InviteNotPendingError()
if invite.expires_at <= datetime.now(timezone.utc):
raise InviteExpiredError()
result = await db.execute(
select(RoomMembership).where(
RoomMembership.room_id == invite.room_id, RoomMembership.user_id == user_id
)
)
membership = result.scalar_one_or_none()
if membership is None:
membership = RoomMembership(room_id=invite.room_id, user_id=user_id, role=RoomRole.member)
db.add(membership)
invite.status = InviteStatus.accepted
await db.commit()
await db.refresh(membership)
return membership
async def decline_invite(db: AsyncSession, invite_id: uuid.UUID, user_id: uuid.UUID) -> RoomInvite:
invite = await _get_invite(db, invite_id)
if invite.target_user_id != user_id:
raise WrongInviteTargetError()
if invite.status != InviteStatus.pending:
raise InviteNotPendingError()
invite.status = InviteStatus.revoked
await db.commit()
await db.refresh(invite)
return invite
async def revoke_invite(db: AsyncSession, room_id: uuid.UUID, invite_id: uuid.UUID) -> RoomInvite:
invite = await _get_invite(db, invite_id)
if invite.room_id != room_id:
raise InviteNotFoundError()
if invite.status != InviteStatus.pending:
raise InviteNotPendingError()
invite.status = InviteStatus.revoked
await db.commit()
await db.refresh(invite)
return invite