diff --git a/AzA march 2026/aza_config.py b/AzA march 2026/aza_config.py index 31ab88c..30bd229 100644 --- a/AzA march 2026/aza_config.py +++ b/AzA march 2026/aza_config.py @@ -230,16 +230,17 @@ AVG_TOKENS_PER_REPORT = 3_000 # ─── Launcher ─── LAUNCHER_CONFIG_FILENAME = "kg_diktat_launcher.json" -LAUNCHER_MODULES = ["kg", "ki", "empfang", "notizen", "translator", "medwork_chat", "praxis_chat"] +LAUNCHER_MODULES = ["kg", "praxis_chat", "empfang", "notizen", "translator", "medwork_chat"] LAUNCHER_MODULE_LABELS = { "ki": "KI-Assistent", "kg": "AzA Office", "empfang": "Empfang", - "notizen": "Audio-Notizen", + "notizen": "Diktieren", "translator": "\u00dcbersetzer", "medwork_chat": "\u00c4rzte-Netzwerk", "praxis_chat": "Praxis-Chat", } +LAUNCHER_DISABLED_MODULES = {"medwork_chat"} # ─── Globale Liste aller offenen Fenster für Skalierung ─── _ALL_WINDOWS = [] diff --git a/AzA march 2026/aza_launcher.py b/AzA march 2026/aza_launcher.py index d7c20df..c09ff90 100644 --- a/AzA march 2026/aza_launcher.py +++ b/AzA march 2026/aza_launcher.py @@ -12,6 +12,7 @@ import tkinter as tk from aza_config import ( LAUNCHER_MODULES, LAUNCHER_MODULE_LABELS, + LAUNCHER_DISABLED_MODULES, ) from aza_persistence import ( load_launcher_prefs, @@ -514,32 +515,41 @@ class AzaLauncher(tk.Tk): def _create_card(self, parent, mod_key: str, card_bg: str = None) -> tk.Frame: label = LAUNCHER_MODULE_LABELS.get(mod_key, mod_key) desc = _MODULE_DESCRIPTIONS.get(mod_key, "") - icon_color = _MODULE_ICON_COLORS.get(mod_key, ACCENT) - _cbg = card_bg or CARD_BG + is_disabled = mod_key in LAUNCHER_DISABLED_MODULES + icon_color = "#B0BEC5" if is_disabled else _MODULE_ICON_COLORS.get(mod_key, ACCENT) + _cbg = "#ECEFF1" if is_disabled else (card_bg or CARD_BG) + _cursor = "arrow" if is_disabled else "hand2" + _text_fg = "#90A4AE" if is_disabled else TEXT + _desc_fg = "#B0BEC5" if is_disabled else SUBTLE - card = tk.Frame(parent, bg=_cbg, cursor="hand2", + card = tk.Frame(parent, bg=_cbg, cursor=_cursor, highlightthickness=1, highlightbackground=CARD_BORDER) - inner = tk.Frame(card, bg=_cbg, cursor="hand2") + inner = tk.Frame(card, bg=_cbg, cursor=_cursor) inner.pack(fill="both", expand=True, padx=20, pady=18) - top_row = tk.Frame(inner, bg=_cbg, cursor="hand2") + top_row = tk.Frame(inner, bg=_cbg, cursor=_cursor) top_row.pack(fill="x", pady=(0, 10)) icon_cv = tk.Canvas(top_row, width=_ICON_SZ, height=_ICON_SZ, - bg=icon_color, highlightthickness=0, cursor="hand2") + bg=icon_color, highlightthickness=0, cursor=_cursor) icon_cv.pack(side="left") _draw_module_icon(icon_cv, mod_key) - tk.Label(inner, text=label, font=(FONT_FAMILY, 12, "bold"), - fg=TEXT, bg=_cbg, anchor="w", cursor="hand2" - ).pack(anchor="w") + lbl_title = tk.Label(inner, text=label, font=(FONT_FAMILY, 12, "bold"), + fg=_text_fg, bg=_cbg, anchor="w", cursor=_cursor) + lbl_title.pack(anchor="w") if desc: - tk.Label(inner, text=desc, font=(FONT_FAMILY, 9), - fg=SUBTLE, bg=_cbg, anchor="w", - justify="left", cursor="hand2" - ).pack(anchor="w", pady=(4, 0)) + lbl_desc = tk.Label(inner, text=desc, font=(FONT_FAMILY, 9), + fg=_desc_fg, bg=_cbg, anchor="w", + justify="left", cursor=_cursor) + lbl_desc.pack(anchor="w", pady=(4, 0)) + + if is_disabled: + tk.Label(inner, text="Bald verf\u00fcgbar", font=(FONT_FAMILY, 8, "italic"), + fg="#B0BEC5", bg=_cbg).pack(anchor="w", pady=(4, 0)) + return card def on_enter(e): card.configure(highlightbackground=CARD_HOVER_BORDER, highlightthickness=2) diff --git a/AzA march 2026/basis14.py b/AzA march 2026/basis14.py index 6ef275b..bf15402 100644 --- a/AzA march 2026/basis14.py +++ b/AzA march 2026/basis14.py @@ -5405,19 +5405,30 @@ WICHTIG unbedingt einhalten: _started = [time.time()] _mouse_was_pressed = [False] + _press_time = [0.0] + def _on_click(x, y, button, pressed): if button != MouseButton.left: return if pressed: if time.time() - _started[0] > 0.3: _mouse_was_pressed[0] = True + _press_time[0] = time.time() return if not _mouse_was_pressed[0]: return _mouse_was_pressed[0] = False + hold_duration = time.time() - _press_time[0] + was_drag = hold_duration > 0.20 + time.sleep(0.05) try: kbd = KbdController() + if not was_drag: + from pynput.mouse import Controller as MController + mc = MController() + mc.click(MouseButton.left, 2) + time.sleep(0.1) with kbd.pressed(Key.ctrl): kbd.tap(KeyCode.from_char('c')) except Exception: @@ -5827,6 +5838,8 @@ WICHTIG unbedingt einhalten: values=["Alle"], width=18, state="readonly") _rcpt_combo.pack(side="left") + _rcpt_refresh_job = [None] + def _refresh_recipients(): try: bu = self.get_backend_url() @@ -5840,7 +5853,15 @@ WICHTIG unbedingt einhalten: except Exception: pass - threading.Thread(target=_refresh_recipients, daemon=True).start() + def _periodic_refresh_recipients(): + threading.Thread(target=_refresh_recipients, daemon=True).start() + try: + if dlg.winfo_exists(): + _rcpt_refresh_job[0] = dlg.after(15000, _periodic_refresh_recipients) + except Exception: + pass + + _periodic_refresh_recipients() # --- Senden --- def do_send(): diff --git a/AzA march 2026/deploy/Caddyfile b/AzA march 2026/deploy/Caddyfile index 22f02a5..1df44cc 100644 --- a/AzA march 2026/deploy/Caddyfile +++ b/AzA march 2026/deploy/Caddyfile @@ -37,12 +37,14 @@ } } -# Empfang-Subdomain: empfang.aza-medwork.ch -> /empfang/ +# Empfang-Subdomain: empfang.aza-medwork.ch +# Root "/" wird transparent auf /empfang/ umgeschrieben (kein sichtbarer Redirect) {$AZA_EMPFANG_DOMAIN:empfang.aza-medwork.ch} { encode gzip zstd handle / { - redir https://{host}/empfang/ permanent + rewrite * /empfang/ + reverse_proxy {$BACKEND_UPSTREAM:backend:8000} } handle { diff --git a/AzA march 2026/deploy/aza-deploy/Caddyfile b/AzA march 2026/deploy/aza-deploy/Caddyfile index 22f02a5..1df44cc 100644 --- a/AzA march 2026/deploy/aza-deploy/Caddyfile +++ b/AzA march 2026/deploy/aza-deploy/Caddyfile @@ -37,12 +37,14 @@ } } -# Empfang-Subdomain: empfang.aza-medwork.ch -> /empfang/ +# Empfang-Subdomain: empfang.aza-medwork.ch +# Root "/" wird transparent auf /empfang/ umgeschrieben (kein sichtbarer Redirect) {$AZA_EMPFANG_DOMAIN:empfang.aza-medwork.ch} { encode gzip zstd handle / { - redir https://{host}/empfang/ permanent + rewrite * /empfang/ + reverse_proxy {$BACKEND_UPSTREAM:backend:8000} } handle { diff --git a/AzA march 2026/empfang_routes.py b/AzA march 2026/empfang_routes.py index ee5de2b..f2ebd40 100644 --- a/AzA march 2026/empfang_routes.py +++ b/AzA march 2026/empfang_routes.py @@ -1,16 +1,21 @@ # -*- coding: utf-8 -*- """ -AZA Empfang - Backend-Routen fuer Empfangs-/Rezeptionsnachrichten. -V2: Thread-Support fuer echten bidirektionalen Chat. +AZA Empfang - Backend-Routen V4. +Serverseitige Auth, Benutzer, Sessions, Nachrichten, Aufgaben. +Alle Daten practice-scoped. Backend ist die einzige Wahrheit. """ +import hashlib +import hmac import json import os +import secrets import time import uuid from pathlib import Path +from typing import Optional -from fastapi import APIRouter, HTTPException, Request +from fastapi import APIRouter, Cookie, HTTPException, Query, Request, Response from fastapi.responses import HTMLResponse, JSONResponse from pydantic import BaseModel, Field @@ -18,31 +23,498 @@ router = APIRouter() _DATA_DIR = Path(__file__).resolve().parent / "data" _EMPFANG_FILE = _DATA_DIR / "empfang_nachrichten.json" +_PRACTICES_FILE = _DATA_DIR / "empfang_practices.json" +_ACCOUNTS_FILE = _DATA_DIR / "empfang_accounts.json" +_SESSIONS_FILE = _DATA_DIR / "empfang_sessions.json" +_TASKS_FILE = _DATA_DIR / "empfang_tasks.json" + +DEFAULT_PRACTICE_ID = "default" +SESSION_MAX_AGE = 30 * 24 * 3600 # 30 Tage def _ensure_data_dir(): _DATA_DIR.mkdir(parents=True, exist_ok=True) -def _load_messages() -> list[dict]: - if not _EMPFANG_FILE.is_file(): - return [] +# ===================================================================== +# JSON helpers (atomic write) +# ===================================================================== + +def _load_json(path: Path, default=None): + if not path.is_file(): + return default if default is not None else [] try: - with open(_EMPFANG_FILE, "r", encoding="utf-8") as f: - data = json.load(f) - return data if isinstance(data, list) else [] + with open(path, "r", encoding="utf-8") as f: + return json.load(f) except Exception: - return [] + return default if default is not None else [] + + +def _save_json(path: Path, data): + _ensure_data_dir() + tmp = str(path) + ".tmp" + with open(tmp, "w", encoding="utf-8") as f: + json.dump(data, f, indent=2, ensure_ascii=False) + os.replace(tmp, str(path)) + + +# ===================================================================== +# Password hashing (PBKDF2 – no external dependency) +# ===================================================================== + +def _hash_password(password: str, salt: str = None) -> tuple[str, str]: + salt = salt or secrets.token_hex(16) + dk = hashlib.pbkdf2_hmac("sha256", password.encode(), salt.encode(), 100_000) + return dk.hex(), salt + + +def _verify_password(password: str, stored_hash: str, salt: str) -> bool: + dk = hashlib.pbkdf2_hmac("sha256", password.encode(), salt.encode(), 100_000) + return hmac.compare_digest(dk.hex(), stored_hash) + + +# ===================================================================== +# Practices +# ===================================================================== + +def _load_practices() -> dict: + return _load_json(_PRACTICES_FILE, {}) + + +def _save_practices(data: dict): + _save_json(_PRACTICES_FILE, data) + + +def _ensure_default_practice(): + practices = _load_practices() + if DEFAULT_PRACTICE_ID not in practices: + practices[DEFAULT_PRACTICE_ID] = { + "practice_id": DEFAULT_PRACTICE_ID, + "name": "Meine Praxis", + "invite_code": secrets.token_urlsafe(8), + "created": time.strftime("%Y-%m-%d %H:%M:%S"), + } + _save_practices(practices) + _migrate_old_users(DEFAULT_PRACTICE_ID) + return practices[DEFAULT_PRACTICE_ID] + + +def _migrate_old_users(practice_id: str): + """Migriert alte empfang_users.json Strings zu echten Accounts.""" + old_file = _DATA_DIR / "empfang_users.json" + if not old_file.is_file(): + return + try: + names = json.loads(old_file.read_text(encoding="utf-8")) + if not isinstance(names, list): + return + accounts = _load_accounts() + for name in names: + name = name.strip() + if not name: + continue + exists = any( + a["display_name"] == name and a["practice_id"] == practice_id + for a in accounts.values() + ) + if not exists: + uid = uuid.uuid4().hex[:12] + pw_hash, pw_salt = _hash_password(name.lower()) + accounts[uid] = { + "user_id": uid, + "practice_id": practice_id, + "display_name": name, + "role": "mpa", + "pw_hash": pw_hash, + "pw_salt": pw_salt, + "created": time.strftime("%Y-%m-%d %H:%M:%S"), + } + _save_accounts(accounts) + except Exception: + pass + + +# ===================================================================== +# Accounts (practice-scoped users with auth) +# ===================================================================== + +def _load_accounts() -> dict: + return _load_json(_ACCOUNTS_FILE, {}) + + +def _save_accounts(data: dict): + _save_json(_ACCOUNTS_FILE, data) + + +def _practice_users(practice_id: str) -> list[dict]: + accounts = _load_accounts() + return [ + {"user_id": a["user_id"], "display_name": a["display_name"], "role": a["role"]} + for a in accounts.values() + if a.get("practice_id") == practice_id + ] + + +# ===================================================================== +# Sessions +# ===================================================================== + +def _load_sessions() -> dict: + return _load_json(_SESSIONS_FILE, {}) + + +def _save_sessions(data: dict): + _save_json(_SESSIONS_FILE, data) + + +def _create_session(user_id: str, practice_id: str, display_name: str, role: str) -> str: + token = secrets.token_urlsafe(32) + sessions = _load_sessions() + sessions[token] = { + "user_id": user_id, + "practice_id": practice_id, + "display_name": display_name, + "role": role, + "created": time.time(), + "last_active": time.time(), + } + _save_sessions(sessions) + return token + + +def _get_session(token: str) -> Optional[dict]: + if not token: + return None + sessions = _load_sessions() + s = sessions.get(token) + if not s: + return None + if time.time() - s.get("created", 0) > SESSION_MAX_AGE: + del sessions[token] + _save_sessions(sessions) + return None + s["last_active"] = time.time() + sessions[token] = s + _save_sessions(sessions) + return s + + +def _delete_session(token: str): + sessions = _load_sessions() + if token in sessions: + del sessions[token] + _save_sessions(sessions) + + +def _session_from_request(request: Request) -> Optional[dict]: + token = request.cookies.get("aza_session") or "" + if not token: + auth = request.headers.get("Authorization", "") + if auth.startswith("Bearer "): + token = auth[7:] + if not token: + token = request.query_params.get("session_token", "") + return _get_session(token) + + +def _require_session(request: Request) -> dict: + s = _session_from_request(request) + if not s: + raise HTTPException(status_code=401, detail="Nicht angemeldet") + return s + + +# ===================================================================== +# Messages (practice-scoped) +# ===================================================================== + +def _load_messages() -> list[dict]: + return _load_json(_EMPFANG_FILE, []) def _save_messages(messages: list[dict]): - _ensure_data_dir() - tmp = str(_EMPFANG_FILE) + ".tmp" - with open(tmp, "w", encoding="utf-8") as f: - json.dump(messages, f, indent=2, ensure_ascii=False) - os.replace(tmp, str(_EMPFANG_FILE)) + _save_json(_EMPFANG_FILE, messages) +def _msg_practice(m: dict) -> str: + return m.get("practice_id") or DEFAULT_PRACTICE_ID + + +def _filter_by_practice(messages: list[dict], pid: str) -> list[dict]: + return [m for m in messages if _msg_practice(m) == pid] + + +# ===================================================================== +# Tasks (practice-scoped, server-side) +# ===================================================================== + +def _load_tasks() -> list[dict]: + return _load_json(_TASKS_FILE, []) + + +def _save_tasks(tasks: list[dict]): + _save_json(_TASKS_FILE, tasks) + + +# ===================================================================== +# AUTH ENDPOINTS +# ===================================================================== + +@router.post("/auth/setup") +async def auth_setup(request: Request): + """Erstellt den ersten Admin-Benutzer fuer die Default-Praxis. + Nur aufrufbar wenn noch keine Accounts existieren.""" + practice = _ensure_default_practice() + accounts = _load_accounts() + practice_accounts = [a for a in accounts.values() + if a.get("practice_id") == DEFAULT_PRACTICE_ID] + if practice_accounts: + raise HTTPException(status_code=409, + detail="Setup bereits abgeschlossen. Bitte Login verwenden.") + try: + body = await request.json() + except Exception: + body = {} + name = (body.get("name") or "").strip() + password = (body.get("password") or "").strip() + if not name or not password or len(password) < 4: + raise HTTPException(status_code=400, + detail="Name und Passwort (min. 4 Zeichen) erforderlich") + uid = uuid.uuid4().hex[:12] + pw_hash, pw_salt = _hash_password(password) + accounts[uid] = { + "user_id": uid, + "practice_id": DEFAULT_PRACTICE_ID, + "display_name": name, + "role": "admin", + "pw_hash": pw_hash, + "pw_salt": pw_salt, + "created": time.strftime("%Y-%m-%d %H:%M:%S"), + } + _save_accounts(accounts) + token = _create_session(uid, DEFAULT_PRACTICE_ID, name, "admin") + resp = JSONResponse(content={ + "success": True, "user_id": uid, "role": "admin", + "display_name": name, "practice_id": DEFAULT_PRACTICE_ID, + "invite_code": practice.get("invite_code", ""), + }) + resp.set_cookie("aza_session", token, httponly=True, samesite="lax", + max_age=SESSION_MAX_AGE) + return resp + + +@router.post("/auth/login") +async def auth_login(request: Request): + """Login mit Name + Passwort.""" + try: + body = await request.json() + except Exception: + body = {} + name = (body.get("name") or "").strip() + password = (body.get("password") or "").strip() + pid = (body.get("practice_id") or "").strip() or DEFAULT_PRACTICE_ID + if not name or not password: + raise HTTPException(status_code=400, detail="Name und Passwort erforderlich") + accounts = _load_accounts() + target = None + for a in accounts.values(): + if a["display_name"] == name and a.get("practice_id") == pid: + target = a + break + if not target: + raise HTTPException(status_code=401, detail="Benutzer nicht gefunden") + if not _verify_password(password, target["pw_hash"], target["pw_salt"]): + raise HTTPException(status_code=401, detail="Falsches Passwort") + token = _create_session(target["user_id"], pid, name, target["role"]) + resp = JSONResponse(content={ + "success": True, "user_id": target["user_id"], "role": target["role"], + "display_name": name, "practice_id": pid, + }) + resp.set_cookie("aza_session", token, httponly=True, samesite="lax", + max_age=SESSION_MAX_AGE) + return resp + + +@router.post("/auth/register") +async def auth_register(request: Request): + """Neuen Benutzer registrieren mit Einladungscode.""" + try: + body = await request.json() + except Exception: + body = {} + invite_code = (body.get("invite_code") or "").strip() + name = (body.get("name") or "").strip() + password = (body.get("password") or "").strip() + role = (body.get("role") or "mpa").strip() + if not invite_code or not name or not password or len(password) < 4: + raise HTTPException(status_code=400, + detail="Einladungscode, Name und Passwort (min. 4 Zeichen) erforderlich") + if role not in ("admin", "arzt", "mpa", "empfang"): + role = "mpa" + practices = _load_practices() + target_pid = None + for pid, p in practices.items(): + if p.get("invite_code") == invite_code: + target_pid = pid + break + if not target_pid: + raise HTTPException(status_code=403, detail="Ungueltiger Einladungscode") + accounts = _load_accounts() + exists = any(a["display_name"] == name and a.get("practice_id") == target_pid + for a in accounts.values()) + if exists: + raise HTTPException(status_code=409, detail="Benutzername bereits vergeben") + uid = uuid.uuid4().hex[:12] + pw_hash, pw_salt = _hash_password(password) + accounts[uid] = { + "user_id": uid, + "practice_id": target_pid, + "display_name": name, + "role": role, + "pw_hash": pw_hash, + "pw_salt": pw_salt, + "created": time.strftime("%Y-%m-%d %H:%M:%S"), + } + _save_accounts(accounts) + token = _create_session(uid, target_pid, name, role) + resp = JSONResponse(content={ + "success": True, "user_id": uid, "role": role, + "display_name": name, "practice_id": target_pid, + }) + resp.set_cookie("aza_session", token, httponly=True, samesite="lax", + max_age=SESSION_MAX_AGE) + return resp + + +@router.get("/auth/me") +async def auth_me(request: Request): + """Aktuelle Session pruefen. Liefert User-Daten oder 401.""" + s = _session_from_request(request) + if not s: + return JSONResponse(status_code=401, content={"authenticated": False}) + return JSONResponse(content={ + "authenticated": True, + "user_id": s["user_id"], + "display_name": s["display_name"], + "role": s["role"], + "practice_id": s["practice_id"], + }) + + +@router.post("/auth/logout") +async def auth_logout(request: Request): + token = request.cookies.get("aza_session", "") + _delete_session(token) + resp = JSONResponse(content={"success": True}) + resp.delete_cookie("aza_session") + return resp + + +@router.get("/auth/needs_setup") +async def auth_needs_setup(): + """Pruefen ob Setup noetig ist (keine Accounts vorhanden).""" + _ensure_default_practice() + accounts = _load_accounts() + has_accounts = any(a.get("practice_id") == DEFAULT_PRACTICE_ID + for a in accounts.values()) + practices = _load_practices() + invite_code = practices.get(DEFAULT_PRACTICE_ID, {}).get("invite_code", "") + return JSONResponse(content={ + "needs_setup": not has_accounts, + "invite_code": invite_code if not has_accounts else "", + }) + + +# ===================================================================== +# USER MANAGEMENT (admin only for invite/role changes) +# ===================================================================== + +@router.get("/users") +async def empfang_users(request: Request): + """Liefert Benutzer der Praxis. Offen fuer alle authentifizierten + Legacy.""" + s = _session_from_request(request) + if s: + users = _practice_users(s["practice_id"]) + return JSONResponse(content={ + "users": [u["display_name"] for u in users], + "users_full": users, + "practice_id": s["practice_id"], + }) + pid = request.query_params.get("practice_id", "") or DEFAULT_PRACTICE_ID + users = _practice_users(pid) + if users: + return JSONResponse(content={ + "users": [u["display_name"] for u in users], + "practice_id": pid, + }) + old_file = _DATA_DIR / "empfang_users.json" + if old_file.is_file(): + try: + names = json.loads(old_file.read_text(encoding="utf-8")) + if isinstance(names, list): + return JSONResponse(content={"users": names, "practice_id": pid}) + except Exception: + pass + return JSONResponse(content={"users": [], "practice_id": pid}) + + +@router.post("/users") +async def empfang_register_user(request: Request): + """Legacy-kompatibel: Benutzer anlegen/umbenennen/loeschen.""" + try: + body = await request.json() + except Exception: + body = {} + name = (body.get("name") or "").strip() + action = (body.get("action") or "add").strip() + pid = DEFAULT_PRACTICE_ID + s = _session_from_request(request) + if s: + pid = s["practice_id"] + if not name: + return JSONResponse(content={"success": False}) + accounts = _load_accounts() + if action == "delete": + to_del = [uid for uid, a in accounts.items() + if a["display_name"] == name and a.get("practice_id") == pid] + for uid in to_del: + del accounts[uid] + _save_accounts(accounts) + elif action == "rename": + new_name = (body.get("new_name") or "").strip() + if new_name: + for a in accounts.values(): + if a["display_name"] == name and a.get("practice_id") == pid: + a["display_name"] = new_name + _save_accounts(accounts) + else: + exists = any(a["display_name"] == name and a.get("practice_id") == pid + for a in accounts.values()) + if not exists: + uid = uuid.uuid4().hex[:12] + pw_hash, pw_salt = _hash_password(name.lower()) + accounts[uid] = { + "user_id": uid, + "practice_id": pid, + "display_name": name, + "role": "mpa", + "pw_hash": pw_hash, + "pw_salt": pw_salt, + "created": time.strftime("%Y-%m-%d %H:%M:%S"), + } + _save_accounts(accounts) + users = _practice_users(pid) + return JSONResponse(content={ + "success": True, + "users": [u["display_name"] for u in users], + "practice_id": pid, + }) + + +# ===================================================================== +# MESSAGE ROUTES (practice-scoped) +# ===================================================================== + class EmpfangMessage(BaseModel): medikamente: str = "" therapieplan: str = "" @@ -51,11 +523,18 @@ class EmpfangMessage(BaseModel): patient: str = "" absender: str = "" zeitstempel: str = "" + practice_id: str = "" extras: dict = Field(default_factory=dict) @router.post("/send") -async def empfang_send(msg: EmpfangMessage): +async def empfang_send(msg: EmpfangMessage, request: Request): + s = _session_from_request(request) + pid = msg.practice_id.strip() or (s["practice_id"] if s else DEFAULT_PRACTICE_ID) + absender = msg.absender.strip() + if s and not absender: + absender = s["display_name"] + msg_id = uuid.uuid4().hex[:12] messages = _load_messages() @@ -72,15 +551,17 @@ async def empfang_send(msg: EmpfangMessage): entry = { "id": msg_id, "thread_id": thread_id, + "practice_id": pid, "medikamente": msg.medikamente.strip(), "therapieplan": msg.therapieplan.strip(), "procedere": msg.procedere.strip(), "kommentar": msg.kommentar.strip(), "patient": msg.patient.strip(), - "absender": msg.absender.strip(), + "absender": absender, "zeitstempel": msg.zeitstempel.strip() or time.strftime("%Y-%m-%d %H:%M:%S"), "empfangen": time.strftime("%Y-%m-%d %H:%M:%S"), "status": "offen", + "user_id": s["user_id"] if s else "", } if msg.extras: entry["extras"] = msg.extras @@ -88,19 +569,29 @@ async def empfang_send(msg: EmpfangMessage): messages.insert(0, entry) _save_messages(messages) - return JSONResponse(content={"success": True, "id": msg_id, "thread_id": thread_id}) + return JSONResponse(content={ + "success": True, "id": msg_id, "thread_id": thread_id, + "practice_id": pid, + }) @router.get("/messages") -async def empfang_list(): +async def empfang_list(request: Request, practice_id: Optional[str] = Query(None)): + s = _session_from_request(request) + pid = (practice_id or "").strip() or (s["practice_id"] if s else DEFAULT_PRACTICE_ID) messages = _load_messages() - return JSONResponse(content={"success": True, "messages": messages}) + filtered = _filter_by_practice(messages, pid) + return JSONResponse(content={"success": True, "messages": filtered}) @router.get("/thread/{thread_id}") -async def empfang_thread(thread_id: str): +async def empfang_thread(thread_id: str, request: Request, + practice_id: Optional[str] = Query(None)): + s = _session_from_request(request) + pid = (practice_id or "").strip() or (s["practice_id"] if s else DEFAULT_PRACTICE_ID) messages = _load_messages() - thread = [m for m in messages if m.get("thread_id") == thread_id] + thread = [m for m in messages + if m.get("thread_id") == thread_id and _msg_practice(m) == pid] thread.sort(key=lambda m: m.get("empfangen", "")) return JSONResponse(content={"success": True, "messages": thread}) @@ -112,8 +603,9 @@ async def empfang_done(msg_id: str): if not target: raise HTTPException(status_code=404, detail="Nachricht nicht gefunden") tid = target.get("thread_id", msg_id) + pid = _msg_practice(target) for m in messages: - if m.get("thread_id") == tid: + if m.get("thread_id") == tid and _msg_practice(m) == pid: m["status"] = "erledigt" _save_messages(messages) return JSONResponse(content={"success": True}) @@ -126,84 +618,142 @@ async def empfang_delete(msg_id: str): if not target: raise HTTPException(status_code=404, detail="Nachricht nicht gefunden") tid = target.get("thread_id", msg_id) + pid = _msg_practice(target) if tid == msg_id: new = [m for m in messages - if m.get("thread_id", m.get("id")) != msg_id and m.get("id") != msg_id] + if not (m.get("thread_id", m.get("id")) == msg_id and _msg_practice(m) == pid) + and not (m.get("id") == msg_id)] else: new = [m for m in messages if m.get("id") != msg_id] _save_messages(new) return JSONResponse(content={"success": True}) -_USERS_FILE = _DATA_DIR / "empfang_users.json" +# ===================================================================== +# TASKS (practice-scoped, server-side) +# ===================================================================== + +@router.get("/tasks") +async def empfang_tasks_list(request: Request): + s = _session_from_request(request) + pid = s["practice_id"] if s else DEFAULT_PRACTICE_ID + tasks = _load_tasks() + filtered = [t for t in tasks if t.get("practice_id", DEFAULT_PRACTICE_ID) == pid] + return JSONResponse(content={"success": True, "tasks": filtered}) -def _load_users() -> list[str]: - if not _USERS_FILE.is_file(): - return [] - try: - with open(_USERS_FILE, "r", encoding="utf-8") as f: - data = json.load(f) - return data if isinstance(data, list) else [] - except Exception: - return [] - - -def _save_users(users: list[str]): - _ensure_data_dir() - with open(str(_USERS_FILE), "w", encoding="utf-8") as f: - json.dump(sorted(set(users)), f, ensure_ascii=False) - - -@router.get("/users") -async def empfang_users(): - return JSONResponse(content={"users": _load_users()}) - - -@router.post("/users") -async def empfang_register_user(request: Request): +@router.post("/tasks") +async def empfang_tasks_create(request: Request): + s = _session_from_request(request) + pid = s["practice_id"] if s else DEFAULT_PRACTICE_ID try: body = await request.json() except Exception: body = {} - name = (body.get("name") or "").strip() - action = (body.get("action") or "add").strip() - if not name: - return JSONResponse(content={"success": False}) - users = _load_users() - if action == "delete": - users = [u for u in users if u != name] - _save_users(users) - elif action == "rename": - new_name = (body.get("new_name") or "").strip() - if new_name: - users = [new_name if u == name else u for u in users] - _save_users(users) - else: - if name not in users: - users.append(name) - _save_users(users) - return JSONResponse(content={"success": True, "users": _load_users()}) + text = (body.get("text") or "").strip() + if not text: + raise HTTPException(status_code=400, detail="Text erforderlich") + task = { + "task_id": uuid.uuid4().hex[:12], + "practice_id": pid, + "text": text, + "done": False, + "assignee": (body.get("assignee") or "").strip(), + "created_by": s["display_name"] if s else "", + "created": time.strftime("%Y-%m-%d %H:%M:%S"), + } + tasks = _load_tasks() + tasks.insert(0, task) + _save_tasks(tasks) + return JSONResponse(content={"success": True, "task": task}) +@router.post("/tasks/{task_id}/update") +async def empfang_tasks_update(task_id: str, request: Request): + try: + body = await request.json() + except Exception: + body = {} + tasks = _load_tasks() + target = next((t for t in tasks if t.get("task_id") == task_id), None) + if not target: + raise HTTPException(status_code=404, detail="Aufgabe nicht gefunden") + if "done" in body: + target["done"] = bool(body["done"]) + if "text" in body: + target["text"] = (body["text"] or "").strip() or target["text"] + if "assignee" in body: + target["assignee"] = (body["assignee"] or "").strip() + _save_tasks(tasks) + return JSONResponse(content={"success": True, "task": target}) + + +@router.delete("/tasks/{task_id}") +async def empfang_tasks_delete(task_id: str): + tasks = _load_tasks() + tasks = [t for t in tasks if t.get("task_id") != task_id] + _save_tasks(tasks) + return JSONResponse(content={"success": True}) + + +# ===================================================================== +# CLEANUP + PRACTICE INFO +# ===================================================================== + @router.post("/cleanup") async def empfang_cleanup(request: Request): - """Delete messages older than max_age_days (default 30).""" try: body = await request.json() except Exception: body = {} max_days = int(body.get("max_age_days", 30)) - cutoff = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(time.time() - max_days * 86400)) + s = _session_from_request(request) + pid = (body.get("practice_id") or "").strip() or ( + s["practice_id"] if s else DEFAULT_PRACTICE_ID) + cutoff = time.strftime( + "%Y-%m-%d %H:%M:%S", + time.localtime(time.time() - max_days * 86400), + ) messages = _load_messages() before = len(messages) - kept = [m for m in messages if (m.get("empfangen") or m.get("zeitstempel", "")) >= cutoff] + kept = [ + m for m in messages + if _msg_practice(m) != pid + or (m.get("empfangen") or m.get("zeitstempel", "")) >= cutoff + ] removed = before - len(kept) if removed > 0: _save_messages(kept) - return JSONResponse(content={"success": True, "removed": removed, "remaining": len(kept)}) + return JSONResponse(content={ + "success": True, "removed": removed, "remaining": len(kept), + }) +@router.get("/practice/info") +async def empfang_practice_info(request: Request): + s = _session_from_request(request) + pid = s["practice_id"] if s else DEFAULT_PRACTICE_ID + users = _practice_users(pid) + messages = _filter_by_practice(_load_messages(), pid) + open_count = sum(1 for m in messages if m.get("status") == "offen") + practices = _load_practices() + p = practices.get(pid, {}) + result = { + "practice_id": pid, + "practice_name": p.get("name", ""), + "user_count": len(users), + "message_count": len(messages), + "open_count": open_count, + } + if s and s.get("role") == "admin": + result["invite_code"] = p.get("invite_code", "") + return JSONResponse(content=result) + + +# ===================================================================== +# HTML PAGE +# ===================================================================== + @router.get("/", response_class=HTMLResponse) async def empfang_page(request: Request): html_path = Path(__file__).resolve().parent / "web" / "empfang.html" diff --git a/AzA march 2026/handover.md b/AzA march 2026/handover.md index 5bd5a3a..45c699f 100644 --- a/AzA march 2026/handover.md +++ b/AzA march 2026/handover.md @@ -73,7 +73,185 @@ medizinisch und AZA-konform umgesetzt. Push-Notifications. **Oeffentliche Subdomain:** empfang.aza-medwork.ch (DNS A-Record auf Hetzner, -Caddy-Block bereits konfiguriert). +Caddy-Block konfiguriert, Root wird transparent auf /empfang/ rewritten). + +### 6. AZA Praxis-Tenant-Chat Architektur (VERBINDLICH, 2026-04-18) + +Interne Kurzbezeichnung: **AZA Praxis-Tenant-Chat Architektur** + +#### 6.1 Mandantenmodell (Tenant = Praxis) + +Eine Praxis / Organisation ist die oberste Einheit. Nicht "eine Lizenz = ein Chat", +sondern: Praxis = Mandant = Vertrauensraum. + +- Lizenzen haengen an der Praxis (nicht am einzelnen Benutzer) +- Mehrere Lizenzen einer groesseren Praxis speisen denselben Praxisraum +- Benutzer, Chats, Aufgaben, Dateien gehoeren immer zu genau einer Praxis +- Trennung NIEMALS nur ueber sichtbare Namen – immer ueber `practice_id` + +#### 6.2 Kerndatenmodell + +| Entitaet | Primaerschluessel | Scope | Beschreibung | +|---|---|---|---| +| Practice | `practice_id` (UUID) | Global | Praxis / Organisation / Mandant | +| User | `user_id` (UUID) | practice_id | Benutzer innerhalb einer Praxis | +| Device | `device_id` (UUID) | user_id | Geraet eines Benutzers | +| Channel | `channel_id` (UUID) | practice_id | Chat-Kanal (Allgemein, Empfang, Aerzte, ...) | +| Message | `message_id` (UUID) | channel_id | Einzelne Nachricht in einem Kanal | +| Thread | `thread_id` (UUID) | channel_id | Gruppierung von Nachrichten | +| Task | `task_id` (UUID) | practice_id | Aufgabe mit Zuweisung | +| File | `file_id` (UUID) | practice_id | Anhaenge / Bilder / Dokumente | +| Session | `session_id` (UUID) | user_id + device_id | Authentifizierte Sitzung | + +#### 6.3 Rollenmodell + +Pflicht-Rollen (V1): +- **Practice Admin** – Benutzer einladen, Rollen aendern, Geraete erlauben/sperren/loeschen, Praxisverbindungen freigeben +- **Arzt** – Voller Chat-/Aufgaben-Zugriff, KG-Senden +- **MPA** – Chat-/Aufgaben-Zugriff, Empfangsfunktionen +- **Empfang** – Chat-/Aufgaben-Zugriff, primaer Empfangsfunktionen + +Spaeter optional: +- Externe Praxis (Verbindung per Einladungscode) +- Lesend (Gast) +- Weitere interne Rollen + +NUR Practice Admin darf: +- Benutzer einladen / entfernen +- Rollen aendern +- Geraete erlauben / sperren / loeschen +- Praxisverbindungen freigeben +- Audit-Log einsehen + +#### 6.4 Geraeteverwaltung + +Jedes Geraet hat: +- `device_id` (UUID) +- Geraetename (z.B. "Praxis-PC Empfang", "Arzt-Laptop zuhause") +- Plattform (Windows/macOS/iOS/Android/Browser) +- Letzter Zugriff (Zeitstempel) +- Vertrauensstatus (trusted / pending / blocked) +- Zugehoerigkeit: user_id + practice_id + +Admin-Aktionen: erlauben, sperren, loeschen, erneute Anmeldung erzwingen. +Sichtbar: Praxis-PC Empfang, Arzt-Laptop, iPhone, Apple Watch, alte Geraete. + +#### 6.5 Mobilgeraet-Kopplung (Variante A – VERBINDLICH) + +Bevorzugter Kopplungspfad: +1. Admin/Benutzer waehlt im Web/Desktop: "Mobilgeraet koppeln" +2. System erzeugt QR-Code (enthaelt Einmal-Token + practice_id + user_id) +3. Handy-App scannt QR-Code +4. Serverseitig: sichere Geraetebindung wird erstellt +5. Geraet erscheint in der Geraeteliste des Admins + +KEIN loses offenes Handy-Login. QR-Code + serverseitige Bestaetigung. + +#### 6.6 Browser-Zugang von zuhause + +Benutzer duerfen von zuhause ueber den Browser arbeiten, aber NUR wenn: +- Benutzer wurde eingeladen und Konto ist aktiv +- Geraet ist bestaetigt / vertraut +- Praxiszugehoerigkeit (practice_id) stimmt +- Session ist gueltig (JWT/Token) +- Rollenpruefung erfolgt serverseitig + +#### 6.7 Chat-Kanalstruktur + +Pro Praxis: +1. **Allgemein** – Standard-Kanal, alle Benutzer +2. **Empfang** – Empfangspersonal +3. **Aerzte** – Nur Aerzte +4. **MPA** – Medizinische Praxisassistenz +5. **Labor** – Falls vorhanden +6. **Administration** – Praxisleitung / Verwaltung +7. **Direktchats** – 1:1 zwischen zwei Benutzern + +Spaeter: Externe Praxis-Verbindungen per Einladungscode. +KEINE globale offene Benutzerliste ueber alle Praxen. + +#### 6.8 Aufgaben als erstklassiges Objekt + +Aufgaben sind NICHT nur lokale To-do-Listen, sondern server-seitige Objekte: + +| Feld | Typ | Beschreibung | +|---|---|---| +| `task_id` | UUID | Eindeutige Aufgaben-ID | +| `practice_id` | UUID | Praxis-Scope | +| `created_by` | user_id | Ersteller | +| `assigned_to_user_id` | user_id | Zugewiesen an Benutzer | +| `assigned_to_role` | string | Oder zugewiesen an Rolle | +| `channel_id` | UUID | Zugehoeriger Kanal (optional) | +| `status` | enum | open / in_progress / done / cancelled | +| `due_at` | timestamp | Faelligkeitsdatum (optional) | +| `attachments` | list | Angehaengte Dateien | +| `audit_log` | list | Aenderungsprotokoll | + +Browser, Haupt-App und spaeter Mobile sehen dieselbe Aufgabenbasis. + +#### 6.9 Sicherheitsbasis + +**Pflicht:** +- Praxis-Mandanten-Trennung (practice_id in JEDER Abfrage) +- Serverseitige Rollenpruefung (NICHT clientseitig) +- Geraetebindung (device_id + Vertrauensstatus) +- Admin-Geraeteverwaltung (erlauben/sperren/loeschen) +- Audit-Log fuer sicherheitsrelevante Aktionen +- Session-Timeout (konfigurierbar) +- KEINE Trennung nur ueber Benutzernamen +- Uploads / Dateien praxisgebunden (practice_id) +- KEINE globale Benutzerliste ueber Praxen + +**Sehr sinnvoll (Phase 2/3):** +- 2FA fuer Practice Admin +- Invite-Links mit Ablauf +- Letzte Aktivitaet pro Benutzer/Geraet +- Remote-Logout / Geraet sperren +- Verschluesselte Nachrichtenspeicherung + +#### 6.10 Mobile-Strategie + +Kurzfristig: Browser + Desktop stabil. +Mittelfristig: Echte Mobile-App fuer iPhone / Android (NICHT nur Browser). +Spaeter: Apple Watch / Wearable NUR fuer leichte Funktionen (Benachrichtigung, +kurze Antworten, kein voller Hauptclient). + +Festlegung: Mobile spaeter lieber als echte App, nicht als dauerhafte Browser-Notloesung. + +#### 6.11 Aktueller Stand vs. Zielarchitektur + +**Was schon da ist (V1):** +- Benutzer-Sync via Backend (`empfang_users.json`) +- Thread-basierter Chat (thread_id, reply_to) +- Aufgaben-Panel (localStorage, user-scoped) +- 3-Panel-Layout im Browser-Empfang +- Ton-/Benachrichtigungssystem +- Empfangs-Desktop-Huelle + +**Was noch fehlt fuer V2:** +- practice_id in allen Entitaeten +- Echte Authentifizierung (JWT/Session) +- Serverseitige Kanalstruktur +- Serverseitige Aufgaben (statt localStorage) +- Geraeteverwaltung fuer Praxis-Admin +- QR-Code-Kopplung fuer Mobile +- Audit-Log + +#### 6.12 Umsetzungsphasen + +**Phase 1 (kurzfristig – aktuell):** +Frontend-Layout, Benutzer-Sync, Chat-Threads. Kein Backend-Umbau. +Einzelpraxis-Betrieb reicht. practice_id wird als Konzept vorbereitet, +aber noch nicht erzwungen. + +**Phase 2 (mittelfristig):** +Backend: practice_id + user_id + JWT-Auth einfuehren. Kanalstruktur serverseitig. +Aufgaben serverseitig. Geraeteverwaltung. Admin-Panel fuer Practice Admin. +Presence/Heartbeat. Invite-Links. + +**Phase 3 (spaeter):** +Multi-Tenant produktiv (mehrere Praxen). WebSocket statt Polling. Mobile-App. +QR-Code-Kopplung. 2FA. Verschluesselte Speicherung. Externe Praxis-Verbindungen. --- diff --git a/AzA march 2026/project_status.json b/AzA march 2026/project_status.json index 5b92154..29636c8 100644 --- a/AzA march 2026/project_status.json +++ b/AzA march 2026/project_status.json @@ -33,7 +33,7 @@ "HANDOVER-REGEL – Sicherheitsblock: Diesen Sicherheits-Benchmark NICHT jedes Mal neu diskutieren. Bei Sicherheits-/Architekturfragen auf dieses Zielbild zurueckgreifen statt neu zu improvisieren. Keine ueberzoegenen Aussenbehauptungen zu HIN/EPD/ISO ohne tatsaechliche Zertifizierung/Integration.", "ARCHITEKTUR – Medikamenten-Quellenlogik: Inhaltsquelle und Originallink getrennt (FIX-11). Inhaltsquellen: _fetch_doccheck_info (Standard) und _fetch_pharmawiki_info (Fallback), benutzerwaehlbar ueber _MED_CONTENT_QUELLEN / med_content_quelle. Originallink: _MED_QUELLEN / medikament_quelle (CH=Compendium, AT=BASG, DE=BfArM) unveraendert. _MEDICATION_FACTS als Offline-Fallback. Deckt alle auf DocCheck + PharmaWiki verfuegbaren Medikamente ab. Spaetere Marktprofile (DE/AT) separat.", "ZUKUNFTSBLOCK – Internationalisierung / Laender- und Quellenprofile: NICHT fuer jetzt. Erst DACH sauber stabilisieren (CH/DE/AT), Produkt erfolgreich machen, Go-Live sichern. Danach pruefen: Mehrsprachigkeit, laenderspezifische med. Quellenprofile. Profillogik vorsehen: app_language, market_region, med_source_profile, dx_source_profile, therapy_source_profile + manueller Override durch Benutzer/Praxis. Handelsnamen, Zulassungen, Fachinfos und Verfuegbarkeit sind laenderspezifisch – deshalb spaeter Quellenprofile pro Markt statt Einheitslogik.", - "ARCHITEKTURZIEL – AZA Praxis-Chat (VERBINDLICH, 2026-04-18): AZA Praxis-Chat wird strukturell in Richtung Softros entwickelt (Benutzer, Gruppen, Direktchat, Verlauf, Status), aber im Design ruhig, modern, medizinisch und AZA-konform umgesetzt. Softros ist FUNKTIONSVORBILD (Benutzerliste, Gruppen, Direktchat, Status, schnelle Interaktion), NICHT Designvorbild (kein 90er-Windows-Look). Zielstruktur: 3-Panel-Layout (Sidebar links mit Benutzern/Kanaelen/Status, Hauptchat mitte mit Inline-Eingabe, Aufgaben rechts). NICHT in rein kartenartige Einzelloesung zurueckfallen. Phase 1: Frontend 3-Panel-Layout + Inline-Chat + Archiv-Tab (kein Backend-Umbau). Phase 2: Backend-Kanaele + Presence + Direktchat-Routing. Phase 3: Multi-Tenant + WebSocket + Auth + Push. Oeffentliche Subdomain: empfang.aza-medwork.ch (DNS + Caddy vorbereitet)." + "ARCHITEKTURZIEL – AZA Praxis-Tenant-Chat Architektur (VERBINDLICH, 2026-04-18): Praxis/Organisation ist oberste Einheit (Mandant/Tenant). NICHT 'eine Lizenz = ein Chat', sondern: Lizenzen haengen an der Praxis, mehrere Lizenzen speisen denselben Praxisraum. Kerndatenmodell: practice_id, user_id, device_id, role, channel_id, task_id, session_id. Rollen: Practice Admin (einladen, Rollen/Geraete verwalten), Arzt, MPA, Empfang. Geraeteverwaltung: Admin sieht/erlaubt/sperrt/loescht Geraete. Mobilgeraet-Kopplung per QR-Code (Variante A verbindlich). Browser-Zugang von zuhause nur mit eingeladenem Konto + vertrautem Geraet + gueltiger Session. Chat-Kanaele pro Praxis: Allgemein, Empfang, Aerzte, MPA, Labor, Administration, Direktchats. Aufgaben als erstklassiges serverseitiges Objekt (task_id, practice_id, assigned_to, status, due_at, audit_log). Sicherheitsbasis: practice_id in JEDER Abfrage, serverseitige Rollenpruefung, Geraetebindung, Audit-Log, Session-Timeout, keine Trennung nur ueber Namen. Mobile-Strategie: kurzfristig Browser+Desktop, mittelfristig echte Mobile-App, spaeter Apple Watch nur leichte Funktionen. Phase 1: Frontend-Layout + Benutzer-Sync (aktuell). Phase 2: Backend practice_id + JWT + Kanaele + serverseitige Aufgaben + Geraeteverwaltung. Phase 3: Multi-Tenant produktiv + WebSocket + Mobile-App + QR-Kopplung + 2FA." ], "auth_contract": { "api_token_env": "MEDWORK_API_TOKEN", diff --git a/AzA march 2026/web/empfang.html b/AzA march 2026/web/empfang.html index 1afefad..1f7b222 100644 --- a/AzA march 2026/web/empfang.html +++ b/AzA march 2026/web/empfang.html @@ -377,9 +377,35 @@ var currentToneIdx = parseInt(localStorage.getItem('empfang_tone_idx') || '0', 1 renderSidebarUsers(); updateSbMe(); renderUserMgmt(); + syncUsersFromServer(); loadUserData(); })(); +function syncUsersFromServer() { + fetch(API_BASE + '/users').then(function(r) { return r.json(); }).then(function(d) { + var serverUsers = d.users || []; + if (!serverUsers.length) return; + var changed = false; + serverUsers.forEach(function(u) { + if (!knownUsers.includes(u)) { + knownUsers.push(u); + changed = true; + } + }); + knownUsers.forEach(function(u) { + if (!serverUsers.includes(u)) { + fetch(API_BASE + '/users', {method:'POST', headers:{'Content-Type':'application/json'}, + body:JSON.stringify({name:u})}).catch(function(){}); + } + }); + if (changed) { + localStorage.setItem('empfang_known_users', JSON.stringify(knownUsers)); + renderSidebarUsers(); + renderUserMgmt(); + } + }).catch(function(){}); +} + /* =================================================================== SIDEBAR =================================================================== */ @@ -1141,8 +1167,9 @@ function copyText(el) { =================================================================== */ loadMessages(); setInterval(loadMessages, 10000); +setInterval(syncUsersFromServer, 30000); document.addEventListener('visibilitychange', function() { - if (!document.hidden) { lastDataHash = ''; loadMessages(); } + if (!document.hidden) { lastDataHash = ''; loadMessages(); syncUsersFromServer(); } }); var bc = null;