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
+11 -8
View File
@@ -4,19 +4,22 @@ A web-based team chat service (Mattermost-style, no threaded conversations),
invite-only. See [ARCHITECTURE.md](ARCHITECTURE.md) for the full system design
and phased build plan.
**Phase 1** (this state of the repo): auth, open-room CRUD, and single-instance
WebSocket chat, backend + a minimal frontend. Later phases (private rooms,
push notifications, Redis fan-out, the admin portal, the bot/extension
system, and production deployment) are tracked as issues in the repo's issue
tracker, prioritized.
**Phase 1**: auth, open-room CRUD, and single-instance WebSocket chat, backend
+ a minimal frontend. **Phase 2**: private rooms, room roles (owner/admin/
member), and room invites — backend only, see below. Later phases (push
notifications, Redis fan-out, the admin portal, the bot/extension system, and
production deployment) are tracked as issues in the repo's issue tracker,
prioritized.
## Structure
- [`backend/`](backend/) — FastAPI + SQLAlchemy 2.0 (async) + PostgreSQL. See
[`backend/README.md`](backend/README.md) for local setup, migrations, and
how to create a user (registration is invite-only — there's no public
sign-up endpoint).
[`backend/README.md`](backend/README.md) for local setup, migrations, how to
create a user (site registration is invite-only — no public sign-up
endpoint), and the Phase 2 room-roles/invites API.
- [`frontend/`](frontend/) — React + Vite PWA (login, room list, chat view).
Still Phase-1-only: it doesn't yet call any of the Phase 2 endpoints. A UI
redesign is happening separately; frontend work resumes once that lands.
## Quickstart
+32 -12
View File
@@ -1,7 +1,8 @@
# KeepItTalking backend (Phase 1)
# KeepItTalking backend (Phase 1 + 2)
FastAPI + SQLAlchemy 2.0 (async) + PostgreSQL. Implements auth, open-room CRUD,
and a single-instance WebSocket chat endpoint. See `../ARCHITECTURE.md` for the
FastAPI + SQLAlchemy 2.0 (async) + PostgreSQL. Implements auth, room CRUD
(open and private), room roles (owner/admin/member) and invites, and a
single-instance WebSocket chat endpoint. See `../ARCHITECTURE.md` for the
full system design and the phased build plan.
This is an **invite-only site**: there is no public registration endpoint.
@@ -79,29 +80,48 @@ app/
main.py create_app(), session middleware, router/WS mounting
config.py environment-driven settings (pydantic-settings)
database.py async engine/session, get_db() dependency
dependencies.py get_current_user, require_room_member
dependencies.py get_current_user, require_room_member, require_room_role
security.py argon2 password hashing
cli.py `python -m app.cli create-user` (account provisioning)
models/ SQLAlchemy models (users, rooms, room_memberships, messages)
models/ SQLAlchemy models (users, rooms, room_memberships,
messages, room_invites)
schemas/ Pydantic request/response models
routers/ auth, rooms, health
routers/ auth, rooms, invites, health
services/ business logic called by routers
ws/ WebSocket connection manager + /ws/chat handler
alembic/ migrations
tests/ pytest + httpx/TestClient tests
```
## Room roles and invites (Phase 2)
Rooms can be `open` (anyone can join via `POST /api/rooms/{id}/join`) or
`private` (`is_private: true` at creation — joinable only via invite). Room
roles are `owner` > `admin` > `member`:
- **member**: post messages, leave the room
- **admin**: edit room settings, create/list/revoke invites, remove plain members
- **owner**: everything admin can, plus delete the room, remove admins, change
member roles, and transfer ownership
Invite flow: an admin+ calls `POST /api/rooms/{id}/invites` with an existing
`target_username`; the invited user sees it via `GET /api/invites/mine` and
calls `POST /api/invites/{id}/accept` (or `/decline`). `GET /api/rooms/mine`
lists every room (open + private) the current user belongs to, alongside
their role.
## Notes / scope decisions
- Invite-only: no `POST /api/auth/register`. Accounts are provisioned with
`python -m app.cli create-user` (see step 4 above). A more self-service
invite flow (per-user tokens, or an admin-portal "generate invite" button)
is a natural phase-2/6 follow-up, not built now.
- Invite-only site registration: no `POST /api/auth/register`. Accounts are
provisioned with `python -m app.cli create-user` (see step 4 above). This is
separate from *room* invites above — site accounts vs. room membership.
- Room invites are by **username only**`room_invites.target_email` exists
in the schema (per `ARCHITECTURE.md`) but is unused, since there's no
email-delivery mechanism anywhere in the stack yet.
- Sessions are signed cookies (Starlette `SessionMiddleware`), not a server-side
session table — see `ARCHITECTURE.md`'s rationale (simplest way to carry auth
through a WebSocket handshake). This means there's currently no way to force-
revoke a session server-side; that needs a real session table later.
- No CSRF token yet — `SameSite=Lax` cookies plus a same-origin frontend dev
proxy (see `../frontend/vite.config.ts`) is the accepted phase-1 mitigation.
- `rooms.is_private` exists in the schema but the API never sets it `True` yet;
private rooms/invites are phase 2 (tracked as a Gitea issue).
- Deleting a room explicitly deletes its messages/memberships/invites first
(`room_service.delete_room`) rather than relying on DB-level cascades.
@@ -0,0 +1,53 @@
"""room invites
Revision ID: 0699d20789b3
Revises: c7981d17890c
Create Date: 2026-08-13 20:14:22.346544
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '0699d20789b3'
down_revision: Union[str, Sequence[str], None] = 'c7981d17890c'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('room_invites',
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('room_id', sa.Uuid(), nullable=False),
sa.Column('invited_by', sa.Uuid(), nullable=False),
sa.Column('token', sa.String(length=64), nullable=False),
sa.Column('target_user_id', sa.Uuid(), nullable=True),
sa.Column('target_email', sa.String(length=255), nullable=True),
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('status', sa.Enum('pending', 'accepted', 'revoked', name='invite_status'), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.CheckConstraint('target_user_id IS NOT NULL OR target_email IS NOT NULL', name='room_invites_target_required'),
sa.ForeignKeyConstraint(['invited_by'], ['users.id'], ),
sa.ForeignKeyConstraint(['room_id'], ['rooms.id'], ),
sa.ForeignKeyConstraint(['target_user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_room_invites_room_id'), 'room_invites', ['room_id'], unique=False)
op.create_index(op.f('ix_room_invites_target_user_id'), 'room_invites', ['target_user_id'], unique=False)
op.create_index(op.f('ix_room_invites_token'), 'room_invites', ['token'], unique=True)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_room_invites_token'), table_name='room_invites')
op.drop_index(op.f('ix_room_invites_target_user_id'), table_name='room_invites')
op.drop_index(op.f('ix_room_invites_room_id'), table_name='room_invites')
op.drop_table('room_invites')
# ### end Alembic commands ###
+12 -1
View File
@@ -5,7 +5,9 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models import RoomMembership, User
from app.models import RoomMembership, RoomRole, User
_ROLE_RANK = {RoomRole.member: 0, RoomRole.admin: 1, RoomRole.owner: 2}
async def get_current_user(
@@ -35,3 +37,12 @@ async def require_room_member(
if membership is None:
raise HTTPException(status_code=403, detail="Not a member of this room")
return membership
async def require_room_role(
room_id: uuid.UUID, user: User, db: AsyncSession, minimum: RoomRole
) -> RoomMembership:
membership = await require_room_member(room_id, user, db)
if _ROLE_RANK[membership.role] < _ROLE_RANK[minimum]:
raise HTTPException(status_code=403, detail="Insufficient room role")
return membership
+2 -1
View File
@@ -2,7 +2,7 @@ from fastapi import FastAPI
from starlette.middleware.sessions import SessionMiddleware
from app.config import settings
from app.routers import auth, health, rooms
from app.routers import auth, health, invites, rooms
from app.ws.chat import router as ws_router
from app.ws.connection_manager import ConnectionManager
@@ -23,6 +23,7 @@ def create_app() -> FastAPI:
app.include_router(health.router)
app.include_router(auth.router)
app.include_router(rooms.router)
app.include_router(invites.router)
app.include_router(ws_router)
return app
+11 -1
View File
@@ -1,7 +1,17 @@
from app.models.base import Base
from app.models.invite import InviteStatus, RoomInvite
from app.models.membership import RoomMembership, RoomRole
from app.models.message import Message
from app.models.room import Room
from app.models.user import User
__all__ = ["Base", "User", "Room", "RoomMembership", "RoomRole", "Message"]
__all__ = [
"Base",
"User",
"Room",
"RoomMembership",
"RoomRole",
"Message",
"RoomInvite",
"InviteStatus",
]
+53
View File
@@ -0,0 +1,53 @@
import enum
import uuid
from datetime import datetime, timedelta, timezone
from sqlalchemy import CheckConstraint, DateTime, Enum, ForeignKey, String, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base
DEFAULT_INVITE_LIFETIME = timedelta(days=7)
def _default_expires_at() -> datetime:
return datetime.now(timezone.utc) + DEFAULT_INVITE_LIFETIME
class InviteStatus(str, enum.Enum):
pending = "pending"
accepted = "accepted"
revoked = "revoked"
class RoomInvite(Base):
__tablename__ = "room_invites"
__table_args__ = (
CheckConstraint(
"target_user_id IS NOT NULL OR target_email IS NOT NULL",
name="room_invites_target_required",
),
)
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
room_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("rooms.id"), index=True, nullable=False)
invited_by: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id"), nullable=False)
token: Mapped[str] = mapped_column(String(64), unique=True, index=True, nullable=False)
target_user_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("users.id"), index=True)
# Stored per the documented schema but not actionable yet: there's no
# email-delivery mechanism anywhere in the stack. Phase 2 only creates
# invites via target_user_id (existing users, looked up by username).
target_email: Mapped[str | None] = mapped_column(String(255))
expires_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=_default_expires_at, nullable=False
)
status: Mapped[InviteStatus] = mapped_column(
Enum(InviteStatus, name="invite_status"), default=InviteStatus.pending, nullable=False
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
room = relationship("Room")
inviter = relationship("User", foreign_keys=[invited_by])
target_user = relationship("User", foreign_keys=[target_user_id])
+70
View File
@@ -0,0 +1,70 @@
import uuid
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.dependencies import get_current_user
from app.models import User
from app.schemas.invite import InviteRead
from app.schemas.room import RoomMemberRead
from app.services.invite_service import (
InviteExpiredError,
InviteNotFoundError,
InviteNotPendingError,
WrongInviteTargetError,
accept_invite,
decline_invite,
list_my_invites,
)
router = APIRouter(prefix="/api/invites", tags=["invites"])
@router.get("/mine", response_model=list[InviteRead])
async def list_my_invites_endpoint(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
return await list_my_invites(db, current_user.id)
@router.post("/{invite_id}/accept", response_model=RoomMemberRead)
async def accept_invite_endpoint(
invite_id: uuid.UUID,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
try:
membership = await accept_invite(db, invite_id, current_user.id)
except InviteNotFoundError:
raise HTTPException(status_code=404, detail="Invite not found")
except WrongInviteTargetError:
raise HTTPException(status_code=403, detail="This invite is not addressed to you")
except InviteNotPendingError:
raise HTTPException(status_code=400, detail="Invite is no longer pending")
except InviteExpiredError:
raise HTTPException(status_code=400, detail="Invite has expired")
return RoomMemberRead(
user_id=membership.user_id,
username=current_user.username,
role=membership.role,
joined_at=membership.joined_at,
)
@router.post("/{invite_id}/decline", response_model=InviteRead)
async def decline_invite_endpoint(
invite_id: uuid.UUID,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
try:
return await decline_invite(db, invite_id, current_user.id)
except InviteNotFoundError:
raise HTTPException(status_code=404, detail="Invite not found")
except WrongInviteTargetError:
raise HTTPException(status_code=403, detail="This invite is not addressed to you")
except InviteNotPendingError:
raise HTTPException(status_code=400, detail="Invite is no longer pending")
+227 -3
View File
@@ -4,19 +4,51 @@ from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.dependencies import get_current_user, require_room_member
from app.models import User
from app.dependencies import get_current_user, require_room_member, require_room_role
from app.models import RoomRole, User
from app.schemas.invite import InviteCreate, InviteRead
from app.schemas.message import MessageRead
from app.schemas.room import RoomCreate, RoomListItem, RoomRead
from app.schemas.room import (
MyRoomItem,
RoomCreate,
RoomListItem,
RoomMemberRead,
RoomMemberRoleUpdate,
RoomRead,
RoomUpdate,
TransferOwnershipRequest,
)
from app.services.invite_service import (
AlreadyMemberError,
DuplicateInviteError,
InviteNotFoundError,
InviteNotPendingError,
TargetUserNotFoundError,
create_invite,
list_room_invites,
revoke_invite,
)
from app.services.message_service import list_recent_messages
from app.services.room_service import (
CannotRemoveOwnerError,
DuplicateRoomError,
InsufficientRoleError,
MembershipNotFoundError,
OwnerMustTransferError,
RoomIsPrivateError,
RoomNotFoundError,
change_member_role,
create_room,
delete_room,
get_room,
join_room,
leave_room,
list_member_rooms,
list_open_rooms,
list_room_members,
remove_member,
transfer_ownership,
update_room,
)
router = APIRouter(prefix="/api/rooms", tags=["rooms"])
@@ -54,6 +86,57 @@ async def list_rooms_endpoint(
]
@router.get("/mine", response_model=list[MyRoomItem])
async def list_my_rooms_endpoint(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
rooms = await list_member_rooms(db, current_user.id)
return [
MyRoomItem(
id=room.id,
name=room.name,
description=room.description,
is_private=room.is_private,
owner_id=room.owner_id,
created_at=room.created_at,
role=role,
)
for room, role in rooms
]
@router.patch("/{room_id}", response_model=RoomRead)
async def update_room_endpoint(
room_id: uuid.UUID,
data: RoomUpdate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
try:
room = await get_room(db, room_id)
await require_room_role(room_id, current_user, db, RoomRole.admin)
return await update_room(db, room, data)
except RoomNotFoundError:
raise HTTPException(status_code=404, detail="Room not found")
except DuplicateRoomError:
raise HTTPException(status_code=409, detail="A room with this name already exists")
@router.delete("/{room_id}", status_code=204)
async def delete_room_endpoint(
room_id: uuid.UUID,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
try:
room = await get_room(db, room_id)
await require_room_role(room_id, current_user, db, RoomRole.owner)
await delete_room(db, room)
except RoomNotFoundError:
raise HTTPException(status_code=404, detail="Room not found")
@router.post("/{room_id}/join", response_model=RoomRead)
async def join_room_endpoint(
room_id: uuid.UUID,
@@ -69,6 +152,103 @@ async def join_room_endpoint(
raise HTTPException(status_code=400, detail="Cannot join a private room directly")
@router.post("/{room_id}/leave", status_code=204)
async def leave_room_endpoint(
room_id: uuid.UUID,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
await require_room_member(room_id, current_user, db)
try:
await leave_room(db, room_id, current_user.id)
except OwnerMustTransferError:
raise HTTPException(
status_code=400, detail="Transfer ownership before leaving this room"
)
except MembershipNotFoundError:
raise HTTPException(status_code=404, detail="Not a member of this room")
@router.get("/{room_id}/members", response_model=list[RoomMemberRead])
async def list_room_members_endpoint(
room_id: uuid.UUID,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
await require_room_member(room_id, current_user, db)
memberships = await list_room_members(db, room_id)
return [
RoomMemberRead(
user_id=m.user_id, username=m.user.username, role=m.role, joined_at=m.joined_at
)
for m in memberships
]
@router.delete("/{room_id}/members/{user_id}", status_code=204)
async def remove_member_endpoint(
room_id: uuid.UUID,
user_id: uuid.UUID,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
membership = await require_room_role(room_id, current_user, db, RoomRole.admin)
try:
await remove_member(db, room_id, user_id, acting_role=membership.role)
except MembershipNotFoundError:
raise HTTPException(status_code=404, detail="That user is not a member of this room")
except CannotRemoveOwnerError:
raise HTTPException(
status_code=400, detail="Room owner must transfer ownership before being removed"
)
except InsufficientRoleError:
raise HTTPException(status_code=403, detail="Only the owner can remove an admin")
@router.patch("/{room_id}/members/{user_id}", response_model=RoomMemberRead)
async def change_member_role_endpoint(
room_id: uuid.UUID,
user_id: uuid.UUID,
data: RoomMemberRoleUpdate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
await require_room_role(room_id, current_user, db, RoomRole.owner)
try:
membership = await change_member_role(db, room_id, user_id, data.role)
except MembershipNotFoundError:
raise HTTPException(status_code=404, detail="That user is not a member of this room")
except InsufficientRoleError:
raise HTTPException(
status_code=400, detail="Use transfer-ownership to change the room owner"
)
return RoomMemberRead(
user_id=membership.user_id,
username=membership.user.username,
role=membership.role,
joined_at=membership.joined_at,
)
@router.post("/{room_id}/transfer-ownership", response_model=RoomRead)
async def transfer_ownership_endpoint(
room_id: uuid.UUID,
data: TransferOwnershipRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
try:
room = await get_room(db, room_id)
await require_room_role(room_id, current_user, db, RoomRole.owner)
return await transfer_ownership(db, room, current_user.id, data.new_owner_user_id)
except RoomNotFoundError:
raise HTTPException(status_code=404, detail="Room not found")
except MembershipNotFoundError:
raise HTTPException(
status_code=404, detail="The new owner must already be a member of this room"
)
@router.get("/{room_id}/messages", response_model=list[MessageRead])
async def get_room_messages_endpoint(
room_id: uuid.UUID,
@@ -78,3 +258,47 @@ async def get_room_messages_endpoint(
):
await require_room_member(room_id, current_user, db)
return await list_recent_messages(db, room_id, limit)
@router.post("/{room_id}/invites", response_model=InviteRead, status_code=201)
async def create_invite_endpoint(
room_id: uuid.UUID,
data: InviteCreate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
await require_room_role(room_id, current_user, db, RoomRole.admin)
try:
return await create_invite(db, room_id, current_user.id, data.target_username)
except TargetUserNotFoundError:
raise HTTPException(status_code=404, detail="No user with that username")
except AlreadyMemberError:
raise HTTPException(status_code=409, detail="That user is already a member")
except DuplicateInviteError:
raise HTTPException(status_code=409, detail="That user already has a pending invite")
@router.get("/{room_id}/invites", response_model=list[InviteRead])
async def list_room_invites_endpoint(
room_id: uuid.UUID,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
await require_room_role(room_id, current_user, db, RoomRole.admin)
return await list_room_invites(db, room_id)
@router.delete("/{room_id}/invites/{invite_id}", status_code=204)
async def revoke_invite_endpoint(
room_id: uuid.UUID,
invite_id: uuid.UUID,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
await require_room_role(room_id, current_user, db, RoomRole.admin)
try:
await revoke_invite(db, room_id, invite_id)
except InviteNotFoundError:
raise HTTPException(status_code=404, detail="Invite not found")
except InviteNotPendingError:
raise HTTPException(status_code=400, detail="Invite is no longer pending")
+22
View File
@@ -0,0 +1,22 @@
import uuid
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field
from app.models import InviteStatus
class InviteCreate(BaseModel):
target_username: str = Field(min_length=1)
class InviteRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
room_id: uuid.UUID
invited_by: uuid.UUID
target_user_id: uuid.UUID | None
status: InviteStatus
expires_at: datetime
created_at: datetime
+27
View File
@@ -3,10 +3,18 @@ from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field
from app.models import RoomRole
class RoomCreate(BaseModel):
name: str = Field(min_length=1, max_length=100)
description: str | None = Field(default=None, max_length=2000)
is_private: bool = False
class RoomUpdate(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=100)
description: str | None = Field(default=None, max_length=2000)
class RoomRead(BaseModel):
@@ -22,3 +30,22 @@ class RoomRead(BaseModel):
class RoomListItem(RoomRead):
is_member: bool
class MyRoomItem(RoomRead):
role: RoomRole
class RoomMemberRead(BaseModel):
user_id: uuid.UUID
username: str
role: RoomRole
joined_at: datetime
class RoomMemberRoleUpdate(BaseModel):
role: RoomRole
class TransferOwnershipRequest(BaseModel):
new_owner_user_id: uuid.UUID
+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
+137 -4
View File
@@ -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()
+13 -1
View File
@@ -42,7 +42,13 @@ async def db_session():
engine = create_async_engine(TEST_DATABASE_URL)
async with engine.connect() as conn:
await conn.begin()
session = AsyncSession(bind=conn, join_transaction_mode="create_savepoint")
# expire_on_commit=False matches app/database.py's production session
# factory -- without it, objects loaded earlier in a request (e.g.
# current_user) go stale after any service-layer commit and touching
# them raises MissingGreenlet on the next sync attribute access.
session = AsyncSession(
bind=conn, join_transaction_mode="create_savepoint", expire_on_commit=False
)
yield session
await session.close()
await conn.rollback()
@@ -106,6 +112,12 @@ async def register_and_login(
data = UserCreate(username=username, email=f"{username}@example.com", password=password)
await register_user(db_session, data)
return await login_as(client, username, password)
async def login_as(client: AsyncClient, username: str, password: str = "password123"):
# Switch the shared `client`'s session cookie to an already-created user,
# without trying to register them again.
resp = await client.post(
"/api/auth/login",
json={"username_or_email": username, "password": password},
+166
View File
@@ -0,0 +1,166 @@
import uuid
from datetime import datetime, timedelta, timezone
from app.models import RoomInvite
from tests.conftest import login_as, register_and_login
async def _create_private_room(client, name="secret"):
resp = await client.post("/api/rooms", json={"name": name, "is_private": True})
assert resp.status_code == 201, resp.text
return resp.json()
async def test_create_invite_requires_admin(client, db_session):
await register_and_login(client, db_session, username="alice")
room = await _create_private_room(client)
await client.post("/api/auth/logout")
await register_and_login(client, db_session, username="bob")
await register_and_login(client, db_session, username="carol")
# bob has no membership in the room at all, so he's blocked by the
# membership check before role is even considered.
resp = await client.post(
f"/api/rooms/{room['id']}/invites", json={"target_username": "carol"}
)
assert resp.status_code == 403
async def test_invite_unknown_username_404(client, db_session):
await register_and_login(client, db_session, username="alice")
room = await _create_private_room(client)
resp = await client.post(
f"/api/rooms/{room['id']}/invites", json={"target_username": "nobody"}
)
assert resp.status_code == 404
async def test_invite_accept_flow(client, db_session):
await register_and_login(client, db_session, username="alice")
room = await _create_private_room(client)
await register_and_login(client, db_session, username="bob") # seed bob's account only
await login_as(client, "alice")
resp = await client.post(
f"/api/rooms/{room['id']}/invites", json={"target_username": "bob"}
)
assert resp.status_code == 201
invite = resp.json()
assert invite["status"] == "pending"
await client.post("/api/auth/logout")
await login_as(client, "bob")
resp = await client.get("/api/invites/mine")
assert resp.status_code == 200
mine = resp.json()
assert len(mine) == 1
assert mine[0]["id"] == invite["id"]
resp = await client.post(f"/api/invites/{invite['id']}/accept")
assert resp.status_code == 200
assert resp.json()["role"] == "member"
resp = await client.get(f"/api/rooms/{room['id']}/messages")
assert resp.status_code == 200 # now a member
resp = await client.get("/api/rooms/mine")
assert any(r["name"] == room["name"] for r in resp.json())
async def test_accept_invite_wrong_user_403(client, db_session):
await register_and_login(client, db_session, username="alice")
room = await _create_private_room(client)
await register_and_login(client, db_session, username="bob")
await login_as(client, "alice")
invite = (
await client.post(f"/api/rooms/{room['id']}/invites", json={"target_username": "bob"})
).json()
await client.post("/api/auth/logout")
await register_and_login(client, db_session, username="carol")
resp = await client.post(f"/api/invites/{invite['id']}/accept")
assert resp.status_code == 403
async def test_decline_invite(client, db_session):
await register_and_login(client, db_session, username="alice")
room = await _create_private_room(client)
await register_and_login(client, db_session, username="bob")
await login_as(client, "alice")
invite = (
await client.post(f"/api/rooms/{room['id']}/invites", json={"target_username": "bob"})
).json()
await client.post("/api/auth/logout")
await login_as(client, "bob")
resp = await client.post(f"/api/invites/{invite['id']}/decline")
assert resp.status_code == 200
assert resp.json()["status"] == "revoked"
resp = await client.post(f"/api/invites/{invite['id']}/accept")
assert resp.status_code == 400 # no longer pending
async def test_revoke_invite(client, db_session):
await register_and_login(client, db_session, username="alice")
room = await _create_private_room(client)
await register_and_login(client, db_session, username="bob")
await login_as(client, "alice")
invite = (
await client.post(f"/api/rooms/{room['id']}/invites", json={"target_username": "bob"})
).json()
resp = await client.delete(f"/api/rooms/{room['id']}/invites/{invite['id']}")
assert resp.status_code == 204
await client.post("/api/auth/logout")
await login_as(client, "bob")
resp = await client.post(f"/api/invites/{invite['id']}/accept")
assert resp.status_code == 400
async def test_duplicate_pending_invite_rejected(client, db_session):
await register_and_login(client, db_session, username="alice")
room = await _create_private_room(client)
await register_and_login(client, db_session, username="bob")
await login_as(client, "alice")
resp1 = await client.post(
f"/api/rooms/{room['id']}/invites", json={"target_username": "bob"}
)
assert resp1.status_code == 201
resp2 = await client.post(
f"/api/rooms/{room['id']}/invites", json={"target_username": "bob"}
)
assert resp2.status_code == 409
async def test_invite_already_member_rejected(client, db_session):
await register_and_login(client, db_session, username="alice")
room = await _create_private_room(client, name="open-ish")
resp = await client.post(
f"/api/rooms/{room['id']}/invites", json={"target_username": "alice"}
)
assert resp.status_code == 409
async def test_expired_invite_rejected_on_accept(client, db_session):
await register_and_login(client, db_session, username="alice")
room = await _create_private_room(client)
await register_and_login(client, db_session, username="bob")
await login_as(client, "alice")
invite = (
await client.post(f"/api/rooms/{room['id']}/invites", json={"target_username": "bob"})
).json()
db_invite = await db_session.get(RoomInvite, uuid.UUID(invite["id"]))
db_invite.expires_at = datetime.now(timezone.utc) - timedelta(days=1)
await db_session.commit()
await client.post("/api/auth/logout")
await login_as(client, "bob")
resp = await client.post(f"/api/invites/{invite['id']}/accept")
assert resp.status_code == 400
+181 -1
View File
@@ -3,7 +3,7 @@ import uuid
from sqlalchemy import select
from app.models import Room, RoomMembership, RoomRole
from tests.conftest import register_and_login
from tests.conftest import login_as, register_and_login
async def test_create_room_requires_auth(client):
@@ -75,3 +75,183 @@ async def test_join_private_room_400(client, db_session):
resp = await client.post(f"/api/rooms/{private_room.id}/join")
assert resp.status_code == 400
async def test_create_private_room_excluded_from_open_list_but_in_mine(client, db_session):
await register_and_login(client, db_session, username="alice")
resp = await client.post("/api/rooms", json={"name": "secret", "is_private": True})
assert resp.status_code == 201
assert resp.json()["is_private"] is True
open_names = {r["name"] for r in (await client.get("/api/rooms")).json()}
assert "secret" not in open_names
mine = (await client.get("/api/rooms/mine")).json()
assert mine[0]["name"] == "secret"
assert mine[0]["role"] == "owner"
async def test_update_room_requires_admin(client, db_session):
await register_and_login(client, db_session, username="alice")
room_id = (await client.post("/api/rooms", json={"name": "general"})).json()["id"]
await client.post("/api/auth/logout")
await register_and_login(client, db_session, username="bob")
await client.post(f"/api/rooms/{room_id}/join")
resp = await client.patch(f"/api/rooms/{room_id}", json={"description": "nope"})
assert resp.status_code == 403
await client.post("/api/auth/logout")
await login_as(client, "alice")
resp = await client.patch(f"/api/rooms/{room_id}", json={"description": "updated"})
assert resp.status_code == 200
assert resp.json()["description"] == "updated"
async def test_delete_room_owner_only(client, db_session):
await register_and_login(client, db_session, username="alice")
room_id = (await client.post("/api/rooms", json={"name": "general"})).json()["id"]
await client.post("/api/auth/logout")
await register_and_login(client, db_session, username="bob")
await client.post(f"/api/rooms/{room_id}/join")
resp = await client.delete(f"/api/rooms/{room_id}")
assert resp.status_code == 403
await client.post("/api/auth/logout")
await login_as(client, "alice")
resp = await client.delete(f"/api/rooms/{room_id}")
assert resp.status_code == 204
result = await db_session.execute(
select(RoomMembership).where(RoomMembership.room_id == uuid.UUID(room_id))
)
assert result.scalar_one_or_none() is None
async def test_leave_room(client, db_session):
await register_and_login(client, db_session, username="alice")
room_id = (await client.post("/api/rooms", json={"name": "general"})).json()["id"]
resp = await client.post(f"/api/rooms/{room_id}/leave")
assert resp.status_code == 400 # owner must transfer first
await client.post("/api/auth/logout")
await register_and_login(client, db_session, username="bob")
await client.post(f"/api/rooms/{room_id}/join")
resp = await client.post(f"/api/rooms/{room_id}/leave")
assert resp.status_code == 204
resp = await client.get(f"/api/rooms/{room_id}/messages")
assert resp.status_code == 403 # no longer a member
async def test_remove_member(client, db_session):
await register_and_login(client, db_session, username="alice")
room_id = (await client.post("/api/rooms", json={"name": "general"})).json()["id"]
await client.post("/api/auth/logout")
bob = await register_and_login(client, db_session, username="bob")
await client.post(f"/api/rooms/{room_id}/join")
await client.post("/api/auth/logout")
await login_as(client, "alice")
resp = await client.delete(f"/api/rooms/{room_id}/members/{bob['id']}")
assert resp.status_code == 204
resp = await client.delete(f"/api/rooms/{room_id}/members/{bob['id']}")
assert resp.status_code == 404
async def test_admin_cannot_remove_another_admin(client, db_session):
await register_and_login(client, db_session, username="alice")
room_id = (await client.post("/api/rooms", json={"name": "general"})).json()["id"]
await client.post("/api/auth/logout")
bob = await register_and_login(client, db_session, username="bob")
await client.post(f"/api/rooms/{room_id}/join")
await client.post("/api/auth/logout")
carol = await register_and_login(client, db_session, username="carol")
await client.post(f"/api/rooms/{room_id}/join")
await client.post("/api/auth/logout")
await login_as(client, "alice")
resp = await client.patch(f"/api/rooms/{room_id}/members/{bob['id']}", json={"role": "admin"})
assert resp.status_code == 200
assert resp.json()["role"] == "admin"
resp = await client.patch(f"/api/rooms/{room_id}/members/{carol['id']}", json={"role": "admin"})
assert resp.status_code == 200
await client.post("/api/auth/logout")
await login_as(client, "bob")
resp = await client.delete(f"/api/rooms/{room_id}/members/{carol['id']}")
assert resp.status_code == 403
async def test_cannot_remove_owner(client, db_session):
await register_and_login(client, db_session, username="alice")
room_id = (await client.post("/api/rooms", json={"name": "general"})).json()["id"]
await client.post("/api/auth/logout")
bob = await register_and_login(client, db_session, username="bob")
await client.post(f"/api/rooms/{room_id}/join")
await client.post("/api/auth/logout")
await login_as(client, "alice")
await client.patch(f"/api/rooms/{room_id}/members/{bob['id']}", json={"role": "admin"})
resp = await client.delete(f"/api/rooms/{room_id}/members/{(await client.get('/api/auth/me')).json()['id']}")
assert resp.status_code == 400
async def test_transfer_ownership(client, db_session):
await register_and_login(client, db_session, username="alice")
room_id = (await client.post("/api/rooms", json={"name": "general"})).json()["id"]
await client.post("/api/auth/logout")
bob = await register_and_login(client, db_session, username="bob")
await client.post(f"/api/rooms/{room_id}/join")
await client.post("/api/auth/logout")
await login_as(client, "alice")
resp = await client.post(
f"/api/rooms/{room_id}/transfer-ownership", json={"new_owner_user_id": bob["id"]}
)
assert resp.status_code == 200
assert resp.json()["owner_id"] == bob["id"]
resp = await client.post(f"/api/rooms/{room_id}/leave")
assert resp.status_code == 204 # alice is admin now, not owner, so she can leave
result = await db_session.execute(
select(RoomMembership).where(
RoomMembership.room_id == uuid.UUID(room_id), RoomMembership.user_id == uuid.UUID(bob["id"])
)
)
assert result.scalar_one().role == RoomRole.owner
async def test_change_member_role_owner_only(client, db_session):
await register_and_login(client, db_session, username="alice")
room_id = (await client.post("/api/rooms", json={"name": "general"})).json()["id"]
await client.post("/api/auth/logout")
bob = await register_and_login(client, db_session, username="bob")
await client.post(f"/api/rooms/{room_id}/join")
resp = await client.patch(f"/api/rooms/{room_id}/members/{bob['id']}", json={"role": "admin"})
assert resp.status_code == 403 # bob is a plain member, not owner
async def test_list_room_members(client, db_session):
await register_and_login(client, db_session, username="alice")
room_id = (await client.post("/api/rooms", json={"name": "general"})).json()["id"]
resp = await client.get(f"/api/rooms/{room_id}/members")
assert resp.status_code == 200
members = resp.json()
assert len(members) == 1
assert members[0]["username"] == "alice"
assert members[0]["role"] == "owner"