Private
Public Access
Site admins can invite a brand-new person by email from the Admin portal Users tab -- a signup-link email lets them set their own username/password and lands them in the app already logged in. Existing users invited to a room now also get an email. Closes the "invited but never notified" gap from both directions. SMTP is configured through the Admin Settings tab at runtime (not the env file), persisted in a new smtp_settings table with the password encrypted at rest via a Fernet key derived from SESSION_SECRET -- the first reversible secret this app stores in the database. A "send test email" button surfaces real delivery errors; the invite/notification paths themselves never fail loudly, since an SMTP outage shouldn't block an action that already succeeded in the database. New site_invites table mirrors RoomInvite's shape but targets an email address with no room context; the raw signup token is hashed the same way API tokens are, and only ever exists in the email link. POST /api/signup is the first genuinely public, unauthenticated account-creation endpoint in this app, reusing the existing register_user path for identical validation.
171 lines
4.9 KiB
Python
171 lines
4.9 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, 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
|