Empfang V4: Auth, Praxis-Tenant, Cockpit, Caddy-Rewrite
Made-with: Cursor
This commit is contained in:
@@ -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 = []
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user