""" Chat router — /api/chat """ from datetime import datetime, timezone from typing import List, Optional from fastapi import APIRouter, Depends, HTTPException, Query, status from sqlalchemy.orm import Session from database import get_db from routers.deps import get_current_user from models.user import User from models.chat import Conversation, ConversationParticipant, ChatMessage from schemas.user import UserOut from schemas.chat import ( ConversationCreate, ConversationOut, MessageCreate, MessageOut, ParticipantOut, ) from services.sse_bus import broadcast_sync router = APIRouter() # --------------------------------------------------------------------------- # Internal helpers # --------------------------------------------------------------------------- def _utcnow() -> datetime: return datetime.now(timezone.utc) def _build_participant_out(p: ConversationParticipant) -> ParticipantOut: return ParticipantOut( user_id=p.user_id, username=p.user.username, joined_at=p.joined_at, last_read_at=p.last_read_at, ) def _build_message_out(msg: ChatMessage) -> MessageOut: return MessageOut( id=msg.id, conversation_id=msg.conversation_id, sender_id=msg.sender_id, sender_name=msg.sender.username, body=msg.body, sent_at=msg.sent_at, is_deleted=msg.deleted_at is not None, ) def _unread_count(db: Session, conv_id: int, user: User) -> int: """ Count messages in a conversation that are 'unread' for this user: - not soft-deleted - not sent by this user (your own messages don't count) - sent after user's last_read_at (or ALL messages if last_read_at is None) """ participant = ( db.query(ConversationParticipant) .filter( ConversationParticipant.conversation_id == conv_id, ConversationParticipant.user_id == user.id, ) .first() ) if not participant: return 0 q = ( db.query(ChatMessage) .filter( ChatMessage.conversation_id == conv_id, ChatMessage.deleted_at == None, # noqa: E711 ChatMessage.sender_id != user.id, ) ) if participant.last_read_at is not None: q = q.filter(ChatMessage.sent_at > participant.last_read_at) return q.count() def _last_message(db: Session, conv_id: int) -> Optional[ChatMessage]: return ( db.query(ChatMessage) .filter( ChatMessage.conversation_id == conv_id, ChatMessage.deleted_at == None, # noqa: E711 ) .order_by(ChatMessage.sent_at.desc()) .first() ) def _get_participant_ids(conv: Conversation) -> List[int]: return [p.user_id for p in conv.participants] def _build_conversation_out(db: Session, conv: Conversation, user: User) -> ConversationOut: last_msg = _last_message(db, conv.id) return ConversationOut( id=conv.id, type=conv.type, name=conv.name, is_system=conv.is_system, created_at=conv.created_at, participants=[_build_participant_out(p) for p in conv.participants], last_message=_build_message_out(last_msg) if last_msg else None, unread_count=_unread_count(db, conv.id, user), ) def _require_participant(db: Session, conv_id: int, user: User) -> Conversation: """Return the conversation if user is a participant, else 403/404.""" conv = db.query(Conversation).filter(Conversation.id == conv_id).first() if not conv: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Conversation not found") is_participant = any(p.user_id == user.id for p in conv.participants) if not is_participant: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not a participant") return conv # --------------------------------------------------------------------------- # Endpoints # --------------------------------------------------------------------------- @router.get("/conversations", response_model=List[ConversationOut]) def list_conversations( db: Session = Depends(get_db), user: User = Depends(get_current_user), ): """List all conversations the current user is a participant of, ordered by last message time desc.""" # Fetch all conversation_ids for this user participant_rows = ( db.query(ConversationParticipant) .filter(ConversationParticipant.user_id == user.id) .all() ) conv_ids = [p.conversation_id for p in participant_rows] if not conv_ids: return [] conversations = ( db.query(Conversation) .filter(Conversation.id.in_(conv_ids)) .all() ) # Sort by last message sent_at desc (conversations with no messages go last) # SQLite returns naive datetimes even for timezone=True columns, so keep everything naive def _sort_key(c: Conversation): last = _last_message(db, c.id) if not last: return datetime.min dt = last.sent_at return dt.replace(tzinfo=None) if dt.tzinfo else dt conversations.sort(key=_sort_key, reverse=True) return [_build_conversation_out(db, c, user) for c in conversations] @router.post("/conversations", response_model=ConversationOut, status_code=status.HTTP_201_CREATED) def create_conversation( payload: ConversationCreate, db: Session = Depends(get_db), user: User = Depends(get_current_user), ): """ Create a direct or group conversation. For direct: returns existing conversation if one already exists between the two users. Creator is automatically added as a participant. """ # Normalise participant list — always include creator participant_ids = list(set(payload.participant_ids + [user.id])) if payload.type == "direct": if len(participant_ids) != 2: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Direct conversations must have exactly 2 participants", ) other_id = next(pid for pid in participant_ids if pid != user.id) # Check for an existing non-system direct conversation between these two users existing_parts_me = ( db.query(ConversationParticipant) .filter(ConversationParticipant.user_id == user.id) .all() ) my_conv_ids = {p.conversation_id for p in existing_parts_me} existing_parts_other = ( db.query(ConversationParticipant) .filter( ConversationParticipant.user_id == other_id, ConversationParticipant.conversation_id.in_(my_conv_ids), ) .all() ) shared_conv_ids = {p.conversation_id for p in existing_parts_other} for conv_id in shared_conv_ids: conv = db.query(Conversation).filter( Conversation.id == conv_id, Conversation.type == "direct", Conversation.is_system == False, # noqa: E712 ).first() if conv: return _build_conversation_out(db, conv, user) elif payload.type == "group": if not payload.name: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Group conversations require a name", ) else: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="type must be 'direct' or 'group'", ) # Create the conversation conv = Conversation( type=payload.type, name=payload.name if payload.type == "group" else None, is_system=False, created_by=user.id, ) db.add(conv) db.flush() now = _utcnow() for uid in participant_ids: db.add(ConversationParticipant( conversation_id=conv.id, user_id=uid, joined_at=now, )) db.commit() db.refresh(conv) return _build_conversation_out(db, conv, user) @router.get("/conversations/{conv_id}", response_model=ConversationOut) def get_conversation( conv_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user), ): """Get single conversation details with participants.""" conv = _require_participant(db, conv_id, user) return _build_conversation_out(db, conv, user) @router.get("/conversations/{conv_id}/messages", response_model=List[MessageOut]) def list_messages( conv_id: int, offset: int = Query(default=0, ge=0), limit: int = Query(default=50, ge=1, le=200), db: Session = Depends(get_db), user: User = Depends(get_current_user), ): """Paginated messages for a conversation, newest first. User must be a participant.""" _require_participant(db, conv_id, user) messages = ( db.query(ChatMessage) .filter(ChatMessage.conversation_id == conv_id) .order_by(ChatMessage.sent_at.desc()) .offset(offset) .limit(limit) .all() ) return [_build_message_out(m) for m in messages] @router.post("/conversations/{conv_id}/messages", response_model=MessageOut, status_code=status.HTTP_201_CREATED) def send_message( conv_id: int, payload: MessageCreate, db: Session = Depends(get_db), user: User = Depends(get_current_user), ): """Send a message to a conversation. Broadcasts chat_message SSE event to all participants.""" conv = _require_participant(db, conv_id, user) if not payload.body.strip(): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Message body cannot be empty", ) msg = ChatMessage( conversation_id=conv_id, sender_id=user.id, body=payload.body, sent_at=_utcnow(), ) db.add(msg) db.commit() db.refresh(msg) participant_ids = _get_participant_ids(conv) # Ensure UTC Z suffix so JS Date() parses correctly regardless of tzinfo sent_at_str = msg.sent_at.strftime('%Y-%m-%dT%H:%M:%S.%f')[:-3] + 'Z' broadcast_sync( "chat_message", { "conversation_id": conv_id, "message_id": msg.id, "sender_id": user.id, "sender_name": user.username, "body": msg.body, "sent_at": sent_at_str, }, user_ids=participant_ids, ) return _build_message_out(msg) @router.post("/conversations/{conv_id}/read", status_code=status.HTTP_204_NO_CONTENT) def mark_read( conv_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user), ): """Update the current user's last_read_at to now. Broadcasts chat_read SSE event.""" conv = _require_participant(db, conv_id, user) participant = ( db.query(ConversationParticipant) .filter( ConversationParticipant.conversation_id == conv_id, ConversationParticipant.user_id == user.id, ) .first() ) now = _utcnow() participant.last_read_at = now db.commit() participant_ids = _get_participant_ids(conv) broadcast_sync( "chat_read", { "conversation_id": conv_id, "user_id": user.id, "read_at": now.strftime('%Y-%m-%dT%H:%M:%S.%f')[:-3] + 'Z', }, user_ids=participant_ids, ) @router.post("/conversations/{conv_id}/leave", status_code=status.HTTP_204_NO_CONTENT) def leave_conversation( conv_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user), ): """ Remove current user from the conversation. Not allowed on system conversations or direct conversations. """ conv = _require_participant(db, conv_id, user) if conv.is_system: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Cannot leave the system group conversation", ) if conv.type == "direct": raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Cannot leave a direct conversation — just stop using it", ) participant = ( db.query(ConversationParticipant) .filter( ConversationParticipant.conversation_id == conv_id, ConversationParticipant.user_id == user.id, ) .first() ) db.delete(participant) db.commit() @router.get("/users", response_model=List[UserOut]) def list_chat_users( db: Session = Depends(get_db), user: User = Depends(get_current_user), ): """All active users — accessible to any authenticated staff member, for starting chats.""" return db.query(User).filter(User.is_active == True).order_by(User.username).all() # noqa: E712