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,87 @@
"""
WebSocket endpoint — one persistent connection per connected client (waiter PWA, KDS).
Authentication: JWT token passed as query param ?token=<jwt>
(Same pattern as SSE — browser WebSocket API also cannot set custom headers.)
Protocol:
1. Client connects: ws://host/api/ws?token=<jwt>
2. Client immediately sends: { "cursor": <last_seq_id> }
(0 = first connect / no history; any positive int = resume from that point)
3. Server replays all missed events (seq > cursor) as individual frames
4. Server sends { "type": "ready" } to signal end of replay / start of live stream
5. Live events arrive as: { "seq": 123, "type": "...", "data": { ... } }
6. Every 25s server sends: { "type": "ping" }
7. Client may respond: { "type": "pong" } (ignored if not sent)
8. On disconnect/error the client reconnects and repeats from step 2
"""
import asyncio
import json
import logging
from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Query
from starlette.websockets import WebSocketState
from routers.deps import decode_token
from services.ws_bus import connect, disconnect, get_events_since
router = APIRouter()
logger = logging.getLogger(__name__)
KEEPALIVE_INTERVAL = 25 # seconds
@router.websocket("/connect")
async def ws_endpoint(websocket: WebSocket, token: str = Query(...)):
# ── Auth ──────────────────────────────────────────────────────────────────
try:
payload = decode_token(token)
user_id: int = int(payload["sub"])
except Exception:
await websocket.close(code=4001)
return
await websocket.accept()
# ── Wait for cursor frame ─────────────────────────────────────────────────
cursor = 0
try:
raw = await asyncio.wait_for(websocket.receive_text(), timeout=10.0)
msg = json.loads(raw)
cursor = int(msg.get("cursor", 0))
except (asyncio.TimeoutError, Exception):
pass # no cursor sent — treat as fresh connect (cursor=0)
# ── Register this connection ──────────────────────────────────────────────
q = await connect(user_id)
try:
# ── Replay missed events ──────────────────────────────────────────────
if cursor > 0:
missed = get_events_since(cursor, user_id)
for event in missed:
await websocket.send_text(json.dumps(event))
# Signal end of replay / start of live stream
await websocket.send_text(json.dumps({"type": "ready"}))
# ── Live stream loop ──────────────────────────────────────────────────
while True:
try:
frame = await asyncio.wait_for(q.get(), timeout=KEEPALIVE_INTERVAL)
if websocket.client_state == WebSocketState.CONNECTED:
await websocket.send_text(frame)
except asyncio.TimeoutError:
# Send keepalive ping
if websocket.client_state == WebSocketState.CONNECTED:
await websocket.send_text(json.dumps({"type": "ping"}))
else:
break
except WebSocketDisconnect:
pass
except Exception as e:
logger.debug("ws_endpoint: connection closed for user %s: %s", user_id, e)
finally:
await disconnect(user_id, q)