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 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.

View File

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

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
pwd_context = CryptContext(
schemes=["bcrypt"],
schemes=["argon2"],
deprecated="auto",
)

View File

@ -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])

View File

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

View File

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

View File

@ -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"),

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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)
return await get_presence(str(group_id))

View File

@ -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)
return await get_active_speaker(group_id_str)

View File

@ -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,13 +70,12 @@ async def group_ws(websocket: WebSocket, group_id: str):
)
try:
async with AsyncSessionLocal() as db:
while True:
data = await websocket.receive_json()
event = data.get("type")
# user wants to speak
if event == "request_speak":
async with AsyncSessionLocal() as db:
token = await request_speak(
db,
group_id,
@ -80,7 +94,6 @@ async def group_ws(websocket: WebSocket, group_id: str):
"token": token
})
else:
async with AsyncSessionLocal() as db:
speaker = await current_speaker(db, group_id)
await websocket.send_json({
@ -90,7 +103,6 @@ async def group_ws(websocket: WebSocket, group_id: str):
# 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,

View File

@ -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))
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()

View File

@ -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):

View File

@ -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):
async def get_user(db: AsyncSession, user_id: str | uuid.UUID):
return await get_user_by_id(db, user_id)

View File

@ -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:
@ -59,13 +59,3 @@ 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"}

View File

@ -1,33 +1,35 @@
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,
role=UserRole.ADMIN,
phone_number=phone_number,
is_admin=True,
secret_hash=hash_password(secret),
)
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")