Files
ds-chat/backend/app/services/invite_service.py
T
ksmithandClaude Sonnet 5 e9fcb9fea2 Rebuild frontend from the Claude Design handoff, DarkSingularity brand
Replaces the Phase 1 placeholder UI with a single persistent app shell (top
bar + sidebar + chat pane, 860px responsive breakpoint) matching the
"PWA chat system UI" design handoff: message bubbles with consecutive-run
avatar/name grouping, auto-growing composer, room search, and the real
DarkSingularity logo (also used to regenerate the PWA icons).

The handoff didn't cover Phase 2 (private rooms, roles, invites) or
browsing/joining open rooms, so those are added using the same visual
language: a room info panel with role badges, invite-by-username with a
pending-invites list, member management (remove/promote/demote/transfer
ownership), room settings (rename/describe/delete), and separate
browse-rooms/invites-inbox modals. Unread badges, last-message preview, and
the typing indicator are deliberately deferred -- both need new backend
features (read-tracking, a WS typing event) that weren't in scope this pass.

Two small backend additions round out data the new UI needs but the API
didn't expose: MessageRead.username (historic messages had no sender name)
and InviteRead.target_username / MyInviteRead.room_name+invited_by_username
(a recipient's invite list can't otherwise resolve a room they're not in).
Both are additive; 35 backend tests still pass.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 21:10:58 -06:00

157 lines
4.5 KiB
Python

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)
invite.target_user = target
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