Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions alembic/versions/a37ad6ed5842_create_rooms_participants_and_.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
"""create_rooms_participants_and_invitations

Revision ID: a37ad6ed5842
Revises: a9a4f2fb79b5
Create Date: 2026-04-03 00:58:07.279213

"""

from typing import Sequence, Union

from alembic import op
import sqlalchemy as sa

# revision identifiers, used by Alembic.
revision: str = "a37ad6ed5842"
down_revision: Union[str, Sequence[str], None] = "a9a4f2fb79b5"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.add_column(
"participants", sa.Column("guest_session_id", sa.Uuid(), nullable=True)
)
op.add_column(
"participants",
sa.Column(
"display_name",
sa.String(length=255),
nullable=False,
server_default="Guest",
),
)
op.alter_column("participants", "user_id", existing_type=sa.UUID(), nullable=True)
op.create_index(
op.f("ix_participants_guest_session_id"),
"participants",
["guest_session_id"],
unique=False,
)
op.create_unique_constraint(
"uq_participant_room_guest", "participants", ["room_id", "guest_session_id"]
)
# ### end Alembic commands ###


def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint("uq_participant_room_guest", "participants", type_="unique")
op.drop_index(op.f("ix_participants_guest_session_id"), table_name="participants")
op.alter_column("participants", "user_id", existing_type=sa.UUID(), nullable=False)
op.drop_column("participants", "display_name")
op.drop_column("participants", "guest_session_id")
# ### end Alembic commands ###
138 changes: 138 additions & 0 deletions alembic/versions/a9a4f2fb79b5_create_rooms_participants_and_.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
"""create_rooms_participants_and_invitations

Revision ID: a9a4f2fb79b5
Revises: b3eb2b8ac7ed
Create Date: 2026-04-02 18:06:01.694264

"""

from typing import Sequence, Union

from alembic import op
import sqlalchemy as sa

