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:
2026-08-14 20:40:37 -06:00
parent b724f8a33b
commit 91589ee647
32 changed files with 735 additions and 1052 deletions
+40 -38
View File
@@ -1,16 +1,16 @@
# KeepItTalking backend (Phase 1 + 2 + 4 + 5 + 6 + 7 + 8, image uploads, emoji & reactions, user profiles, site invites & email)
FastAPI + SQLAlchemy 2.0 (async) + PostgreSQL + Redis. Implements auth, room
CRUD (open and private), room roles (owner/admin/member) and invites, a
WebSocket chat endpoint that fans out across multiple app-server instances
via Redis pub/sub, Web Push notifications for offline room members, a
site-admin portal (user/room/bot management + an audit log), a bot/
extension layer (scoped API tokens, live bot WebSocket access, incoming and
outgoing webhooks, message editing), image uploads in chat messages, emoji
reactions on messages, self-service user profiles (display name, avatar),
and admin-issued email invites for new accounts plus email notifications
for room invites. See `../ARCHITECTURE.md` for the full system design and
the phased build plan.
CRUD (open and private), room roles (owner/admin/member) and direct
membership management, a WebSocket chat endpoint that fans out across
multiple app-server instances via Redis pub/sub, Web Push notifications for
offline room members, a site-admin portal (user/room/bot management + an
audit log), a bot/extension layer (scoped API tokens, live bot WebSocket
access, incoming and outgoing webhooks, message editing), image uploads in
chat messages, emoji reactions on messages, self-service user profiles
(display name, avatar), and admin-issued email invites for new accounts
plus email notifications when a user is added to a room. 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.
Accounts are created by an operator on the app server — see step 4 below.
@@ -128,11 +128,11 @@ app/
cli.py `python -m app.cli create-user` / `generate-vapid-keys`
models/ SQLAlchemy models (users, rooms, room_memberships,
messages, message_images, message_reactions,
room_invites, site_invites, smtp_settings,
site_invites, smtp_settings,
push_subscriptions, admin_audit_log, api_tokens,
webhooks_incoming, event_subscriptions)
schemas/ Pydantic request/response models
routers/ auth, rooms, users, invites, signup, push, admin,
routers/ auth, rooms, users, signup, push, admin,
bots, webhooks, health
services/ business logic called by routers
ws/ connection_manager (local sockets), presence +
@@ -309,21 +309,25 @@ that point, so nothing online-facing is delayed, and it sidesteps
on. An expired/invalid subscription (pywebpush 404/410) is deleted
automatically.
## Room roles and invites (Phase 2)
## Room roles and membership (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`:
`private` (`is_private: true` at creation — joinable only by being added).
Room roles are `owner` > `admin` > `member`:
- **member**: post messages, leave the room
- **admin**: edit room settings, create/list/revoke invites, remove plain members
- **admin**: edit room settings, add/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.
Adding to a private room: an admin+ calls `POST /api/rooms/{id}/members` with
an existing user's `user_id` — this adds them straight to
`room_memberships` (no accept/decline step) and fires a "you've been added"
notification email (see Site invites & email below; silently skipped if
SMTP isn't configured). There used to be a separate accept/decline
`RoomInvite` flow here; it was removed in favor of direct add + notify,
since nothing meaningful was gained by making the target confirm first.
`GET /api/rooms/mine` lists every room (open + private) the current user
belongs to, alongside their role.
## Image uploads
@@ -423,10 +427,10 @@ change after the fact.
## Site invites & email
Two related gaps closed together: creating a new account was CLI-only, and
neither a brand-new invitee nor an existing user invited to a room got any
neither a brand-new invitee nor an existing user added to a room got any
notification. Site admins (only) invite a brand-new person by email from
the Admin portal; both that signup-invite and the existing room-invite flow
send an email.
the Admin portal; being added directly to a room (see Room roles and
membership above) sends a "you've been added" email too.
**Email sending** (`app/services/email_service.py`, using `aiosmtplib`):
`send_email(db, to, subject, body)` is the fire-and-forget path used by
@@ -450,11 +454,10 @@ password on update means "keep the current one" — the frontend never has
the plaintext to send back, only whether one is set (`has_password`).
**Site invites** (`app/models/site_invite.py`, `app/services/site_invite_service.py`) —
distinct from `RoomInvite` (existing user, specific room): this targets an
email address for the site, no room involved. The raw token exists only in
the email link, stored hashed (`security.hash_token`, the same convention
API tokens use — it's a bearer secret looked up by itself, not
`RoomInvite.token`'s current unhashed/unused column). `POST /api/signup`
distinct from adding an existing user to a room: this targets an email
address for the site, no room involved. The raw token exists only in the
email link, stored hashed (`security.hash_token`, the same convention API
tokens use — it's a bearer secret looked up by itself). `POST /api/signup`
(`app/routers/signup.py`) is the first genuinely public,
unauthenticated endpoint in this app that creates a `User` row — it calls
the existing `auth_service.register_user` directly for identical
@@ -464,9 +467,9 @@ already signed in. No new rate limiting on it — the unguessable, single-use,
expiring token is the actual protection, inheriting the same "no rate
limiting on human/bot traffic" gap already documented below, not a new one.
**Room-invite email**: `invite_service.create_invite` sends one email to
the target user after creating the `RoomInvite`, using the live request's
`base_url` for the link — no new "public URL" config needed.
**Room-membership email**: `room_service.add_member` sends one email to
the target user after creating the `RoomMembership`, using the live
request's `base_url` for the link — no new "public URL" config needed.
Scope cuts: no outgoing-webhook event type for these (matching image
uploads/reactions), no resend for a site invite (revoke + re-invite covers
@@ -475,18 +478,17 @@ it), no HTML email templates.
## Notes / scope decisions
- 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.
provisioned with `python -m app.cli create-user` (see step 4 above), or via
a site invite (see Site invites & email below). This is separate from
adding an existing user to a private room — site accounts vs. room
membership.
- 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.
- Deleting a room explicitly deletes its messages/memberships/invites first
- Deleting a room explicitly deletes its messages/memberships first
(`room_service.delete_room`) rather than relying on DB-level cascades.
- `admin_audit_log` has no admin UI for filtering/searching yet — it's a
flat newest-first list with `limit`/`offset` pagination, no filter by
@@ -0,0 +1,53 @@
"""drop room_invites (replaced by direct add-to-room)
Revision ID: a3f7c2e91b4d
Revises: 41139ce908df
Create Date: 2026-08-14 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = 'a3f7c2e91b4d'
down_revision: Union[str, Sequence[str], None] = '41139ce908df'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# Room invites are replaced by adding an existing user to a room
# directly (RoomMembership row + notification email, no accept step).
# The invite_status enum type stays -- site_invites still uses it.
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')
def downgrade() -> None:
"""Downgrade schema."""
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', postgresql.ENUM('pending', 'accepted', 'revoked', name='invite_status', create_type=False), 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)
+1 -2
View File
@@ -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)
+1 -2
View File
@@ -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",
-46
View File
@@ -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])
+1 -1
View File
@@ -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"
-86
View File
@@ -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")
+21 -62
View File
@@ -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)
+15
View File
@@ -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,
-31
View File
@@ -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
+4
View File
@@ -45,6 +45,10 @@ class RoomMemberRead(BaseModel):
joined_at: datetime
class RoomMemberAdd(BaseModel):
user_id: uuid.UUID
class RoomMemberRoleUpdate(BaseModel):
role: RoomRole
+14
View File
@@ -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
-170
View File
@@ -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
+45 -2
View File
@@ -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()
-233
View File
@@ -1,233 +0,0 @@
import uuid
from datetime import datetime, timedelta, timezone
from app.models import RoomInvite, User
from tests.conftest import login_as, register_and_login
async def _make_admin(db_session, user_id: str) -> None:
user = await db_session.get(User, uuid.UUID(user_id))
user.is_site_admin = True
await db_session.commit()
async def _configure_smtp(client):
resp = await client.put(
"/api/admin/settings/smtp",
json={
"host": "smtp.example.com",
"port": 587,
"from_address": "noreply@example.com",
},
)
assert resp.status_code == 200, resp.text
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"
assert invite["target_username"] == "bob"
resp = await client.get(f"/api/rooms/{room['id']}/invites")
assert resp.status_code == 200
assert resp.json()[0]["target_username"] == "bob"
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"]
assert mine[0]["room_name"] == room["name"]
assert mine[0]["invited_by_username"] == "alice"
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
async def test_create_invite_sends_email_to_target(client, db_session, monkeypatch):
calls = []
async def fake_send(message, **kwargs):
calls.append(kwargs)
monkeypatch.setattr("app.services.email_service.aiosmtplib.send", fake_send)
alice = await register_and_login(client, db_session, username="alice")
await _make_admin(db_session, alice["id"])
await _configure_smtp(client)
room = await _create_private_room(client)
await register_and_login(client, db_session, username="bob")
await login_as(client, "alice")
resp = await client.post(
f"/api/rooms/{room['id']}/invites", json={"target_username": "bob"}
)
assert resp.status_code == 201
assert len(calls) == 1
assert calls[0]["hostname"] == "smtp.example.com"
async def test_create_invite_succeeds_even_if_email_delivery_fails(client, db_session, monkeypatch):
async def fake_send(message, **kwargs):
raise ConnectionRefusedError("boom")
monkeypatch.setattr("app.services.email_service.aiosmtplib.send", fake_send)
alice = await register_and_login(client, db_session, username="alice")
await _make_admin(db_session, alice["id"])
await _configure_smtp(client)
room = await _create_private_room(client)
await register_and_login(client, db_session, username="bob")
await login_as(client, "alice")
resp = await client.post(
f"/api/rooms/{room['id']}/invites", json={"target_username": "bob"}
)
assert resp.status_code == 201
+76
View File
@@ -245,6 +245,82 @@ async def test_change_member_role_owner_only(client, db_session):
assert resp.status_code == 403 # bob is a plain member, not owner
def _fake_send_email(monkeypatch):
calls = []
async def fake(db, to, subject, body):
calls.append({"to": to, "subject": subject, "body": body})
monkeypatch.setattr("app.services.room_service.send_email", fake)
return calls
async def test_add_member_directly(client, db_session, monkeypatch):
calls = _fake_send_email(monkeypatch)
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("/api/auth/logout")
await login_as(client, "alice")
resp = await client.post(f"/api/rooms/{room_id}/members", json={"user_id": bob["id"]})
assert resp.status_code == 201, resp.text
assert resp.json()["username"] == "bob"
assert resp.json()["role"] == "member"
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.member
assert len(calls) == 1
assert calls[0]["to"] == bob["email"]
assert "added" in calls[0]["subject"].lower()
async def test_add_member_requires_admin_role(client, db_session, monkeypatch):
_fake_send_email(monkeypatch)
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")
carol = await register_and_login(client, db_session, username="carol")
resp = await client.post(f"/api/rooms/{room_id}/members", json={"user_id": carol["id"]})
assert resp.status_code == 403
async def test_add_member_already_member_conflict(client, db_session, monkeypatch):
_fake_send_email(monkeypatch)
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}/members", json={"user_id": bob["id"]})
assert resp.status_code == 409
async def test_add_member_unknown_user_404(client, db_session, monkeypatch):
_fake_send_email(monkeypatch)
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}/members", json={"user_id": str(uuid.uuid4())})
assert resp.status_code == 404
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"]
+58
View File
@@ -0,0 +1,58 @@
import uuid
from app.models import User
from app.services.bot_service import create_bot
from tests.conftest import register_and_login
def _unique(prefix: str) -> str:
return f"{prefix}-{uuid.uuid4().hex[:8]}"
async def _make_admin(db_session, user_id: str) -> None:
user = await db_session.get(User, uuid.UUID(user_id))
user.is_site_admin = True
await db_session.commit()
async def test_user_directory_requires_auth(client):
resp = await client.get("/api/users")
assert resp.status_code == 401
async def test_user_directory_lists_active_users(client, db_session):
alice = await register_and_login(client, db_session, username=_unique("alice"))
await register_and_login(client, db_session, username=_unique("bob"))
resp = await client.get("/api/users")
assert resp.status_code == 200
usernames = {u["username"] for u in resp.json()}
assert alice["username"] in usernames
entry = next(u for u in resp.json() if u["id"] == alice["id"])
assert entry["display_name"] is None
assert entry["avatar_filename"] is None
async def test_user_directory_excludes_bots(client, db_session):
admin = await register_and_login(client, db_session, username=_unique("admin"))
await _make_admin(db_session, admin["id"])
admin_user = await db_session.get(User, uuid.UUID(admin["id"]))
bot_username = _unique("bot")
await create_bot(db_session, admin_user, bot_username)
resp = await client.get("/api/users")
usernames = {u["username"] for u in resp.json()}
assert bot_username not in usernames
async def test_user_directory_excludes_deactivated_users(client, db_session):
admin = await register_and_login(client, db_session, username=_unique("admin"))
await _make_admin(db_session, admin["id"])
bob = await register_and_login(client, db_session, username=_unique("bob"))
await client.post("/api/auth/login", json={"username_or_email": admin["username"], "password": "password123"})
await client.post(f"/api/admin/users/{bob['id']}/deactivate")
resp = await client.get("/api/users")
usernames = {u["username"] for u in resp.json()}
assert bob["username"] not in usernames