feat: bump client-services (accumulated feature work + deploy fixes)
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>
This commit is contained in:
404
local_backend/routers/chat.py
Normal file
404
local_backend/routers/chat.py
Normal file
@@ -0,0 +1,404 @@
|
||||
"""
|
||||
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
|
||||
Reference in New Issue
Block a user