Private
Public Access
Add resizable room panel, searchable user picker, and direct room membership
Room info panel is now user-resizable (fixing a layout clip at narrow widths), and every user-selection spot (room membership, admin ownership transfer) uses a new searchable UserPicker instead of raw text input or prompt(). Member rows fold role + actions into a single inline dropdown instead of a row of buttons, so the member list stays usable as rooms grow. Room invites (the accept/decline flow) are replaced by adding a user to a room directly -- an admin/owner picks someone and they're a member immediately, with a "you've been added" notification email instead of an invite email. Drops the now-unused room_invites table.
This commit is contained in:
@@ -1,170 +0,0 @@
|
||||
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, Room, RoomInvite, RoomMembership, RoomRole, User
|
||||
from app.services.email_service import send_email
|
||||
|
||||
|
||||
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,
|
||||
base_url: 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)
|
||||
invite.target_user = target
|
||||
|
||||
room = await db.get(Room, room_id)
|
||||
await send_email(
|
||||
db,
|
||||
target.email,
|
||||
f"You've been invited to #{room.name}" if room else "You've been invited to a room",
|
||||
f"You've been invited to join a room on KeepItTalking.\n\n"
|
||||
f"Open the app to accept: {base_url.rstrip('/')}",
|
||||
)
|
||||
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)
|
||||
.options(selectinload(RoomInvite.target_user))
|
||||
)
|
||||
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), selectinload(RoomInvite.inviter))
|
||||
)
|
||||
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
|
||||
@@ -5,8 +5,9 @@ from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.models import Message, Room, RoomInvite, RoomMembership, RoomRole
|
||||
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):
|
||||
@@ -37,6 +38,14 @@ 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,
|
||||
@@ -108,6 +117,41 @@ async def join_room(db: AsyncSession, room_id: uuid.UUID, user_id: uuid.UUID) ->
|
||||
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 KeepItTalking.\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
|
||||
@@ -126,7 +170,6 @@ 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()
|
||||
|
||||
Reference in New Issue
Block a user