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:
2026-07-19 10:00:14 +03:00
parent 02ec1aa28f
commit 34ae328b0d
182 changed files with 34874 additions and 3556 deletions

View 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