Snapshot of in-progress work across local_backend, manager_dashboard, and waiter_pwa (pricing, chat, fiscal, prep zones, recovery codes, CRM, inventory, permissions), plus the nginx/docker-compose deploy fixes for the Unraid + NPM reverse-proxy setup. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
405 lines
13 KiB
Python
405 lines
13 KiB
Python
"""
|
|
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
|