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
-2
@@ -11,7 +11,7 @@ from redis.asyncio import Redis
|
||||
from starlette.middleware.sessions import SessionMiddleware
|
||||
|
||||
from app.config import settings
|
||||
from app.routers import admin, auth, bots, health, invites, push, rooms, signup, users, webhooks
|
||||
from app.routers import admin, auth, bots, health, push, rooms, signup, users, webhooks
|
||||
from app.ws.broadcaster import RoomBroadcaster
|
||||
from app.ws.chat import router as ws_router
|
||||
from app.ws.connection_manager import ConnectionManager
|
||||
@@ -75,7 +75,6 @@ def create_app() -> FastAPI:
|
||||
app.include_router(signup.router)
|
||||
app.include_router(rooms.router)
|
||||
app.include_router(users.router)
|
||||
app.include_router(invites.router)
|
||||
app.include_router(push.router)
|
||||
app.include_router(admin.router)
|
||||
app.include_router(bots.router)
|
||||
|
||||
@@ -2,7 +2,7 @@ from app.models.admin_audit_log import AdminAuditLog
|
||||
from app.models.api_token import ApiToken
|
||||
from app.models.base import Base
|
||||
from app.models.event_subscription import EventSubscription
|
||||
from app.models.invite import InviteStatus, RoomInvite
|
||||
from app.models.invite import InviteStatus
|
||||
from app.models.membership import RoomMembership, RoomRole
|
||||
from app.models.message import Message
|
||||
from app.models.message_image import MessageImage
|
||||
@@ -23,7 +23,6 @@ __all__ = [
|
||||
"Message",
|
||||
"MessageImage",
|
||||
"MessageReaction",
|
||||
"RoomInvite",
|
||||
"InviteStatus",
|
||||
"SiteInvite",
|
||||
"SmtpSettings",
|
||||
|
||||
@@ -1,53 +1,7 @@
|
||||
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])
|
||||
|
||||
@@ -16,7 +16,7 @@ def _default_expires_at() -> datetime:
|
||||
|
||||
class SiteInvite(Base):
|
||||
"""An admin-issued invite for someone with no account yet -- distinct
|
||||
from RoomInvite, which targets an existing user for a specific room."""
|
||||
from adding an existing user directly to a room."""
|
||||
|
||||
__tablename__ = "site_invites"
|
||||
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
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, MyInviteRead
|
||||
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[MyInviteRead])
|
||||
async def list_my_invites_endpoint(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
invites = await list_my_invites(db, current_user.id)
|
||||
return [
|
||||
MyInviteRead(
|
||||
id=i.id,
|
||||
room_id=i.room_id,
|
||||
invited_by=i.invited_by,
|
||||
target_user_id=i.target_user_id,
|
||||
status=i.status,
|
||||
expires_at=i.expires_at,
|
||||
created_at=i.created_at,
|
||||
room_name=i.room.name,
|
||||
invited_by_username=i.inviter.username,
|
||||
)
|
||||
for i in invites
|
||||
]
|
||||
|
||||
|
||||
@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,
|
||||
display_name=current_user.display_name,
|
||||
avatar_filename=current_user.avatar_filename,
|
||||
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")
|
||||
@@ -12,29 +12,19 @@ from app.dependencies import (
|
||||
require_scope,
|
||||
)
|
||||
from app.models import MessageImage, RoomRole, User
|
||||
from app.schemas.invite import InviteCreate, InviteRead
|
||||
from app.schemas.message import MessageRead
|
||||
from app.schemas.message_image import MessageImageCreated
|
||||
from app.schemas.room import (
|
||||
MyRoomItem,
|
||||
RoomCreate,
|
||||
RoomListItem,
|
||||
RoomMemberAdd,
|
||||
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.schemas.webhook import (
|
||||
EventSubscriptionCreate,
|
||||
EventSubscriptionCreated,
|
||||
@@ -44,6 +34,7 @@ from app.schemas.webhook import (
|
||||
)
|
||||
from app.services.message_service import get_reactions_for_messages, list_recent_messages
|
||||
from app.services.room_service import (
|
||||
AlreadyMemberError,
|
||||
CannotRemoveOwnerError,
|
||||
DuplicateRoomError,
|
||||
InsufficientRoleError,
|
||||
@@ -51,6 +42,8 @@ from app.services.room_service import (
|
||||
OwnerMustTransferError,
|
||||
RoomIsPrivateError,
|
||||
RoomNotFoundError,
|
||||
TargetUserNotFoundError,
|
||||
add_member,
|
||||
change_member_role,
|
||||
create_room,
|
||||
delete_room,
|
||||
@@ -373,66 +366,32 @@ async def get_room_image_endpoint(
|
||||
)
|
||||
|
||||
|
||||
def _to_invite_read(invite) -> InviteRead:
|
||||
return InviteRead(
|
||||
id=invite.id,
|
||||
room_id=invite.room_id,
|
||||
invited_by=invite.invited_by,
|
||||
target_user_id=invite.target_user_id,
|
||||
target_username=invite.target_user.username if invite.target_user else None,
|
||||
status=invite.status,
|
||||
expires_at=invite.expires_at,
|
||||
created_at=invite.created_at,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{room_id}/invites", response_model=InviteRead, status_code=201)
|
||||
async def create_invite_endpoint(
|
||||
@router.post("/{room_id}/members", response_model=RoomMemberRead, status_code=201)
|
||||
async def add_member_endpoint(
|
||||
room_id: uuid.UUID,
|
||||
data: InviteCreate,
|
||||
data: RoomMemberAdd,
|
||||
request: Request,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
await require_room_role(room_id, current_user, db, RoomRole.admin)
|
||||
try:
|
||||
invite = await create_invite(
|
||||
db, room_id, current_user.id, data.target_username, str(request.base_url)
|
||||
)
|
||||
room = await get_room(db, room_id)
|
||||
await require_room_role(room_id, current_user, db, RoomRole.admin)
|
||||
membership = await add_member(db, room, data.user_id, str(request.base_url))
|
||||
except RoomNotFoundError:
|
||||
raise HTTPException(status_code=404, detail="Room not found")
|
||||
except TargetUserNotFoundError:
|
||||
raise HTTPException(status_code=404, detail="No user with that username")
|
||||
raise HTTPException(status_code=404, detail="No user with that ID")
|
||||
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")
|
||||
return _to_invite_read(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)
|
||||
invites = await list_room_invites(db, room_id)
|
||||
return [_to_invite_read(i) for i in invites]
|
||||
|
||||
|
||||
@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")
|
||||
return RoomMemberRead(
|
||||
user_id=membership.user_id,
|
||||
username=membership.user.username,
|
||||
display_name=membership.user.display_name,
|
||||
avatar_filename=membership.user.avatar_filename,
|
||||
role=membership.role,
|
||||
joined_at=membership.joined_at,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{room_id}/webhooks/incoming", response_model=WebhookIncomingRead, status_code=201)
|
||||
|
||||
@@ -2,16 +2,31 @@ import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi.responses import FileResponse
|
||||
from sqlalchemy import select
|
||||
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.user import UserDirectoryRead
|
||||
from app.storage import UPLOADS_DIR
|
||||
|
||||
router = APIRouter(prefix="/api/users", tags=["users"])
|
||||
|
||||
|
||||
@router.get("", response_model=list[UserDirectoryRead])
|
||||
async def list_users_directory_endpoint(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(
|
||||
select(User)
|
||||
.where(User.is_active.is_(True), User.is_bot.is_(False))
|
||||
.order_by(User.username)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
@router.get("/{user_id}/avatar")
|
||||
async def get_user_avatar_endpoint(
|
||||
user_id: uuid.UUID,
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
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
|
||||
target_username: str | None = None
|
||||
status: InviteStatus
|
||||
expires_at: datetime
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class MyInviteRead(InviteRead):
|
||||
"""InviteRead plus context the recipient can't otherwise resolve client-side --
|
||||
GET /api/invites/mine is for rooms the user isn't a member of yet."""
|
||||
|
||||
room_name: str
|
||||
invited_by_username: str
|
||||
@@ -45,6 +45,10 @@ class RoomMemberRead(BaseModel):
|
||||
joined_at: datetime
|
||||
|
||||
|
||||
class RoomMemberAdd(BaseModel):
|
||||
user_id: uuid.UUID
|
||||
|
||||
|
||||
class RoomMemberRoleUpdate(BaseModel):
|
||||
role: RoomRole
|
||||
|
||||
|
||||
@@ -25,3 +25,17 @@ class UserRead(BaseModel):
|
||||
|
||||
class ProfileUpdate(BaseModel):
|
||||
display_name: str | None = Field(default=None, max_length=50)
|
||||
|
||||
|
||||
class UserDirectoryRead(BaseModel):
|
||||
"""Lightweight entry for user-picker UIs (room invites, admin ownership
|
||||
transfer) -- same visibility level as an avatar: any authenticated user
|
||||
can see this much about anyone (excludes bots, which aren't invited
|
||||
through these flows)."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: uuid.UUID
|
||||
username: str
|
||||
display_name: str | None
|
||||
avatar_filename: str | None
|
||||
|
||||
@@ -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