
Complete rewrite from task management to full-featured chat system: Core Features: - Real-time WebSocket messaging with connection management - Direct messages and group chats with admin controls - Message types: text, images, videos, audio, documents - Message status tracking: sent, delivered, read receipts - Typing indicators and user presence (online/offline) - Message replies, editing, and deletion Security & Encryption: - End-to-end encryption with RSA + AES hybrid approach - JWT authentication for API and WebSocket connections - Secure file storage with access control - Automatic RSA key pair generation per user Media & File Sharing: - Multi-format file upload (images, videos, audio, documents) - Automatic thumbnail generation for images/videos - File size validation and MIME type checking - Secure download endpoints with permission checks Notifications & Alerts: - Real-time WebSocket notifications - Push notifications via Firebase integration - @username mention alerts with notification history - Unread message and mention counting - Custom notification types (message, mention, group invite) Advanced Features: - Group chat management with roles (member, admin, owner) - User search and chat member management - Message pagination and chat history - Last seen timestamps and activity tracking - Comprehensive API documentation with WebSocket events Architecture: - Clean layered architecture with services, models, schemas - WebSocket connection manager for real-time features - Modular notification system with multiple channels - Comprehensive error handling and validation - Production-ready with Docker support Technologies: FastAPI, WebSocket, SQLAlchemy, SQLite, Cryptography, Firebase, Pillow
391 lines
13 KiB
Python
391 lines
13 KiB
Python
from typing import List
|
|
from fastapi import APIRouter, Depends, HTTPException
|
|
from sqlalchemy.orm import Session
|
|
from sqlalchemy import desc
|
|
from app.db.session import get_db
|
|
from app.core.deps import get_current_active_user
|
|
from app.models import User, Chat as ChatModel, ChatMember as ChatMemberModel, Message as MessageModel, ChatType, MemberRole
|
|
from app.schemas.chat import Chat, ChatCreate, DirectChatCreate, ChatUpdate, ChatList, ChatMember, ChatMemberCreate
|
|
from app.schemas.user import UserPublic
|
|
from app.websocket.connection_manager import connection_manager
|
|
|
|
router = APIRouter()
|
|
|
|
@router.get("/", response_model=List[ChatList])
|
|
def get_user_chats(
|
|
skip: int = 0,
|
|
limit: int = 50,
|
|
current_user: User = Depends(get_current_active_user),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""Get all chats for the current user"""
|
|
# Get user's chat memberships
|
|
memberships = db.query(ChatMemberModel).filter(
|
|
ChatMemberModel.user_id == current_user.id
|
|
).offset(skip).limit(limit).all()
|
|
|
|
chats = []
|
|
for membership in memberships:
|
|
chat = membership.chat
|
|
|
|
# Get last message
|
|
last_message = db.query(MessageModel).filter(
|
|
MessageModel.chat_id == chat.id
|
|
).order_by(desc(MessageModel.created_at)).first()
|
|
|
|
# Calculate unread count
|
|
unread_count = 0
|
|
if membership.last_read_message_id:
|
|
unread_count = db.query(MessageModel).filter(
|
|
MessageModel.chat_id == chat.id,
|
|
MessageModel.id > membership.last_read_message_id,
|
|
MessageModel.sender_id != current_user.id
|
|
).count()
|
|
else:
|
|
unread_count = db.query(MessageModel).filter(
|
|
MessageModel.chat_id == chat.id,
|
|
MessageModel.sender_id != current_user.id
|
|
).count()
|
|
|
|
# For direct chats, get the other user's online status
|
|
is_online = False
|
|
chat_name = chat.name
|
|
if chat.chat_type == ChatType.DIRECT:
|
|
other_member = db.query(ChatMemberModel).filter(
|
|
ChatMemberModel.chat_id == chat.id,
|
|
ChatMemberModel.user_id != current_user.id
|
|
).first()
|
|
if other_member:
|
|
is_online = other_member.user.is_online
|
|
if not chat_name:
|
|
chat_name = other_member.user.username
|
|
|
|
chat_data = ChatList(
|
|
id=chat.id,
|
|
name=chat_name,
|
|
chat_type=chat.chat_type,
|
|
avatar_url=chat.avatar_url,
|
|
last_message={
|
|
"id": last_message.id,
|
|
"content": last_message.content[:100] if last_message.content else "",
|
|
"sender_username": last_message.sender.username,
|
|
"created_at": last_message.created_at.isoformat()
|
|
} if last_message else None,
|
|
last_activity=last_message.created_at if last_message else chat.updated_at,
|
|
unread_count=unread_count,
|
|
is_online=is_online
|
|
)
|
|
chats.append(chat_data)
|
|
|
|
# Sort by last activity
|
|
chats.sort(key=lambda x: x.last_activity or x.id, reverse=True)
|
|
return chats
|
|
|
|
@router.post("/direct", response_model=Chat)
|
|
def create_direct_chat(
|
|
chat_data: DirectChatCreate,
|
|
current_user: User = Depends(get_current_active_user),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""Create or get existing direct chat"""
|
|
other_user = db.query(User).filter(User.id == chat_data.other_user_id).first()
|
|
if not other_user:
|
|
raise HTTPException(status_code=404, detail="User not found")
|
|
|
|
if other_user.id == current_user.id:
|
|
raise HTTPException(status_code=400, detail="Cannot create chat with yourself")
|
|
|
|
# Check if direct chat already exists
|
|
existing_chat = db.query(ChatModel).join(ChatMemberModel).filter(
|
|
ChatModel.chat_type == ChatType.DIRECT,
|
|
ChatMemberModel.user_id.in_([current_user.id, other_user.id])
|
|
).group_by(ChatModel.id).having(
|
|
db.func.count(ChatMemberModel.user_id) == 2
|
|
).first()
|
|
|
|
if existing_chat:
|
|
return _get_chat_with_details(existing_chat.id, current_user.id, db)
|
|
|
|
# Create new direct chat
|
|
chat = ChatModel(
|
|
chat_type=ChatType.DIRECT,
|
|
is_active=True
|
|
)
|
|
db.add(chat)
|
|
db.commit()
|
|
db.refresh(chat)
|
|
|
|
# Add both users as members
|
|
for user in [current_user, other_user]:
|
|
member = ChatMemberModel(
|
|
chat_id=chat.id,
|
|
user_id=user.id,
|
|
role=MemberRole.MEMBER
|
|
)
|
|
db.add(member)
|
|
|
|
db.commit()
|
|
|
|
# Add users to connection manager
|
|
connection_manager.add_user_to_chat(current_user.id, chat.id)
|
|
connection_manager.add_user_to_chat(other_user.id, chat.id)
|
|
|
|
return _get_chat_with_details(chat.id, current_user.id, db)
|
|
|
|
@router.post("/group", response_model=Chat)
|
|
def create_group_chat(
|
|
chat_data: ChatCreate,
|
|
current_user: User = Depends(get_current_active_user),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""Create a new group chat"""
|
|
if not chat_data.name:
|
|
raise HTTPException(status_code=400, detail="Group name is required")
|
|
|
|
# Create group chat
|
|
chat = ChatModel(
|
|
name=chat_data.name,
|
|
description=chat_data.description,
|
|
chat_type=ChatType.GROUP,
|
|
avatar_url=chat_data.avatar_url,
|
|
is_active=True
|
|
)
|
|
db.add(chat)
|
|
db.commit()
|
|
db.refresh(chat)
|
|
|
|
# Add creator as owner
|
|
owner_member = ChatMemberModel(
|
|
chat_id=chat.id,
|
|
user_id=current_user.id,
|
|
role=MemberRole.OWNER
|
|
)
|
|
db.add(owner_member)
|
|
|
|
# Add other members
|
|
for member_id in chat_data.member_ids:
|
|
if member_id != current_user.id: # Don't add creator twice
|
|
user = db.query(User).filter(User.id == member_id).first()
|
|
if user:
|
|
member = ChatMemberModel(
|
|
chat_id=chat.id,
|
|
user_id=member_id,
|
|
role=MemberRole.MEMBER
|
|
)
|
|
db.add(member)
|
|
|
|
db.commit()
|
|
|
|
# Add users to connection manager
|
|
connection_manager.add_user_to_chat(current_user.id, chat.id)
|
|
for member_id in chat_data.member_ids:
|
|
connection_manager.add_user_to_chat(member_id, chat.id)
|
|
|
|
return _get_chat_with_details(chat.id, current_user.id, db)
|
|
|
|
@router.get("/{chat_id}", response_model=Chat)
|
|
def get_chat(
|
|
chat_id: int,
|
|
current_user: User = Depends(get_current_active_user),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""Get chat details"""
|
|
# Verify user is member of chat
|
|
membership = db.query(ChatMemberModel).filter(
|
|
ChatMemberModel.chat_id == chat_id,
|
|
ChatMemberModel.user_id == current_user.id
|
|
).first()
|
|
|
|
if not membership:
|
|
raise HTTPException(status_code=404, detail="Chat not found")
|
|
|
|
return _get_chat_with_details(chat_id, current_user.id, db)
|
|
|
|
@router.put("/{chat_id}", response_model=Chat)
|
|
def update_chat(
|
|
chat_id: int,
|
|
chat_update: ChatUpdate,
|
|
current_user: User = Depends(get_current_active_user),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""Update chat details (admin/owner only)"""
|
|
# Verify user is admin/owner of chat
|
|
membership = db.query(ChatMemberModel).filter(
|
|
ChatMemberModel.chat_id == chat_id,
|
|
ChatMemberModel.user_id == current_user.id,
|
|
ChatMemberModel.role.in_([MemberRole.ADMIN, MemberRole.OWNER])
|
|
).first()
|
|
|
|
if not membership:
|
|
raise HTTPException(status_code=403, detail="Insufficient permissions")
|
|
|
|
chat = db.query(ChatModel).filter(ChatModel.id == chat_id).first()
|
|
if not chat:
|
|
raise HTTPException(status_code=404, detail="Chat not found")
|
|
|
|
update_data = chat_update.dict(exclude_unset=True)
|
|
for field, value in update_data.items():
|
|
setattr(chat, field, value)
|
|
|
|
db.commit()
|
|
db.refresh(chat)
|
|
|
|
return _get_chat_with_details(chat_id, current_user.id, db)
|
|
|
|
@router.post("/{chat_id}/members", response_model=ChatMember)
|
|
def add_chat_member(
|
|
chat_id: int,
|
|
member_data: ChatMemberCreate,
|
|
current_user: User = Depends(get_current_active_user),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""Add member to chat (admin/owner only)"""
|
|
# Verify permissions
|
|
membership = db.query(ChatMemberModel).filter(
|
|
ChatMemberModel.chat_id == chat_id,
|
|
ChatMemberModel.user_id == current_user.id,
|
|
ChatMemberModel.role.in_([MemberRole.ADMIN, MemberRole.OWNER])
|
|
).first()
|
|
|
|
if not membership:
|
|
raise HTTPException(status_code=403, detail="Insufficient permissions")
|
|
|
|
# Check if user is already a member
|
|
existing_member = db.query(ChatMemberModel).filter(
|
|
ChatMemberModel.chat_id == chat_id,
|
|
ChatMemberModel.user_id == member_data.user_id
|
|
).first()
|
|
|
|
if existing_member:
|
|
raise HTTPException(status_code=400, detail="User is already a member")
|
|
|
|
# Verify user exists
|
|
user = db.query(User).filter(User.id == member_data.user_id).first()
|
|
if not user:
|
|
raise HTTPException(status_code=404, detail="User not found")
|
|
|
|
# Add member
|
|
new_member = ChatMemberModel(
|
|
chat_id=chat_id,
|
|
user_id=member_data.user_id,
|
|
role=member_data.role,
|
|
nickname=member_data.nickname
|
|
)
|
|
db.add(new_member)
|
|
db.commit()
|
|
db.refresh(new_member)
|
|
|
|
# Add to connection manager
|
|
connection_manager.add_user_to_chat(member_data.user_id, chat_id)
|
|
|
|
return ChatMember(
|
|
id=new_member.id,
|
|
chat_id=new_member.chat_id,
|
|
user_id=new_member.user_id,
|
|
role=new_member.role,
|
|
nickname=new_member.nickname,
|
|
is_muted=new_member.is_muted,
|
|
is_banned=new_member.is_banned,
|
|
joined_at=new_member.joined_at,
|
|
last_read_message_id=new_member.last_read_message_id,
|
|
user=UserPublic.from_orm(user)
|
|
)
|
|
|
|
@router.delete("/{chat_id}/members/{user_id}")
|
|
def remove_chat_member(
|
|
chat_id: int,
|
|
user_id: int,
|
|
current_user: User = Depends(get_current_active_user),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""Remove member from chat"""
|
|
# Users can remove themselves, or admins/owners can remove others
|
|
if user_id != current_user.id:
|
|
membership = db.query(ChatMemberModel).filter(
|
|
ChatMemberModel.chat_id == chat_id,
|
|
ChatMemberModel.user_id == current_user.id,
|
|
ChatMemberModel.role.in_([MemberRole.ADMIN, MemberRole.OWNER])
|
|
).first()
|
|
|
|
if not membership:
|
|
raise HTTPException(status_code=403, detail="Insufficient permissions")
|
|
|
|
# Find and remove member
|
|
member = db.query(ChatMemberModel).filter(
|
|
ChatMemberModel.chat_id == chat_id,
|
|
ChatMemberModel.user_id == user_id
|
|
).first()
|
|
|
|
if not member:
|
|
raise HTTPException(status_code=404, detail="Member not found")
|
|
|
|
db.delete(member)
|
|
db.commit()
|
|
|
|
# Remove from connection manager
|
|
connection_manager.remove_user_from_chat(user_id, chat_id)
|
|
|
|
return {"message": "Member removed successfully"}
|
|
|
|
def _get_chat_with_details(chat_id: int, current_user_id: int, db: Session) -> Chat:
|
|
"""Helper function to get chat with all details"""
|
|
chat = db.query(ChatModel).filter(ChatModel.id == chat_id).first()
|
|
|
|
# Get members
|
|
members = db.query(ChatMemberModel).filter(ChatMemberModel.chat_id == chat_id).all()
|
|
chat_members = []
|
|
|
|
for member in members:
|
|
user_public = UserPublic.from_orm(member.user)
|
|
chat_member = ChatMember(
|
|
id=member.id,
|
|
chat_id=member.chat_id,
|
|
user_id=member.user_id,
|
|
role=member.role,
|
|
nickname=member.nickname,
|
|
is_muted=member.is_muted,
|
|
is_banned=member.is_banned,
|
|
joined_at=member.joined_at,
|
|
last_read_message_id=member.last_read_message_id,
|
|
user=user_public
|
|
)
|
|
chat_members.append(chat_member)
|
|
|
|
# Get last message
|
|
last_message = db.query(MessageModel).filter(
|
|
MessageModel.chat_id == chat_id
|
|
).order_by(desc(MessageModel.created_at)).first()
|
|
|
|
# Calculate unread count
|
|
current_member = next((m for m in members if m.user_id == current_user_id), None)
|
|
unread_count = 0
|
|
if current_member and current_member.last_read_message_id:
|
|
unread_count = db.query(MessageModel).filter(
|
|
MessageModel.chat_id == chat_id,
|
|
MessageModel.id > current_member.last_read_message_id,
|
|
MessageModel.sender_id != current_user_id
|
|
).count()
|
|
else:
|
|
unread_count = db.query(MessageModel).filter(
|
|
MessageModel.chat_id == chat_id,
|
|
MessageModel.sender_id != current_user_id
|
|
).count()
|
|
|
|
return Chat(
|
|
id=chat.id,
|
|
name=chat.name,
|
|
description=chat.description,
|
|
chat_type=chat.chat_type,
|
|
avatar_url=chat.avatar_url,
|
|
is_active=chat.is_active,
|
|
created_at=chat.created_at,
|
|
updated_at=chat.updated_at,
|
|
members=chat_members,
|
|
last_message={
|
|
"id": last_message.id,
|
|
"content": last_message.content,
|
|
"sender_username": last_message.sender.username,
|
|
"created_at": last_message.created_at.isoformat()
|
|
} if last_message else None,
|
|
unread_count=unread_count
|
|
) |