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>
216 lines
7.1 KiB
Python
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]
|