diff --git a/Back/README.md b/Back/README.md index 1845761..af97574 100644 --- a/Back/README.md +++ b/Back/README.md @@ -1,142 +1,58 @@ # NEDA Backend -NEDA is a real-time group voice communication backend designed for wearable devices (e.g., smartwatches). -It enables secure, low-latency push-to-talk audio communication within isolated groups. +NEDA is a FastAPI backend for real-time group voice communication. +It includes authentication, user/group management, WebSocket signaling, Redis for real-time state, and PostgreSQL for persistent data. -This repository contains the FastAPI backend, realtime control layer, and database schema. +## Tech Stack ---- +- FastAPI + Uvicorn +- SQLAlchemy (Async) + asyncpg +- PostgreSQL +- Redis +- Alembic +- LiveKit -# โœจ Features - -- Real-time push-to-talk voice groups -- Single active speaker per group -- Secure group isolation -- Role-based group permissions -- Admin-managed membership -- Redis-based realtime state -- LiveKit media integration -- Async PostgreSQL (SQLAlchemy) -- Alembic migrations -- WebSocket signaling layer - ---- - -# ๐Ÿงฑ Architecture - -NEDA follows a **domain-oriented layered modular monolith** architecture. +## Project Structure +```text +core/ config, security, deps +alembic/ database migrations +db/ database/redis setup +domains/ domain modules (auth, users, admin, groups, realtime, notifications) +integrations/ external integrations (LiveKit) +scripts/ utility scripts (create_admin) +main.py FastAPI app entrypoint ``` -core/ shared infrastructure -db/ database & redis -domains/ business domains -integrations/ external services -alembic/ migrations +## Active Routes -``` +- `POST /auth/login` +- `GET /users/` +- `POST /admin/users` +- `POST /admin/users/{user_id}/logout` +- `POST /admin/users/{user_id}/reset-secret` +- `GET /admin/users` +- `GET /admin/groups` +- `POST /groups/` +- `GET /groups/my` +- `GET /groups/admin/all` +- `GET /groups/{group_id}/members` +- `POST /groups/{group_id}/invite` +- `DELETE /groups/{group_id}/members/{user_id}` +- `WS /ws/groups/{group_id}` -Domains: +## Environment Variables -- users -- groups -- realtime -- auth -- admin +Create a `.env` file in the project root: -This design keeps domain logic isolated and allows future service extraction. - ---- - -# ๐ŸŽ™๏ธ Realtime Model - -- Audio media โ†’ LiveKit -- Signaling โ†’ WebSocket (FastAPI) -- State โ†’ Redis -- Persistence โ†’ PostgreSQL - -Active speaker is stored in Redis: - -``` - -speaker:{group_id} = user_id - -``` - -Presence: - -``` - -presence:{group_id} = set(user_ids) - -```` - ---- - -# ๐Ÿ‘ฅ Roles - -System role (User): - -- `admin` -- `user` - -Group role (GroupMember): - -- `group_manager` (exactly one per group) -- `member` - -Only admins can: - -- create groups -- assign group manager -- add/remove members - -Group managers have realtime authority only (speaker control). - ---- - -# ๐Ÿ—„๏ธ Database - -Core entities: - -- User -- Group -- GroupMember -- Session -- GroupVoiceSession -- SpeakerHistory - -Rules: - -- soft delete for main entities -- single active group_manager per group -- unique membership (user, group) - ---- - -# ๐Ÿš€ Running with Docker - -```bash -docker compose up --build -```` - -Services: - -* API โ†’ [http://localhost:8000](http://localhost:8000) -* Docs โ†’ [http://localhost:8000/docs](http://localhost:8000/docs) -* LiveKit โ†’ [http://localhost:7880](http://localhost:7880) -* Postgres โ†’ 5432 -* Redis โ†’ 6379 - ---- - -# โš™๏ธ Environment - -`.env` - -``` +```env APP_NAME=NEDA +DEBUG=False + SECRET_KEY=change-me +ACCESS_TOKEN_EXPIRE_MINUTES=30 +ALGORITHM=HS256 +SECRET_PASS_LENGTH=32 POSTGRES_DB=neda POSTGRES_USER=neda_user @@ -150,96 +66,114 @@ LIVEKIT_API_SECRET=neda_secret LIVEKIT_HOST=http://livekit:7880 ``` ---- - -# ๐Ÿงช Development Setup - -Create venv and install: +## Run With Docker ```bash +docker compose up --build -d +``` + +Services: +- API: `http://localhost:8000` +- Swagger Docs: `http://localhost:8000/docs` +- LiveKit: `http://localhost:7880` +- Postgres: `localhost:5432` +- Redis: `localhost:6379` + +API logs: + +```bash +docker compose logs -f api +``` + +## Create Admin (Inside Docker) + +After services are up, create the initial admin user inside the API container: + +```bash +docker compose exec api python -m scripts.create_admin +``` + +The script asks for `username` and optional `phone_number`, creates the user with `is_admin=True`, and prints the initial `secret`. + +## Admin API Hardening + +- `POST /admin/users` creates only non-admin users. +- Sending `is_admin` in the payload is not allowed and returns `422`. +- Admin creation is only allowed through the server-side script: `scripts/create_admin.py`. + +Invalid payload example: + +```json +{ + "username": "new_user", + "phone_number": "09123456789", + "is_admin": true +} +``` + +Admin login: +- Endpoint: `POST /auth/login` +- Body: + +```json +{ + "username": "admin_username", + "secret": "printed_secret" +} +``` + +## Database Migrations (Alembic) + +This project uses Alembic, and `env.py` reads `DATABASE_URL` from `.env`. + +Apply migrations (inside Docker): + +```bash +docker compose exec api alembic upgrade head +``` + +Create a new migration (autogenerate): + +```bash +docker compose exec api alembic revision --autogenerate -m "your_message" +``` + +Check current DB revision: + +```bash +docker compose exec api alembic current +``` + +Show migration history: + +```bash +docker compose exec api alembic history +``` + +Important: run `alembic upgrade head` before starting the API in a new environment. + +## Local Development (Without Docker) + +```bash +python3 -m venv .venv +source .venv/bin/activate pip install -r requirements.txt ``` -Run API: - -```bash -uvicorn neda.main:app --reload -``` - ---- - -# ๐Ÿ“œ Migrations (Alembic) - -Init (first time): - -```bash -alembic init alembic -``` - -Create migration: - -```bash -alembic revision --autogenerate -m "init" -``` - -Apply: +Run migrations: ```bash alembic upgrade head ``` ---- +Start API: -# ๐Ÿ”Œ Realtime Flow - -Request to speak: - -1. Client โ†’ WS `REQUEST_TALK` -2. Backend โ†’ Redis `SET NX speaker:{group}` -3. If granted โ†’ LiveKit publish token -4. Others โ†’ subscribers -5. Release โ†’ Redis delete - ---- - -# ๐Ÿงญ Project Structure - -``` -domains/ - users/ - groups/ - realtime/ - auth/ - admin/ +```bash +uvicorn main:app --reload ``` -Each domain contains: - -* models -* schemas -* repo -* service -* api - ---- - -# ๐Ÿง  Design Principles - -* realtime state outside DB -* single responsibility domains -* admin control plane -* Redis for locks/presence -* DB for long-term truth -* media separated from signaling - ---- - -# ๐Ÿ“ก Future Scaling - -The architecture supports: - -* realtime service extraction -* horizontal scaling -* sharded groups -* multi-tenant deployments +## Realtime Notes +- Presence is stored in Redis. +- Speaker lock is managed atomically in Redis. +- LiveKit tokens for listener/speaker roles are issued during the WebSocket flow. diff --git a/Back/alembic/env.py b/Back/alembic/env.py index c091022..32ed530 100644 --- a/Back/alembic/env.py +++ b/Back/alembic/env.py @@ -12,6 +12,7 @@ from db.base import Base import domains.users.models import domains.groups.models +import domains.notifications.models from alembic import context diff --git a/Back/alembic/versions/24e07dd1307e_feat_add_unique_constraint_to_.py b/Back/alembic/versions/24e07dd1307e_feat_add_unique_constraint_to_.py new file mode 100644 index 0000000..7ce576a --- /dev/null +++ b/Back/alembic/versions/24e07dd1307e_feat_add_unique_constraint_to_.py @@ -0,0 +1,32 @@ +"""feat: add unique constraint to GroupMember + +Revision ID: 24e07dd1307e +Revises: 4080314c8f5a +Create Date: 2026-03-08 15:57:14.560371 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '24e07dd1307e' +down_revision: Union[str, Sequence[str], None] = '4080314c8f5a' +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_unique_constraint('uq_group_member', 'group_members', ['user_id', 'group_id']) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint('uq_group_member', 'group_members', type_='unique') + # ### end Alembic commands ### diff --git a/Back/alembic/versions/4080314c8f5a_add_notifications.py b/Back/alembic/versions/4080314c8f5a_add_notifications.py new file mode 100644 index 0000000..92205d9 --- /dev/null +++ b/Back/alembic/versions/4080314c8f5a_add_notifications.py @@ -0,0 +1,88 @@ +"""add notifications + +Revision ID: 4080314c8f5a +Revises: b1f09d977759 +Create Date: 2026-03-07 16:16:30.792790 + +""" +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 = '4080314c8f5a' +down_revision: Union[str, Sequence[str], None] = 'b1f09d977759' +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! ### + group_member_role_enum = postgresql.ENUM( + 'MANAGER', + 'MEMBER', + name='group_member_role' + ) + group_member_role_enum.create(op.get_bind(), checkfirst=True) + + op.create_table('notifications', + sa.Column('title', sa.String(length=200), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('type', sa.Enum('PUBLIC', 'JOIN_REQUEST', name='notification_type'), nullable=False), + sa.Column('is_accepted', sa.Boolean(), nullable=True), + sa.Column('receiver_id', sa.UUID(), nullable=False), + sa.Column('sender_id', sa.UUID(), nullable=True), + sa.Column('group_id', sa.UUID(), nullable=True), + sa.Column('id', sa.UUID(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['group_id'], ['groups.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['receiver_id'], ['users.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['sender_id'], ['users.id'], ondelete='SET NULL'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_notifications_receiver_id'), 'notifications', ['receiver_id'], unique=False) + op.add_column( + 'group_members', + sa.Column( + 'role', + sa.Enum('MANAGER', 'MEMBER', name='group_member_role'), + nullable=False, + server_default='MEMBER' + ) + ) + op.alter_column('group_members', 'role', server_default=None) + op.add_column('users', sa.Column('is_admin', sa.Boolean(), nullable=False, server_default=sa.false())) + op.add_column('users', sa.Column('phone_number', sa.String(length=11), nullable=True)) + op.add_column('users', sa.Column('token_version', sa.Integer(), nullable=False, server_default='1')) + op.alter_column('users', 'is_admin', server_default=None) + op.alter_column('users', 'token_version', server_default=None) + op.drop_index(op.f('ix_users_role'), table_name='users') + op.create_index(op.f('ix_users_is_admin'), 'users', ['is_admin'], unique=False) + op.create_index(op.f('ix_users_phone_number'), 'users', ['phone_number'], unique=True) + op.drop_column('users', 'role') + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('users', sa.Column('role', postgresql.ENUM('ADMIN', 'GROUP_MANAGER', 'MEMBER', name='user_role'), autoincrement=False, nullable=False)) + op.drop_index(op.f('ix_users_phone_number'), table_name='users') + op.drop_index(op.f('ix_users_is_admin'), table_name='users') + op.create_index(op.f('ix_users_role'), 'users', ['role'], unique=False) + op.drop_column('users', 'token_version') + op.drop_column('users', 'phone_number') + op.drop_column('users', 'is_admin') + op.drop_column('group_members', 'role') + postgresql.ENUM( + 'MANAGER', + 'MEMBER', + name='group_member_role' + ).drop(op.get_bind(), checkfirst=True) + op.drop_index(op.f('ix_notifications_receiver_id'), table_name='notifications') + op.drop_table('notifications') + # ### end Alembic commands ### diff --git a/Back/core/security.py b/Back/core/security.py index 7ecf433..dbd6f1b 100755 --- a/Back/core/security.py +++ b/Back/core/security.py @@ -1,7 +1,7 @@ from passlib.context import CryptContext pwd_context = CryptContext( - schemes=["bcrypt"], + schemes=["argon2"], deprecated="auto", ) diff --git a/Back/domains/admin/api.py b/Back/domains/admin/api.py index 446a050..cc6dc8d 100644 --- a/Back/domains/admin/api.py +++ b/Back/domains/admin/api.py @@ -38,8 +38,7 @@ async def create_user( user, secret = await admin_create_user( db, payload.username, - payload.phone_number, - payload.is_admin + payload.phone_number ) except ValueError as e: @@ -97,7 +96,7 @@ async def list_users( db: AsyncSession = Depends(get_db), admin=Depends(get_current_admin) ): - return await get_all_users(db) + return await get_all_users(db, include_admin=True) @router.get("/groups", response_model=list[GroupResponse]) @@ -105,4 +104,4 @@ async def list_groups( db: AsyncSession = Depends(get_db), admin=Depends(get_current_admin) ): - return await get_all_groups(db) \ No newline at end of file + return await get_all_groups(db) diff --git a/Back/domains/admin/schemas.py b/Back/domains/admin/schemas.py index a0bb7f7..77c93ee 100644 --- a/Back/domains/admin/schemas.py +++ b/Back/domains/admin/schemas.py @@ -1,10 +1,10 @@ import uuid -from pydantic import BaseModel +from pydantic import BaseModel, ConfigDict class AdminCreateUser(BaseModel): + model_config = ConfigDict(extra="forbid") username: str phone_number: str | None = None - is_admin: bool = False class AdminUserResponse(BaseModel): id: uuid.UUID @@ -21,4 +21,4 @@ class AdminCreateUserResult(BaseModel): secret: str class AdminResetSecretResult(BaseModel): - secret: str \ No newline at end of file + secret: str diff --git a/Back/domains/admin/service.py b/Back/domains/admin/service.py index 1093459..e798163 100644 --- a/Back/domains/admin/service.py +++ b/Back/domains/admin/service.py @@ -1,4 +1,5 @@ import secrets +import uuid from sqlalchemy.ext.asyncio import AsyncSession @@ -10,18 +11,29 @@ from domains.users.repo import ( ) from core.security import hash_password -from core.config import settings def generate_user_secret(): - # return secrets.token_urlsafe(settings.SECRET_PASS_LENGTH) - return "1234" + return secrets.token_urlsafe(16) async def admin_create_user( db: AsyncSession, username: str, - phone_number: str | None = None, - is_admin: bool = False + phone_number: str | None = None +): + return await _create_user_with_role( + db=db, + username=username, + phone_number=phone_number, + is_admin=False + ) + + +async def _create_user_with_role( + db: AsyncSession, + username: str, + phone_number: str | None, + is_admin: bool ): existing = await get_user_by_username(db, username) @@ -45,7 +57,7 @@ async def admin_create_user( async def admin_logout_user( db: AsyncSession, - user_id: str + user_id: str | uuid.UUID ): user = await get_user_by_id(db, user_id) if not user: @@ -58,7 +70,7 @@ async def admin_logout_user( async def admin_reset_user_secret( db: AsyncSession, - user_id + user_id: str | uuid.UUID ): user = await get_user_by_id(db, user_id) @@ -68,4 +80,4 @@ async def admin_reset_user_secret( new_secret = generate_user_secret() user.secret_hash = hash_password(new_secret) await db.commit() - return new_secret \ No newline at end of file + return new_secret diff --git a/Back/domains/groups/models.py b/Back/domains/groups/models.py index 688e397..dea0c26 100644 --- a/Back/domains/groups/models.py +++ b/Back/domains/groups/models.py @@ -1,7 +1,7 @@ import uuid from enum import Enum -from sqlalchemy import String, Boolean, ForeignKey, Enum as SQLEnum +from sqlalchemy import String, Boolean, ForeignKey, Enum as SQLEnum, UniqueConstraint from sqlalchemy.orm import Mapped, mapped_column, relationship from typing import TYPE_CHECKING @@ -45,6 +45,9 @@ class Group(Base): class GroupMember(Base): __tablename__ = "group_members" # type: ignore + __table_args__ = ( + UniqueConstraint("user_id", "group_id", name="uq_group_member"), + ) user_id: Mapped[uuid.UUID] = mapped_column( ForeignKey("users.id", ondelete="CASCADE"), @@ -64,4 +67,4 @@ class GroupMember(Base): # Relationships group: Mapped["Group"] = relationship(back_populates="members") - user: Mapped["User"] = relationship() \ No newline at end of file + user: Mapped["User"] = relationship() diff --git a/Back/domains/groups/repo.py b/Back/domains/groups/repo.py index c159701..a8c8414 100644 --- a/Back/domains/groups/repo.py +++ b/Back/domains/groups/repo.py @@ -1,5 +1,6 @@ -from sqlalchemy import select import uuid + +from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from domains.groups.models import Group, GroupMember @@ -11,7 +12,7 @@ async def create_group(db: AsyncSession, group: Group): return group -async def get_group_by_id(db: AsyncSession, group_id): +async def get_group_by_id(db: AsyncSession, group_id: uuid.UUID): result = await db.execute( select(Group).where(Group.id == group_id) ) @@ -23,7 +24,7 @@ async def add_group_member(db: AsyncSession, membership: GroupMember): return membership -async def get_user_groups(db: AsyncSession, user_id): +async def get_user_groups(db: AsyncSession, user_id: uuid.UUID): result = await db.execute( select(Group) .join(GroupMember) @@ -70,4 +71,4 @@ async def delete_group_member(db: AsyncSession, group_id: uuid.UUID, user_id: uu .where(GroupMember.group_id == group_id) .where(GroupMember.user_id == user_id) ) - await db.commit() \ No newline at end of file + await db.commit() diff --git a/Back/domains/groups/service.py b/Back/domains/groups/service.py index f851a28..a165e0c 100644 --- a/Back/domains/groups/service.py +++ b/Back/domains/groups/service.py @@ -1,6 +1,7 @@ -from sqlalchemy.ext.asyncio import AsyncSession import uuid +from sqlalchemy.ext.asyncio import AsyncSession + from domains.users.repo import get_user_by_id from domains.groups.models import Group, GroupMember, GroupType, GroupMemberRole from domains.groups.repo import ( @@ -10,6 +11,7 @@ from domains.groups.repo import ( get_user_groups, get_group_members_with_details, delete_group_member, + get_group_member, get_all_groups as repo_get_all_groups ) from domains.realtime.presence_service import list_online_users @@ -18,7 +20,7 @@ from domains.realtime.presence_service import list_online_users async def create_new_group( db: AsyncSession, name: str, - creator_id, + creator_id: uuid.UUID, is_admin: bool ): group_type = GroupType.PUBLIC if is_admin else GroupType.PRIVATE @@ -43,18 +45,29 @@ async def create_new_group( async def invite_member_to_group( db: AsyncSession, - group_id, - sender_id, + group_id: str | uuid.UUID, + sender_id: uuid.UUID, target_username: str ): from domains.users.repo import get_user_by_username from domains.notifications.service import send_join_request + group_id_uuid = group_id if isinstance(group_id, uuid.UUID) else uuid.UUID(group_id) + # 1. Check if group exists - group = await get_group_by_id(db, group_id) + group = await get_group_by_id(db, group_id_uuid) if not group: raise ValueError("Group not found") + sender = await get_user_by_id(db, sender_id) + if not sender: + raise ValueError("Sender not found") + + if not sender.is_admin: + membership = await get_group_member(db, group_id_uuid, sender_id) + if not membership: + raise ValueError("Not a group member") + # 2. Check if target user exists target_user = await get_user_by_username(db, target_username) if not target_user: @@ -63,9 +76,9 @@ async def invite_member_to_group( # 3. Send notification (Req 12) return await send_join_request( db, - sender_id=str(sender_id), - receiver_id=str(target_user.id), - group_id=str(group.id), + sender_id=sender_id, + receiver_id=target_user.id, + group_id=group.id, title="Group Invitation", description=f"You have been invited to join group {group.name}" ) @@ -73,13 +86,20 @@ async def invite_member_to_group( async def add_member_to_group( db: AsyncSession, - group_id, - user_id, + group_id: str | uuid.UUID, + user_id: str | uuid.UUID, role: GroupMemberRole = GroupMemberRole.MEMBER ): + group_id_uuid = group_id if isinstance(group_id, uuid.UUID) else uuid.UUID(group_id) + user_id_uuid = user_id if isinstance(user_id, uuid.UUID) else uuid.UUID(user_id) + + existing = await get_group_member(db, group_id_uuid, user_id_uuid) + if existing: + raise ValueError("User already in group") + membership = GroupMember( - group_id=group_id, - user_id=user_id, + group_id=group_id_uuid, + user_id=user_id_uuid, role=role ) return await add_group_member(db, membership) @@ -87,7 +107,7 @@ async def add_member_to_group( async def list_user_groups( db: AsyncSession, - user_id + user_id: uuid.UUID ): return await get_user_groups(db, user_id) @@ -115,20 +135,17 @@ async def list_group_members_api(db: AsyncSession, group_id: str): async def remove_member_from_group( db: AsyncSession, - group_id, - target_user_id, + group_id: str | uuid.UUID, + target_user_id: str | uuid.UUID, requesting_user ): group_id_uuid = group_id if isinstance(group_id, uuid.UUID) else uuid.UUID(group_id) target_user_id_uuid = target_user_id if isinstance(target_user_id, uuid.UUID) else uuid.UUID(target_user_id) - # Req 13: Admin or Group Manager can remove + # Admin can remove anyone if not requesting_user.is_admin: - # Check if requesting user is manager of this group - from domains.groups.repo import get_group_member membership = await get_group_member(db, group_id_uuid, requesting_user.id) if not membership or membership.role != GroupMemberRole.MANAGER: - raise ValueError("Permission denied. Only admin or group manager can remove members.") + raise ValueError("Permission denied") - # For now, let's assume we implement the check here or in repo. - await delete_group_member(db, group_id_uuid, target_user_id_uuid) \ No newline at end of file + await delete_group_member(db, group_id_uuid, target_user_id_uuid) diff --git a/Back/domains/notifications/api.py b/Back/domains/notifications/api.py index aa8a09a..6aa73f7 100644 --- a/Back/domains/notifications/api.py +++ b/Back/domains/notifications/api.py @@ -54,7 +54,7 @@ async def broadcast_notification( admin=Depends(get_current_admin) ): users = await get_all_users(db) - user_ids = [str(u.id) for u in users] + user_ids = [u.id for u in users] return await send_public_notification( db, title, diff --git a/Back/domains/notifications/repo.py b/Back/domains/notifications/repo.py index e50b464..0c97b05 100644 --- a/Back/domains/notifications/repo.py +++ b/Back/domains/notifications/repo.py @@ -1,3 +1,5 @@ +import uuid + from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from domains.notifications.models import Notification @@ -8,13 +10,13 @@ async def create_notification(db: AsyncSession, notification: Notification): await db.refresh(notification) return notification -async def get_notification_by_id(db: AsyncSession, notification_id): +async def get_notification_by_id(db: AsyncSession, notification_id: uuid.UUID): result = await db.execute( select(Notification).where(Notification.id == notification_id) ) return result.scalar_one_or_none() -async def get_user_notifications(db: AsyncSession, user_id): +async def get_user_notifications(db: AsyncSession, user_id: uuid.UUID): result = await db.execute( select(Notification) .where(Notification.receiver_id == user_id) diff --git a/Back/domains/notifications/service.py b/Back/domains/notifications/service.py index bfbfeea..32b215e 100644 --- a/Back/domains/notifications/service.py +++ b/Back/domains/notifications/service.py @@ -1,4 +1,7 @@ +import uuid + from sqlalchemy.ext.asyncio import AsyncSession + from domains.notifications.models import Notification, NotificationType from domains.notifications.repo import ( create_notification, @@ -11,8 +14,8 @@ async def send_public_notification( db: AsyncSession, title: str, description: str, - sender_id: str, - receiver_ids: list[str] + sender_id: uuid.UUID, + receiver_ids: list[uuid.UUID] ): notifications = [] for receiver_id in receiver_ids: @@ -31,9 +34,9 @@ async def send_public_notification( async def send_join_request( db: AsyncSession, - sender_id: str, - receiver_id: str, - group_id: str, + sender_id: uuid.UUID, + receiver_id: uuid.UUID, + group_id: uuid.UUID, title: str, description: str | None = None ): @@ -47,16 +50,17 @@ async def send_join_request( ) return await create_notification(db, notification) -async def list_my_notifications(db: AsyncSession, user_id): +async def list_my_notifications(db: AsyncSession, user_id: uuid.UUID): return await get_user_notifications(db, user_id) async def respond_to_notification( db: AsyncSession, - notification_id: str, - user_id: str, + notification_id: str | uuid.UUID, + user_id: uuid.UUID, is_accepted: bool ): - notification = await get_notification_by_id(db, notification_id) + notification_id_uuid = notification_id if isinstance(notification_id, uuid.UUID) else uuid.UUID(notification_id) + notification = await get_notification_by_id(db, notification_id_uuid) if not notification: raise ValueError("Notification not found") @@ -69,6 +73,6 @@ async def respond_to_notification( # If it's a join request and accepted, add user to group if notification.type == NotificationType.JOIN_REQUEST and is_accepted: from domains.groups.service import add_member_to_group - await add_member_to_group(db, notification.group_id, user_id) + await add_member_to_group(db, notification.group_id, user_id) # type: ignore return notification diff --git a/Back/domains/realtime/presence_service.py b/Back/domains/realtime/presence_service.py index 2af7d47..f31e0ca 100644 --- a/Back/domains/realtime/presence_service.py +++ b/Back/domains/realtime/presence_service.py @@ -1,3 +1,5 @@ +import uuid + from db.redis import ( add_presence, remove_presence, @@ -5,22 +7,22 @@ from db.redis import ( ) -async def user_join_group(group_id: str, user_id: str): +async def user_join_group(group_id: str | uuid.UUID, user_id: str | uuid.UUID): """ Called when websocket connects """ - await add_presence(group_id, user_id) + await add_presence(str(group_id), str(user_id)) -async def user_leave_group(group_id: str, user_id: str): +async def user_leave_group(group_id: str | uuid.UUID, user_id: str | uuid.UUID): """ Called when websocket disconnects """ - await remove_presence(group_id, user_id) + await remove_presence(str(group_id), str(user_id)) -async def list_online_users(group_id: str): +async def list_online_users(group_id: str | uuid.UUID): """ Returns online users in a group """ - return await get_presence(group_id) \ No newline at end of file + return await get_presence(str(group_id)) diff --git a/Back/domains/realtime/speaker_service.py b/Back/domains/realtime/speaker_service.py index 785783b..089749b 100644 --- a/Back/domains/realtime/speaker_service.py +++ b/Back/domains/realtime/speaker_service.py @@ -1,3 +1,5 @@ +import uuid + from sqlalchemy.ext.asyncio import AsyncSession from db.redis import ( @@ -7,42 +9,44 @@ from db.redis import ( ) from domains.groups.repo import get_group_by_id -from domains.groups.models import GroupType from integrations.livekit.token_service import generate_join_token async def request_speak( db: AsyncSession, - group_id: str, - user_id: str + group_id: str | uuid.UUID, + user_id: str | uuid.UUID ): + group_id_uuid = group_id if isinstance(group_id, uuid.UUID) else uuid.UUID(group_id) + group_id_str = str(group_id_uuid) + user_id_str = str(user_id) - group = await get_group_by_id(db, group_id) + group = await get_group_by_id(db, group_id_uuid) if not group: return None # direct chat โ†’ no speaker lock - if group.type == GroupType.DIRECT: + if str(group.type) == "direct": token = generate_join_token( - user_id=user_id, - group_id=group_id, + user_id=user_id_str, + group_id=group_id_str, can_publish=True ) return token # group chat โ†’ push-to-talk - granted = await acquire_speaker(group_id, user_id) + granted = await acquire_speaker(group_id_str, user_id_str) if not granted: return None token = generate_join_token( - user_id=user_id, - group_id=group_id, + user_id=user_id_str, + group_id=group_id_str, can_publish=True ) @@ -51,33 +55,38 @@ async def request_speak( async def stop_speaking( db: AsyncSession, - group_id: str, - user_id: str + group_id: str | uuid.UUID, + user_id: str | uuid.UUID ): + group_id_uuid = group_id if isinstance(group_id, uuid.UUID) else uuid.UUID(group_id) + group_id_str = str(group_id_uuid) + user_id_str = str(user_id) - group = await get_group_by_id(db, group_id) + group = await get_group_by_id(db, group_id_uuid) if not group: return False # direct chat โ†’ nothing to release - if group.type == GroupType.DIRECT: + if str(group.type) == "direct": return True - return await release_speaker(group_id, user_id) + return await release_speaker(group_id_str, user_id_str) async def current_speaker( db: AsyncSession, - group_id: str + group_id: str | uuid.UUID ): + group_id_uuid = group_id if isinstance(group_id, uuid.UUID) else uuid.UUID(group_id) + group_id_str = str(group_id_uuid) - group = await get_group_by_id(db, group_id) + group = await get_group_by_id(db, group_id_uuid) if not group: return None - if group.type == GroupType.DIRECT: + if str(group.type) == "direct": return None - return await get_active_speaker(group_id) \ No newline at end of file + return await get_active_speaker(group_id_str) diff --git a/Back/domains/realtime/ws.py b/Back/domains/realtime/ws.py index 7729553..42a6e6c 100644 --- a/Back/domains/realtime/ws.py +++ b/Back/domains/realtime/ws.py @@ -1,6 +1,10 @@ +import uuid + from fastapi import APIRouter, WebSocket, WebSocketDisconnect from core.websocket import get_ws_current_user +from db.session import AsyncSessionLocal +from domains.groups.repo import get_group_member from domains.realtime.ws_manager import manager from domains.realtime.presence_service import ( @@ -16,7 +20,6 @@ from domains.realtime.speaker_service import ( ) from integrations.livekit.token_service import generate_join_token -from db.session import AsyncSessionLocal router = APIRouter() @@ -25,6 +28,18 @@ router = APIRouter() async def group_ws(websocket: WebSocket, group_id: str): user = await get_ws_current_user(websocket) + try: + group_id_uuid = uuid.UUID(group_id) + except ValueError: + await websocket.close(code=1008) + return + + async with AsyncSessionLocal() as db: + membership = await get_group_member(db, group_id_uuid, user.id) + if not membership: + await websocket.close(code=1008) + return + user_id = str(user.id) # connect websocket @@ -55,49 +70,46 @@ async def group_ws(websocket: WebSocket, group_id: str): ) try: - - while True: - data = await websocket.receive_json() - event = data.get("type") - # user wants to speak - if event == "request_speak": - async with AsyncSessionLocal() as db: + async with AsyncSessionLocal() as db: + while True: + data = await websocket.receive_json() + event = data.get("type") + # user wants to speak + if event == "request_speak": token = await request_speak( db, group_id, user_id ) - if token: + if token: + await manager.broadcast( + group_id, + { + "type": "speaker", + "user_id": user_id + } + ) + await websocket.send_json({ + "type": "speaker_granted", + "token": token + }) + else: + speaker = await current_speaker(db, group_id) + + await websocket.send_json({ + "type": "speaker_busy", + "speaker": speaker + }) + + # user stops speaking + elif event == "stop_speak": + await stop_speaking(db, group_id, user_id) await manager.broadcast( group_id, { - "type": "speaker", - "user_id": user_id + "type": "speaker_released" } ) - await websocket.send_json({ - "type": "speaker_granted", - "token": token - }) - else: - async with AsyncSessionLocal() as db: - speaker = await current_speaker(db, group_id) - - await websocket.send_json({ - "type": "speaker_busy", - "speaker": speaker - }) - - # user stops speaking - elif event == "stop_speak": - async with AsyncSessionLocal() as db: - await stop_speaking(db, group_id, user_id) - await manager.broadcast( - group_id, - { - "type": "speaker_released" - } - ) except WebSocketDisconnect: manager.disconnect(group_id, websocket) @@ -108,4 +120,4 @@ async def group_ws(websocket: WebSocket, group_id: str): "type": "presence", "users": await list_online_users(group_id) } - ) \ No newline at end of file + ) diff --git a/Back/domains/users/repo.py b/Back/domains/users/repo.py index 369ce25..9fd87f4 100644 --- a/Back/domains/users/repo.py +++ b/Back/domains/users/repo.py @@ -1,15 +1,17 @@ +import uuid + from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from domains.users.models import User -async def get_user_by_id(db: AsyncSession, user_id): +async def get_user_by_id(db: AsyncSession, user_id: str | uuid.UUID): result = await db.execute( select(User).where(User.id == user_id) ) return result.scalar_one_or_none() -async def get_user_by_username(db: AsyncSession, username): +async def get_user_by_username(db: AsyncSession, username: str): result = await db.execute( select(User).where(User.username == username) ) @@ -21,6 +23,10 @@ async def create_user(db: AsyncSession, user: User): await db.refresh(user) return user -async def get_all_users(db: AsyncSession): - result = await db.execute(select(User)) - return result.scalars().all() \ No newline at end of file +async def get_all_users(db: AsyncSession, include_admin: bool = False): + query = select(User) + if not include_admin: + query = query.where(User.is_admin.is_(False)) + + result = await db.execute(query) + return result.scalars().all() diff --git a/Back/domains/users/schemas.py b/Back/domains/users/schemas.py index ffdc732..ecd0d7d 100644 --- a/Back/domains/users/schemas.py +++ b/Back/domains/users/schemas.py @@ -1,5 +1,4 @@ import uuid -from pydantic import BaseModel from pydantic import BaseModel, Field, field_validator import re @@ -13,8 +12,8 @@ class UserCreate(BaseModel): def validate_phone_number(cls, v: str | None) -> str | None: if v is None: return v - if not re.match(r"^\d{11}$", v): - raise ValueError("Phone number must be exactly 11 digits") + if not re.match(r"^09\d{9}$", v): + raise ValueError("Phone number must start with 09 and be exactly 11 digits") return v class UserResponse(BaseModel): @@ -29,4 +28,4 @@ class UserResponse(BaseModel): class UserCreateResult(BaseModel): user: UserResponse - secret: str \ No newline at end of file + secret: str diff --git a/Back/domains/users/service.py b/Back/domains/users/service.py index 4f5f00b..ae9cddc 100644 --- a/Back/domains/users/service.py +++ b/Back/domains/users/service.py @@ -1,6 +1,8 @@ +import uuid + from sqlalchemy.ext.asyncio import AsyncSession from domains.users.repo import get_user_by_id -async def get_user(db: AsyncSession, user_id): - return await get_user_by_id(db, user_id) \ No newline at end of file +async def get_user(db: AsyncSession, user_id: str | uuid.UUID): + return await get_user_by_id(db, user_id) diff --git a/Back/main.py b/Back/main.py index 94bd0d2..79a86da 100644 --- a/Back/main.py +++ b/Back/main.py @@ -17,7 +17,7 @@ async def lifespan(app: FastAPI): # ---------- Startup ---------- try: - await redis_client.ping() + await redis_client.ping() # type: ignore print("Redis connected") except Exception as e: @@ -58,14 +58,4 @@ app.include_router(auth_router) app.include_router(users_router) app.include_router(admin_router) app.include_router(groups_router) -app.include_router(realtime_router) - - -# ------------------------- -# Health Check -# ------------------------- - -@app.get("/health") -async def health_check(): - - return {"status": "ok"} \ No newline at end of file +app.include_router(realtime_router) \ No newline at end of file diff --git a/Back/scripts/create_admin.py b/Back/scripts/create_admin.py index 71ab7c1..a633c65 100644 --- a/Back/scripts/create_admin.py +++ b/Back/scripts/create_admin.py @@ -1,36 +1,38 @@ import asyncio import secrets -from sqlalchemy.ext.asyncio import AsyncSession - from db.session import AsyncSessionLocal +from domains.users.models import User from core.security import hash_password -from domains.users.models import User, UserRole - - -async def create_admin(): - - username = input("Admin username: ") - +async def create_admin() -> None: + username = input("Admin username: ").strip() + phone_number = input("Phone number (optional, 11 digits): ").strip() or None + # secret = secrets.token_urlsafe(16) secret = "1234" - + async with AsyncSessionLocal() as db: + try: + user = User( + username=username, + phone_number=phone_number, + is_admin=True, + secret_hash=hash_password(secret), + ) - user = User( - username=username, - role=UserRole.ADMIN, - secret_hash=hash_password(secret), - ) + db.add(user) + await db.commit() - db.add(user) - await db.commit() + except ValueError as exc: + print(f"\nError: {exc}\n") + return print("\nAdmin created successfully\n") - print("Username:", username) + print("Username:", user.username) + print("Phone number:", user.phone_number) print("Secret:", secret) print("\nSave this secret!\n") if __name__ == "__main__": - asyncio.run(create_admin()) \ No newline at end of file + asyncio.run(create_admin())