feat: update Back Security
This commit is contained in:
parent
a99d920f2c
commit
f1f864d468
342
Back/README.md
342
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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 ###
|
||||
88
Back/alembic/versions/4080314c8f5a_add_notifications.py
Normal file
88
Back/alembic/versions/4080314c8f5a_add_notifications.py
Normal 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 ###
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
from passlib.context import CryptContext
|
||||
|
||||
pwd_context = CryptContext(
|
||||
schemes=["bcrypt"],
|
||||
schemes=["argon2"],
|
||||
deprecated="auto",
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
12
Back/main.py
12
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:
|
||||
|
|
@ -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"}
|
||||
|
|
@ -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,
|
||||
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")
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user