Empfang V4: Auth, Praxis-Tenant, Cockpit, Caddy-Rewrite

Made-with: Cursor
This commit is contained in:
2026-04-19 22:22:11 +02:00
parent 22397f1d28
commit c53bba4587
9 changed files with 885 additions and 94 deletions

View File

@@ -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 = []

View File

@@ -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)

View File

@@ -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():

View File

@@ -37,12 +37,14 @@
}
# Empfang-Subdomain: empfang.aza-medwork.ch
{$AZA_EMPFANG_DOMAIN:empfang.aza-medwork.ch} {
# Root "/" wird transparent auf /empfang/ umgeschrieben (kein sichtbarer Redirect)
{$AZA_EMPFANG_DOMAIN:empfang.aza-medwork.ch} {
encode gzip zstd
handle / {
rewrite * /empfang/
}
reverse_proxy {$BACKEND_UPSTREAM:backend:8000}
}
handle {
reverse_proxy {$BACKEND_UPSTREAM:backend:8000}

View File

@@ -37,12 +37,14 @@
}
# Empfang-Subdomain: empfang.aza-medwork.ch
{$AZA_EMPFANG_DOMAIN:empfang.aza-medwork.ch} {
# Root "/" wird transparent auf /empfang/ umgeschrieben (kein sichtbarer Redirect)
{$AZA_EMPFANG_DOMAIN:empfang.aza-medwork.ch} {
encode gzip zstd
handle / {
rewrite * /empfang/
}
reverse_proxy {$BACKEND_UPSTREAM:backend:8000}
}
handle {
reverse_proxy {$BACKEND_UPSTREAM:backend:8000}

View File

@@ -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"

View File

@@ -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.
---

View File

@@ -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",

View File

@@ -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;