feat: update Back Security

This commit is contained in:
roai_linux 2026-03-08 19:36:00 +03:30
parent a99d920f2c
commit f1f864d468
22 changed files with 477 additions and 362 deletions

View File

@ -1,142 +1,58 @@
# NEDA Backend # NEDA Backend
NEDA is a real-time group voice communication backend designed for wearable devices (e.g., smartwatches). NEDA is a FastAPI backend for real-time group voice communication.
It enables secure, low-latency push-to-talk audio communication within isolated groups. 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 ## Project Structure
- 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.
```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 ## Active Routes
db/ database & redis
domains/ business domains
integrations/ external services
alembic/ migrations
``` - `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 Create a `.env` file in the project root:
- groups
- realtime
- auth
- admin
This design keeps domain logic isolated and allows future service extraction. ```env
---
# 🎙️ 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`
```
APP_NAME=NEDA APP_NAME=NEDA
DEBUG=False
SECRET_KEY=change-me SECRET_KEY=change-me
ACCESS_TOKEN_EXPIRE_MINUTES=30
ALGORITHM=HS256
SECRET_PASS_LENGTH=32
POSTGRES_DB=neda POSTGRES_DB=neda
POSTGRES_USER=neda_user POSTGRES_USER=neda_user
@ -150,96 +66,114 @@ LIVEKIT_API_SECRET=neda_secret
LIVEKIT_HOST=http://livekit:7880 LIVEKIT_HOST=http://livekit:7880
``` ```
--- ## Run With Docker
# 🧪 Development Setup
Create venv and install:
```bash ```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 pip install -r requirements.txt
``` ```
Run API: Run migrations:
```bash
uvicorn neda.main:app --reload
```
---
# 📜 Migrations (Alembic)
Init (first time):
```bash
alembic init alembic
```
Create migration:
```bash
alembic revision --autogenerate -m "init"
```
Apply:
```bash ```bash
alembic upgrade head alembic upgrade head
``` ```
--- Start API:
# 🔌 Realtime Flow ```bash
uvicorn main:app --reload
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/
``` ```
Each domain contains: ## Realtime Notes
* 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
- Presence is stored in Redis.
- Speaker lock is managed atomically in Redis.
- LiveKit tokens for listener/speaker roles are issued during the WebSocket flow.

View File

@ -12,6 +12,7 @@ from db.base import Base
import domains.users.models import domains.users.models
import domains.groups.models import domains.groups.models
import domains.notifications.models
from alembic import context from alembic import context

View File

@ -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 ###

View File

@ -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 ###

View File

@ -1,7 +1,7 @@
from passlib.context import CryptContext from passlib.context import CryptContext
pwd_context = CryptContext( pwd_context = CryptContext(
schemes=["bcrypt"], schemes=["argon2"],
deprecated="auto", deprecated="auto",
) )

View File

@ -38,8 +38,7 @@ async def create_user(
user, secret = await admin_create_user( user, secret = await admin_create_user(
db, db,
payload.username, payload.username,
payload.phone_number, payload.phone_number
payload.is_admin
) )
except ValueError as e: except ValueError as e:
@ -97,7 +96,7 @@ async def list_users(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
admin=Depends(get_current_admin) 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]) @router.get("/groups", response_model=list[GroupResponse])

View File

@ -1,10 +1,10 @@
import uuid import uuid
from pydantic import BaseModel from pydantic import BaseModel, ConfigDict
class AdminCreateUser(BaseModel): class AdminCreateUser(BaseModel):
model_config = ConfigDict(extra="forbid")
username: str username: str
phone_number: str | None = None phone_number: str | None = None
is_admin: bool = False
class AdminUserResponse(BaseModel): class AdminUserResponse(BaseModel):
id: uuid.UUID id: uuid.UUID

View File

