DGSoft cfd7068af5 feat: Add blinking envelope icons for unread messages
- Implement unread message indicators with Material-UI icons
- Add BlinkingEnvelope component with theme-compatible colors
- Create UnreadMessagesContext for managing unread states
- Integrate WebSocket message handling for real-time notifications
- Icons only appear for inactive channels/DMs, disappear when opened
- Add test functionality (double-click to mark as unread)
- Fix WebSocket URL handling for production deployment
- Unify WebSocket architecture using presence connection for all messages
2025-12-12 11:26:36 +01:00

270 lines
9.2 KiB
Python

from fastapi import APIRouter, Depends, HTTPException, status, BackgroundTasks
from sqlmodel import Session, select
from sqlalchemy.orm import joinedload
from typing import List
import os
from app.database import get_session
from app.models import Message, Channel, User, FileAttachment, Snippet
from app.schemas import MessageCreate, MessageResponse
from app.auth import get_current_user
from app.websocket import manager
router = APIRouter(prefix="/messages", tags=["Messages"])
def user_has_channel_access(user: User, channel_id: int, session: Session) -> bool:
"""Check if user has access to a channel"""
channel = session.get(Channel, channel_id)
if not channel:
return False
user_dept_ids = [dept.id for dept in user.departments]
return channel.department_id in user_dept_ids
@router.post("/", response_model=MessageResponse, status_code=status.HTTP_201_CREATED)
async def create_message(
message_data: MessageCreate,
background_tasks: BackgroundTasks,
session: Session = Depends(get_session),
current_user: User = Depends(get_current_user)
):
"""Create a new message in a channel"""
# Check if channel exists
channel = session.get(Channel, message_data.channel_id)
if not channel:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Channel not found"
)
# Check if user has access to this channel
if not user_has_channel_access(current_user, message_data.channel_id, session):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You don't have access to this channel"
)
new_message = Message(
content=message_data.content,
sender_id=current_user.id,
channel_id=message_data.channel_id,
snippet_id=message_data.snippet_id,
reply_to_id=message_data.reply_to_id
)
session.add(new_message)
session.commit()
session.refresh(new_message)
# Build response dict manually to avoid issues with relationships
reply_to_data = None
if new_message.reply_to_id:
reply_msg = session.get(Message, new_message.reply_to_id)
if reply_msg:
reply_sender = session.get(User, reply_msg.sender_id)
reply_to_data = {
"id": reply_msg.id,
"content": reply_msg.content,
"sender_username": reply_sender.username if reply_sender else "Unknown",
"sender_full_name": reply_sender.full_name if reply_sender else None
}
# Load snippet data if present
snippet_data = None
if new_message.snippet_id:
snippet = session.get(Snippet, new_message.snippet_id)
if snippet:
snippet_data = {
"id": snippet.id,
"title": snippet.title,
"content": snippet.content,
"language": snippet.language,
"created_at": snippet.created_at.isoformat(),
"updated_at": snippet.updated_at.isoformat()
}
response_data = {
"id": new_message.id,
"content": new_message.content,
"channel_id": new_message.channel_id,
"sender_id": new_message.sender_id,
"sender_username": current_user.username,
"sender_full_name": current_user.full_name,
"sender_profile_picture": current_user.profile_picture,
"created_at": new_message.created_at.isoformat(),
"snippet_id": new_message.snippet_id,
"reply_to_id": new_message.reply_to_id,
"reply_to": reply_to_data,
"snippet": snippet_data,
"attachments": [],
"is_deleted": False
}
# Broadcast to all connected clients in this channel
await manager.broadcast_to_channel(
{
"type": "message",
"message": response_data
},
message_data.channel_id
)
# Update user activity
manager.update_activity(current_user.id)
# Return proper response
response = MessageResponse.model_validate(new_message)
response.sender_username = current_user.username
response.sender_full_name = current_user.full_name
response.sender_profile_picture = current_user.profile_picture
return response
@router.get("/channel/{channel_id}", response_model=List[MessageResponse])
def get_channel_messages(
channel_id: int,
limit: int = 50,
offset: int = 0,
session: Session = Depends(get_session),
current_user: User = Depends(get_current_user)
):
"""Get messages from a channel"""
# Check if user has access to this channel
if not user_has_channel_access(current_user, channel_id, session):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You don't have access to this channel"
)
statement = (
select(Message)
.where(Message.channel_id == channel_id)
.options(joinedload(Message.attachments))
.options(joinedload(Message.snippet))
.order_by(Message.created_at.desc())
.offset(offset)
.limit(limit)
)
messages = session.exec(statement).unique().all()
# Add sender usernames and reply_to info
responses = []
for msg in messages:
msg_response = MessageResponse.model_validate(msg)
sender = session.get(User, msg.sender_id)
msg_response.sender_username = sender.username if sender else "Unknown"
msg_response.sender_full_name = sender.full_name if sender else None
msg_response.sender_profile_picture = sender.profile_picture if sender else None
# Add reply_to info if exists
if msg.reply_to_id:
reply_msg = session.get(Message, msg.reply_to_id)
if reply_msg:
reply_sender = session.get(User, reply_msg.sender_id)
msg_response.reply_to = {
"id": reply_msg.id,
"content": reply_msg.content,
"sender_username": reply_sender.username if reply_sender else "Unknown",
"sender_full_name": reply_sender.full_name if reply_sender else None
}
responses.append(msg_response)
# Reverse to show oldest first
return list(reversed(responses))
@router.get("/{message_id}", response_model=MessageResponse)
def get_message(
message_id: int,
session: Session = Depends(get_session),
current_user: User = Depends(get_current_user)
):
"""Get a specific message"""
message = session.get(Message, message_id)
if not message:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Message not found"
)
# Check if user has access to this message's channel
if not user_has_channel_access(current_user, message.channel_id, session):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You don't have access to this message"
)
response = MessageResponse.model_validate(message)
sender = session.get(User, message.sender_id)
response.sender_username = sender.username if sender else "Unknown"
response.sender_full_name = sender.full_name if sender else None
response.sender_profile_picture = sender.profile_picture if sender else None
return response
@router.delete("/{message_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_message(
message_id: int,
session: Session = Depends(get_session),
current_user: User = Depends(get_current_user)
):
"""Delete a message and its attachments"""
message = session.get(Message, message_id)
if not message:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Message not found"
)
# Only sender can delete their own messages
if message.sender_id != current_user.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You can only delete your own messages"
)
# Check channel access
if not user_has_channel_access(current_user, message.channel_id, session):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You don't have access to this channel"
)
channel_id = message.channel_id
# Delete all file attachments
statement = select(FileAttachment).where(FileAttachment.message_id == message_id)
attachments = session.exec(statement).all()
for attachment in attachments:
# Delete physical file
if os.path.exists(attachment.file_path):
try:
os.remove(attachment.file_path)
except Exception as e:
print(f"Error deleting file {attachment.file_path}: {e}")
# Delete database record
session.delete(attachment)
# Mark message as deleted instead of removing it
message.is_deleted = True
message.content = "Diese Nachricht wurde gelöscht"
session.add(message)
session.commit()
# Broadcast deletion to all clients
await manager.broadcast_to_channel(
{
"type": "message_deleted",
"message_id": message_id
},
channel_id
)
return None