# revision identifiers, used by Alembic.
revision: str = "a9a4f2fb79b5"
down_revision: Union[str, Sequence[str], None] = "b3eb2b8ac7ed"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"rooms",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("room_code", sa.String(length=12), nullable=False),
sa.Column("host_id", sa.Uuid(), nullable=False),
sa.Column("name", sa.String(length=255), nullable=False),
sa.Column("status", sa.String(length=10), nullable=False),
sa.Column("scheduled_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("ended_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("settings", sa.JSON(), nullable=False),
sa.ForeignKeyConstraint(
["host_id"],
["users.id"],
),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_rooms_host_id"), "rooms", ["host_id"], unique=False)
op.create_index(op.f("ix_rooms_id"), "rooms", ["id"], unique=False)
op.create_index(op.f("ix_rooms_room_code"), "rooms", ["room_code"], unique=True)
op.create_index(op.f("ix_rooms_status"), "rooms", ["status"], unique=False)
op.create_table(
"meeting_invitations",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("room_id", sa.Uuid(), nullable=False),
sa.Column("inviter_id", sa.Uuid(), nullable=False),
sa.Column("email", sa.String(length=255), nullable=False),
sa.Column("status", sa.String(length=10), nullable=False),
sa.Column("token", sa.String(length=64), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(
["inviter_id"],
["users.id"],
),
sa.ForeignKeyConstraint(
["room_id"],
["rooms.id"],
),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
op.f("ix_meeting_invitations_email"),
"meeting_invitations",
["email"],
unique=False,
)
op.create_index(
op.f("ix_meeting_invitations_id"), "meeting_invitations", ["id"], unique=False
)
op.create_index(
op.f("ix_meeting_invitations_room_id"),
"meeting_invitations",
["room_id"],
unique=False,
)
op.create_index(
op.f("ix_meeting_invitations_token"),
"meeting_invitations",
["token"],
unique=True,
)
op.create_table(
"participants",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("room_id", sa.Uuid(), nullable=False),
sa.Column("user_id", sa.Uuid(), nullable=False),
sa.Column("joined_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("left_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("role", sa.String(length=10), nullable=False),
sa.ForeignKeyConstraint(
["room_id"],
["rooms.id"],
),
sa.ForeignKeyConstraint(
["user_id"],
["users.id"],
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("room_id", "user_id", name="uq_participant_room_user"),
)
op.create_index(op.f("ix_participants_id"), "participants", ["id"], unique=False)
op.create_index(
op.f("ix_participants_room_id"), "participants", ["room_id"], unique=False
)
op.create_index(
op.f("ix_participants_user_id"), "participants", ["user_id"], unique=False
)
# ### end Alembic commands ###


def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f("ix_participants_user_id"), table_name="participants")
op.drop_index(op.f("ix_participants_room_id"), table_name="participants")
op.drop_index(op.f("ix_participants_id"), table_name="participants")
op.drop_table("participants")
op.drop_index(
op.f("ix_meeting_invitations_token"), table_name="meeting_invitations"
)
op.drop_index(
op.f("ix_meeting_invitations_room_id"), table_name="meeting_invitations"
)
op.drop_index(op.f("ix_meeting_invitations_id"), table_name="meeting_invitations")
op.drop_index(
op.f("ix_meeting_invitations_email"), table_name="meeting_invitations"
)
op.drop_table("meeting_invitations")
op.drop_index(op.f("ix_rooms_status"), table_name="rooms")
op.drop_index(op.f("ix_rooms_room_code"), table_name="rooms")
op.drop_index(op.f("ix_rooms_id"), table_name="rooms")
op.drop_index(op.f("ix_rooms_host_id"), table_name="rooms")
op.drop_table("rooms")
# ### end Alembic commands ###
11 changes: 10 additions & 1 deletion app/auth/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,9 +62,18 @@ async def signup(
@limiter.limit("10/minute")
async def login(
request: Request,
payload: LoginRequest,
payload: LoginRequest | None = None,
auth_service: AuthService = Depends(get_auth_service),
) -> JSONResponse:

if payload is None:
from app.core.exceptions import BadRequestException

raise BadRequestException(
code="MISSING_CREDENTIALS",
message="Email and password are required.",
)

del request # consumed by slowapi

login_response, refresh_token, refresh_ttl = await auth_service.login(payload)
Expand Down
49 changes: 46 additions & 3 deletions app/core/dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,11 @@
import logging

from fastapi import Depends
from fastapi.security import OAuth2PasswordBearer
from fastapi.security import (
HTTPAuthorizationCredentials,
HTTPBearer,
OAuth2PasswordBearer,
)
from jose import JWTError, jwt
from sqlalchemy import select
from sqlalchemy.orm import Session
Expand All @@ -24,17 +28,20 @@
oauth2_scheme = OAuth2PasswordBearer(
tokenUrl=f"{settings.API_V1_STR}/auth/login",
)
bearer_scheme = HTTPBearer(auto_error=False)


async def get_current_user(
token: str = Depends(oauth2_scheme),
token: str | None = Depends(oauth2_scheme),
bearer: HTTPAuthorizationCredentials | None = Depends(bearer_scheme),
db: Session = Depends(get_db),
token_store: TokenStoreService = Depends(get_token_store_service),
) -> User:
"""Decode an access-token JWT and return the authenticated user.

Guards
------
- Missing token → 401
- Invalid / expired JWT → 401
- Blacklisted JTI → 401
- User not found → 401
Expand All @@ -45,6 +52,16 @@ async def get_current_user(
-------
The :class:`~app.auth.models.User` ORM instance.
"""
# Prefer Bearer token if provided (e.g. from 'HTTP Bearer' field in Swagger)
# otherwise fall back to OAuth2 token (from 'Authorize' login form).
final_token = bearer.credentials if bearer else token

if not final_token:
raise UnauthorizedException(
code="MISSING_TOKEN",
message="Not authenticated",
)

credentials_exc = UnauthorizedException(
code="INVALID_CREDENTIALS",
message="Could not validate credentials.",
Expand All @@ -53,7 +70,7 @@ async def get_current_user(
# ── 1. Decode JWT ─────────────────────────────────────────────────
try:
payload = jwt.decode(
token,
final_token,
settings.SECRET_KEY,
algorithms=[settings.ALGORITHM],
)
Expand Down Expand Up @@ -94,3 +111,29 @@ async def get_current_user(
)

return user


oauth2_scheme_optional = OAuth2PasswordBearer(
tokenUrl=f"{settings.API_V1_STR}/auth/login",
auto_error=False,
)


async def get_current_user_optional(
token: str | None = Depends(oauth2_scheme_optional),
bearer: HTTPAuthorizationCredentials | None = Depends(bearer_scheme),
db: Session = Depends(get_db),
token_store: TokenStoreService = Depends(get_token_store_service),
) -> User | None:
"""Attempt to decode JWT and return User if present, otherwise return None."""
try:
user = await get_current_user(
token=token, bearer=bearer, db=db, token_store=token_store
)
return user
except UnauthorizedException:
# Happens if token is missing or generic Invalid Credentials
return None
except ForbiddenException:
# Happens if account is deleted or deactivated. Could also return None.
return None
Empty file added app/meeting/__init__.py
Empty file.
57 changes: 57 additions & 0 deletions app/meeting/constants.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
"""Constants for the meeting feature package."""

import enum
from typing import Final

# ── Magic Numbers & Defaults ──────────────────────────────────────────
ROOM_CODE_BYTE_LENGTH: Final = 9
MAX_ROOM_CODE_RETRIES: Final = 5
DEFAULT_ROOM_SETTINGS: Final = {
"lock_room": False,
"enable_transcription": False,
"max_participants": 20,
}


# ── Enums ─────────────────────────────────────────────────────────────
class RoomStatus(enum.StrEnum):
PENDING = "pending"
ACTIVE = "active"
ENDED = "ended"


class ParticipantRole(enum.StrEnum):
HOST = "host"
GUEST = "guest"


class InvitationStatus(enum.StrEnum):
PENDING = "pending"
ACCEPTED = "accepted"
DECLINED = "declined"
EXPIRED = "expired"


# ── Response messages ─────────────────────────────────────────────────
MSG_ROOM_CREATED = "Room created successfully."
MSG_ROOM_DETAILS = "Room details retrieved successfully."
MSG_ROOM_JOINED = "Joined room successfully."
MSG_ROOM_LEFT = "Left room successfully."
MSG_USER_ADMITTED = "User admitted to room."
MSG_MEETING_ENDED = "Meeting ended successfully."
MSG_ROOM_CONFIG_UPDATED = "Room configuration updated."
MSG_MEETING_HISTORY = "Meeting history retrieved successfully."
MSG_INVITATIONS_SENT = "Meeting invitations sent."


# ── Redis Key Patterns ────────────────────────────────────────────────
def key_room_participants(room_code: str) -> str:
return f"room:{room_code}:participants"


def key_room_lobby(room_code: str) -> str:
return f"room:{room_code}:lobby"


def key_room_active_speaker(room_code: str) -> str:
return f"room:{room_code}:active_speaker"
30 changes: 30 additions & 0 deletions app/meeting/dependencies.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
"""FastAPI dependencies for the meeting feature package."""

from fastapi import Depends
from sqlalchemy.orm import Session

from app.db.session import get_db
from app.meeting.repository import MeetingRepository
from app.meeting.service import MeetingService
from app.meeting.state import MeetingStateService


def get_meeting_repository(db: Session = Depends(get_db)) -> MeetingRepository:
"""Provide a MeetingRepository wired to the current DB session."""
return MeetingRepository(db=db)


def get_meeting_state_service() -> MeetingStateService:
"""Provide the Redis-backed state service.

Instantiates its own internally cached redis client if not passed.
"""
return MeetingStateService()


def get_meeting_service(
repo: MeetingRepository = Depends(get_meeting_repository),
state: MeetingStateService = Depends(get_meeting_state_service),
) -> MeetingService:
"""Provide the high-level business logic service."""
return MeetingService(repo=repo, state=state)
Loading
Loading