Files
xenia-pos-local/local_backend/routers/messages.py
bonamin 8ba8c95ecd feat: initial commit — local services (backend + manager dashboard + waiter PWA)
Includes all work to date:
- local_backend: FastAPI backend with products, orders, tables, shifts, cloud sync
- manager_dashboard: React manager UI with product/category management, reports, settings
- waiter_pwa: React PWA for waiter devices
- Category reparent endpoint and UI
- Waiter domain: local_ip sent on heartbeat, waiter_domain persisted from cloud response
- QR code modal in AppInfoTab for waiter domain
- Product form: number input spinners removed, category pre-selected on new product
- Category row: count badge moved to far right

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 14:04:38 +03:00

216 lines
7.1 KiB
Python

import json
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session, joinedload
from typing import List
from database import get_db
from models.message import StaffMessage, StaffMessageAck, QuickMessageTemplate
from models.user import User
from schemas.message import (
SendMessageRequest, StaffMessageOut,
QuickTemplateCreate, QuickTemplateUpdate, QuickTemplateOut,
)
from routers.deps import get_current_user, require_manager
from services.sse_bus import broadcast_sync
router = APIRouter()
def _load_msg(db: Session, msg_id: int) -> StaffMessage:
"""Reload a message with sender and acks eagerly loaded."""
return db.query(StaffMessage).options(
joinedload(StaffMessage.sender),
joinedload(StaffMessage.acks),
).filter(StaffMessage.id == msg_id).one()
def _message_out(msg: StaffMessage) -> StaffMessageOut:
sender_name = None
try:
sender_name = msg.sender.username if msg.sender else None
except Exception:
pass
return StaffMessageOut(
id=msg.id,
sender_id=msg.sender_id,
sender_name=sender_name,
body=msg.body,
target_waiter_ids=msg.target_waiter_ids,
table_ids=msg.table_ids,
created_at=msg.created_at,
acked_by=[ack.waiter_id for ack in msg.acks],
)
# ─── Quick templates ──────────────────────────────────────────────────────────
@router.get("/templates", response_model=List[QuickTemplateOut])
def list_templates(
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
return db.query(QuickMessageTemplate).filter(
QuickMessageTemplate.is_active == True
).order_by(QuickMessageTemplate.sort_order, QuickMessageTemplate.id).all()
@router.post("/templates", response_model=QuickTemplateOut, status_code=status.HTTP_201_CREATED)
def create_template(
body: QuickTemplateCreate,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
t = QuickMessageTemplate(**body.model_dump())
db.add(t)
db.commit()
db.refresh(t)
return t
@router.put("/templates/{template_id}", response_model=QuickTemplateOut)
def update_template(
template_id: int,
body: QuickTemplateUpdate,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
t = db.query(QuickMessageTemplate).filter(QuickMessageTemplate.id == template_id).first()
if not t:
raise HTTPException(status_code=404, detail="Template not found")
for k, v in body.model_dump(exclude_unset=True).items():
setattr(t, k, v)
db.commit()
db.refresh(t)
return t
@router.delete("/templates/{template_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_template(
template_id: int,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
t = db.query(QuickMessageTemplate).filter(QuickMessageTemplate.id == template_id).first()
if not t:
raise HTTPException(status_code=404, detail="Template not found")
t.is_active = False
db.commit()
# ─── Staff messages ───────────────────────────────────────────────────────────
@router.post("/send", response_model=StaffMessageOut, status_code=status.HTTP_201_CREATED)
def send_message(
body: SendMessageRequest,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
msg = StaffMessage(
sender_id=user.id,
body=body.body,
target_waiter_ids=json.dumps(body.target_waiter_ids),
table_ids=json.dumps(body.table_ids or []),
)
db.add(msg)
db.commit()
msg = _load_msg(db, msg.id)
out = _message_out(msg)
# Broadcast to targeted users (empty list = all connected users)
target_ids = body.target_waiter_ids if body.target_waiter_ids else None
broadcast_sync(
"message_sent",
{
"id": out.id,
"sender_id": out.sender_id,
"sender_name": out.sender_name,
"body": out.body,
"table_ids": out.table_ids,
"created_at": out.created_at.isoformat() if out.created_at else None,
},
user_ids=target_ids,
)
return out
@router.get("/unread", response_model=List[StaffMessageOut])
def get_unread_messages(
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
"""
Returns messages targeting this waiter that they haven't acked yet.
A message targets a waiter if their ID is in target_waiter_ids,
OR if target_waiter_ids is empty (broadcast to all).
"""
all_msgs = db.query(StaffMessage).options(
joinedload(StaffMessage.sender),
joinedload(StaffMessage.acks),
).order_by(StaffMessage.created_at.desc()).limit(200).all()
acked_ids = {
ack.message_id
for ack in db.query(StaffMessageAck).filter(StaffMessageAck.waiter_id == user.id).all()
}
result = []
for msg in all_msgs:
if msg.id in acked_ids:
continue
targets = json.loads(msg.target_waiter_ids or "[]")
# Empty list = broadcast to all
if not targets or user.id in targets:
result.append(_message_out(msg))
return result
@router.get("/recent", response_model=List[StaffMessageOut])
def get_recent_messages(
limit: int = 10,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
"""Last N messages targeting this user (for notification history drawer)."""
all_msgs = db.query(StaffMessage).options(
joinedload(StaffMessage.sender),
joinedload(StaffMessage.acks),
).order_by(StaffMessage.created_at.desc()).limit(200).all()
result = []
for msg in all_msgs:
targets = json.loads(msg.target_waiter_ids or "[]")
if not targets or user.id in targets:
result.append(_message_out(msg))
if len(result) >= limit:
break
return result
@router.post("/{message_id}/ack", status_code=status.HTTP_204_NO_CONTENT)
def ack_message(
message_id: int,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
msg = db.query(StaffMessage).filter(StaffMessage.id == message_id).first()
if not msg:
raise HTTPException(status_code=404, detail="Message not found")
existing = db.query(StaffMessageAck).filter(
StaffMessageAck.message_id == message_id,
StaffMessageAck.waiter_id == user.id,
).first()
if not existing:
db.add(StaffMessageAck(message_id=message_id, waiter_id=user.id))
db.commit()
@router.get("/all", response_model=List[StaffMessageOut])
def list_all_messages(
limit: int = 50,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
msgs = db.query(StaffMessage).options(
joinedload(StaffMessage.sender),
joinedload(StaffMessage.acks),
).order_by(StaffMessage.created_at.desc()).limit(limit).all()
return [_message_out(m) for m in msgs]