@ -1,4 +1,5 @@
import secrets import secrets
import uuid
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@ -10,18 +11,29 @@ from domains.users.repo import (
) )
from core.security import hash_password from core.security import hash_password
from core.config import settings
def generate_user_secret(): def generate_user_secret():
# return secrets.token_urlsafe(settings.SECRET_PASS_LENGTH) return secrets.token_urlsafe(16)
return "1234"
async def admin_create_user( async def admin_create_user(
db: AsyncSession, db: AsyncSession,
username: str, username: str,
phone_number: str | None = None, phone_number: str | None = None
is_admin: bool = False ):
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) existing = await get_user_by_username(db, username)
@ -45,7 +57,7 @@ async def admin_create_user(
async def admin_logout_user( async def admin_logout_user(
db: AsyncSession, db: AsyncSession,
user_id: str user_id: str | uuid.UUID
): ):
user = await get_user_by_id(db, user_id) user = await get_user_by_id(db, user_id)
if not user: if not user:
@ -58,7 +70,7 @@ async def admin_logout_user(
async def admin_reset_user_secret( async def admin_reset_user_secret(
db: AsyncSession, db: AsyncSession,
user_id user_id: str | uuid.UUID
): ):
user = await get_user_by_id(db, user_id) user = await get_user_by_id(db, user_id)

View File

@ -1,7 +1,7 @@
import uuid import uuid
from enum import Enum 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 sqlalchemy.orm import Mapped, mapped_column, relationship
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
@ -45,6 +45,9 @@ class Group(Base):
class GroupMember(Base): class GroupMember(Base):
__tablename__ = "group_members" # type: ignore __tablename__ = "group_members" # type: ignore
__table_args__ = (
UniqueConstraint("user_id", "group_id", name="uq_group_member"),
)
user_id: Mapped[uuid.UUID] = mapped_column( user_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), ForeignKey("users.id", ondelete="CASCADE"),

View File

@ -1,5 +1,6 @@
from sqlalchemy import select
import uuid import uuid
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from domains.groups.models import Group, GroupMember from domains.groups.models import Group, GroupMember
@ -11,7 +12,7 @@ async def create_group(db: AsyncSession, group: Group):
return 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( result = await db.execute(
select(Group).where(Group.id == group_id) select(Group).where(Group.id == group_id)
) )
@ -23,7 +24,7 @@ async def add_group_member(db: AsyncSession, membership: GroupMember):
return membership 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( result = await db.execute(
select(Group) select(Group)
.join(GroupMember) .join(GroupMember)

View File

@ -1,6 +1,7 @@
from sqlalchemy.ext.asyncio import AsyncSession
import uuid import uuid
from sqlalchemy.ext.asyncio import AsyncSession
from domains.users.repo import get_user_by_id from domains.users.repo import get_user_by_id
from domains.groups.models import Group, GroupMember, GroupType, GroupMemberRole from domains.groups.models import Group, GroupMember, GroupType, GroupMemberRole
from domains.groups.repo import ( from domains.groups.repo import (
@ -10,6 +11,7 @@ from domains.groups.repo import (
get_user_groups, get_user_groups,
get_group_members_with_details, get_group_members_with_details,
delete_group_member, delete_group_member,
get_group_member,
get_all_groups as repo_get_all_groups get_all_groups as repo_get_all_groups
) )
from domains.realtime.presence_service import list_online_users 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( async def create_new_group(
db: AsyncSession, db: AsyncSession,
name: str, name: str,
creator_id, creator_id: uuid.UUID,
is_admin: bool is_admin: bool
): ):
group_type = GroupType.PUBLIC if is_admin else GroupType.PRIVATE 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( async def invite_member_to_group(
db: AsyncSession, db: AsyncSession,
group_id, group_id: str | uuid.UUID,
sender_id, sender_id: uuid.UUID,
target_username: str target_username: str
): ):
from domains.users.repo import get_user_by_username from domains.users.repo import get_user_by_username
from domains.notifications.service import send_join_request 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 # 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: if not group:
raise ValueError("Group not found") 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 # 2. Check if target user exists
target_user = await get_user_by_username(db, target_username) target_user = await get_user_by_username(db, target_username)
if not target_user: if not target_user:
@ -63,9 +76,9 @@ async def invite_member_to_group(
# 3. Send notification (Req 12) # 3. Send notification (Req 12)
return await send_join_request( return await send_join_request(
db, db,
sender_id=str(sender_id), sender_id=sender_id,
receiver_id=str(target_user.id), receiver_id=target_user.id,
group_id=str(group.id), group_id=group.id,
title="Group Invitation", title="Group Invitation",
description=f"You have been invited to join group {group.name}" 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( async def add_member_to_group(
db: AsyncSession, db: AsyncSession,
group_id, group_id: str | uuid.UUID,
user_id, user_id: str | uuid.UUID,
role: GroupMemberRole = GroupMemberRole.MEMBER 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( membership = GroupMember(
group_id=group_id, group_id=group_id_uuid,
user_id=user_id, user_id=user_id_uuid,
role=role role=role
) )
return await add_group_member(db, membership) return await add_group_member(db, membership)
@ -87,7 +107,7 @@ async def add_member_to_group(
async def list_user_groups( async def list_user_groups(
db: AsyncSession, db: AsyncSession,
user_id user_id: uuid.UUID
): ):
return await get_user_groups(db, user_id) 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( async def remove_member_from_group(
db: AsyncSession, db: AsyncSession,
group_id, group_id: str | uuid.UUID,
target_user_id, target_user_id: str | uuid.UUID,
requesting_user requesting_user
): ):
group_id_uuid = group_id if isinstance(group_id, uuid.UUID) else uuid.UUID(group_id) 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) 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: 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) membership = await get_group_member(db, group_id_uuid, requesting_user.id)
if not membership or membership.role != GroupMemberRole.MANAGER: 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) await delete_group_member(db, group_id_uuid, target_user_id_uuid)

View File

@ -54,7 +54,7 @@ async def broadcast_notification(
admin=Depends(get_current_admin) admin=Depends(get_current_admin)
): ):
users = await get_all_users(db) 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( return await send_public_notification(
db, db,
title, title,

View File

@ -1,3 +1,5 @@
import uuid
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from domains.notifications.models import Notification from domains.notifications.models import Notification
@ -8,13 +10,13 @@ async def create_notification(db: AsyncSession, notification: Notification):
await db.refresh(notification) await db.refresh(notification)
return 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( result = await db.execute(
select(Notification).where(Notification.id == notification_id) select(Notification).where(Notification.id == notification_id)
) )
return result.scalar_one_or_none() 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( result = await db.execute(
select(Notification) select(Notification)
.where(Notification.receiver_id == user_id) .where(Notification.receiver_id == user_id)

View File

@ -1,4 +1,7 @@
import uuid
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from domains.notifications.models import Notification, NotificationType from domains.notifications.models import Notification, NotificationType
from domains.notifications.repo import ( from domains.notifications.repo import (
create_notification, create_notification,
@ -11,8 +14,8 @@ async def send_public_notification(
db: AsyncSession, db: AsyncSession,
title: str, title: str,
description: str, description: str,
sender_id: str, sender_id: uuid.UUID,
receiver_ids: list[str] receiver_ids: list[uuid.UUID]
): ):
notifications = [] notifications = []
for receiver_id in receiver_ids: for receiver_id in receiver_ids:
@ -31,9 +34,9 @@ async def send_public_notification(
async def send_join_request( async def send_join_request(
db: AsyncSession, db: AsyncSession,
sender_id: str, sender_id: uuid.UUID,
receiver_id: str, receiver_id: uuid.UUID,
group_id: str, group_id: uuid.UUID,
title: str, title: str,
description: str | None = None description: str | None = None
): ):
@ -47,16 +50,17 @@ async def send_join_request(
) )
return await create_notification(db, notification) 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) return await get_user_notifications(db, user_id)
async def respond_to_notification( async def respond_to_notification(
db: AsyncSession, db: AsyncSession,
notification_id: str, notification_id: str | uuid.UUID,
user_id: str, user_id: uuid.UUID,
is_accepted: bool 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: if not notification:
raise ValueError("Notification not found") 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 it's a join request and accepted, add user to group
if notification.type == NotificationType.JOIN_REQUEST and is_accepted: if notification.type == NotificationType.JOIN_REQUEST and is_accepted:
from domains.groups.service import add_member_to_group 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 return notification

View File

@ -1,3 +1,5 @@
import uuid
from db.redis import ( from db.redis import (
add_presence, add_presence,
remove_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 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 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 Returns online users in a group
""" """
return await get_presence(group_id) return await get_presence(str(group_id))

View File

@ -1,3 +1,5 @@
import uuid
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from db.redis import ( from db.redis import (
@ -7,42 +9,44 @@ from db.redis import (
) )
from domains.groups.repo import get_group_by_id from domains.groups.repo import get_group_by_id
from domains.groups.models import GroupType
from integrations.livekit.token_service import generate_join_token from integrations.livekit.token_service import generate_join_token
async def request_speak( async def request_speak(
db: AsyncSession, db: AsyncSession,
group_id: str, group_id: str | uuid.UUID,
user_id: str 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: if not group:
return None return None
# direct chat → no speaker lock # direct chat → no speaker lock
if group.type == GroupType.DIRECT: if str(group.type) == "direct":
token = generate_join_token( token = generate_join_token(
user_id=user_id, user_id=user_id_str,
group_id=group_id, group_id=group_id_str,
can_publish=True can_publish=True
) )
return token return token
# group chat → push-to-talk # 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: if not granted:
return None return None
token = generate_join_token( token = generate_join_token(
user_id=user_id, user_id=user_id_str,
group_id=group_id, group_id=group_id_str,
can_publish=True can_publish=True
) )
@ -51,33 +55,38 @@ async def request_speak(
async def stop_speaking( async def stop_speaking(
db: AsyncSession, db: AsyncSession,
group_id: str, group_id: str | uuid.UUID,
user_id: str 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: if not group:
return False return False
# direct chat → nothing to release # direct chat → nothing to release
if group.type == GroupType.DIRECT: if str(group.type) == "direct":
return True return True
return await release_speaker(group_id, user_id) return await release_speaker(group_id_str, user_id_str)
async def current_speaker( async def current_speaker(
db: AsyncSession, 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: if not group:
return None return None
if group.type == GroupType.DIRECT: if str(group.type) == "direct":
return None return None
return await get_active_speaker(group_id) return await get_active_speaker(group_id_str)

View File

@ -1,6 +1,10 @@
import uuid
from fastapi import APIRouter, WebSocket, WebSocketDisconnect from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from core.websocket import get_ws_current_user 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.ws_manager import manager
from domains.realtime.presence_service import ( 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 integrations.livekit.token_service import generate_join_token
from db.session import AsyncSessionLocal
router = APIRouter() router = APIRouter()
@ -25,6 +28,18 @@ router = APIRouter()
async def group_ws(websocket: WebSocket, group_id: str): async def group_ws(websocket: WebSocket, group_id: str):
user = await get_ws_current_user(websocket) 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) user_id = str(user.id)
# connect websocket # connect websocket
@ -55,49 +70,46 @@ async def group_ws(websocket: WebSocket, group_id: str):
) )
try: try:
async with AsyncSessionLocal() as db:
while True: while True:
data = await websocket.receive_json() data = await websocket.receive_json()
event = data.get("type") event = data.get("type")
# user wants to speak # user wants to speak
if event == "request_speak": if event == "request_speak":
async with AsyncSessionLocal() as db:
token = await request_speak( token = await request_speak(
db, db,
group_id, group_id,
user_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( await manager.broadcast(
group_id, group_id,
{ {
"type": "speaker", "type": "speaker_released"
"user_id": user_id
} }
) )
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: except WebSocketDisconnect:
manager.disconnect(group_id, websocket) manager.disconnect(group_id, websocket)

View File

@ -1,15 +1,17 @@
import uuid
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from domains.users.models import User 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( result = await db.execute(
select(User).where(User.id == user_id) select(User).where(User.id == user_id)
) )
return result.scalar_one_or_none() 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( result = await db.execute(
select(User).where(User.username == username) select(User).where(User.username == username)
) )
@ -21,6 +23,10 @@ async def create_user(db: AsyncSession, user: User):
await db.refresh(user) await db.refresh(user)
return user return user
async def get_all_users(db: AsyncSession): async def get_all_users(db: AsyncSession, include_admin: bool = False):
result = await db.execute(select(User)) query = select(User)
if not include_admin:
query = query.where(User.is_admin.is_(False))
result = await db.execute(query)
return result.scalars().all() return result.scalars().all()

View File

@ -1,5 +1,4 @@
import uuid import uuid
from pydantic import BaseModel
from pydantic import BaseModel, Field, field_validator from pydantic import BaseModel, Field, field_validator
import re import re
@ -13,8 +12,8 @@ class UserCreate(BaseModel):
def validate_phone_number(cls, v: str | None) -> str | None: def validate_phone_number(cls, v: str | None) -> str | None:
if v is None: if v is None:
return v return v
if not re.match(r"^\d{11}$", v): if not re.match(r"^09\d{9}$", v):
raise ValueError("Phone number must be exactly 11 digits") raise ValueError("Phone number must start with 09 and be exactly 11 digits")
return v return v
class UserResponse(BaseModel): class UserResponse(BaseModel):

View File

@ -1,6 +1,8 @@
import uuid
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from domains.users.repo import get_user_by_id from domains.users.repo import get_user_by_id
async def get_user(db: AsyncSession, user_id): async def get_user(db: AsyncSession, user_id: str | uuid.UUID):
return await get_user_by_id(db, user_id) return await get_user_by_id(db, user_id)

View File

@ -17,7 +17,7 @@ async def lifespan(app: FastAPI):
# ---------- Startup ---------- # ---------- Startup ----------
try: try:
await redis_client.ping() await redis_client.ping() # type: ignore
print("Redis connected") print("Redis connected")
except Exception as e: except Exception as e:
@ -59,13 +59,3 @@ app.include_router(users_router)
app.include_router(admin_router) app.include_router(admin_router)
app.include_router(groups_router) app.include_router(groups_router)
app.include_router(realtime_router) app.include_router(realtime_router)
# -------------------------
# Health Check
# -------------------------
@app.get("/health")
async def health_check():
return {"status": "ok"}

View File

@ -1,33 +1,35 @@
import asyncio import asyncio
import secrets import secrets
from sqlalchemy.ext.asyncio import AsyncSession
from db.session import AsyncSessionLocal from db.session import AsyncSessionLocal
from domains.users.models import User
from core.security import hash_password from core.security import hash_password
from domains.users.models import User, UserRole async def create_admin() -> None:
username = input("Admin username: ").strip()
phone_number = input("Phone number (optional, 11 digits): ").strip() or None
async def create_admin(): # secret = secrets.token_urlsafe(16)
username = input("Admin username: ")
secret = "1234" secret = "1234"
async with AsyncSessionLocal() as db: async with AsyncSessionLocal() as db:
try:
user = User(
username=username,
phone_number=phone_number,
is_admin=True,
secret_hash=hash_password(secret),
)
user = User( db.add(user)
username=username, await db.commit()
role=UserRole.ADMIN,
secret_hash=hash_password(secret),
)
db.add(user) except ValueError as exc:
await db.commit() print(f"\nError: {exc}\n")
return
print("\nAdmin created successfully\n") print("\nAdmin created successfully\n")
print("Username:", username) print("Username:", user.username)
print("Phone number:", user.phone_number)
print("Secret:", secret) print("Secret:", secret)
print("\nSave this secret!\n") print("\nSave this secret!\n")