Private
Public Access
Phase 2: private rooms, room roles, and invites (backend only)
Adds room_invites table + migration, owner/admin/member role enforcement (require_room_role), and endpoints for private room creation, room management (update/delete/leave/transfer-ownership/change-role/remove-member), and the invite lifecycle (create/list/accept/decline/revoke). Registration stays invite-only via the CLI from Phase 1 — this is a separate, room-level invite system for adding existing users to private rooms. Frontend is untouched: the UI redesign is happening separately, so this phase is backend + tests only (35 passing). Verified no regressions in the Phase 1 open-room/WebSocket flow via manual smoke test. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,53 @@
|
||||
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])
|
||||
Reference in New Issue
Block a user