Files
aza/AzA march 2026/backup_external_chat_fix_2026-05-14_234913/empfang_routes.py
2026-05-16 20:33:36 +02:00

9347 lines
339 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""
AZA Empfang - Backend-Routen V5: Admin, Devices, Federation, Channels.
Serverseitige Auth, Benutzer, Sessions, Nachrichten, Aufgaben,
Geraeteverwaltung, Kanaele, Praxis-Federation.
Alle Daten practice-scoped. Backend ist die einzige Wahrheit.
"""
import base64
import hashlib
import hmac
import json
import logging
import os
import re
from collections import defaultdict
import secrets
import tempfile
import time
import unicodedata
import uuid
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional, Tuple
import asyncio
from fastapi import (
APIRouter,
Cookie,
File,
Form,
HTTPException,
Query,
Request,
Response,
UploadFile,
WebSocket,
WebSocketDisconnect,
)
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, RedirectResponse
from pydantic import BaseModel, Field
router = APIRouter()
_log = logging.getLogger(__name__)
_DATA_DIR = Path(__file__).resolve().parent / "data"
_EMPFANG_FILE = _DATA_DIR / "empfang_nachrichten.json"
_EXTERNAL_DM_MESSAGES_FILE = _DATA_DIR / "empfang_external_messages.json"
_EXTERNAL_DM_TEXT_MAX = 16000
_EXTERNAL_DM_READS_FILE = _DATA_DIR / "empfang_external_dm_reads.json"
_ATTACHMENT_MAX_BYTES = 5 * 1024 * 1024
_ATTACHMENT_MAX_PER_MESSAGE = 3
_ATTACHMENT_ALLOWED_MIME = frozenset({"image/png", "image/jpeg", "image/webp"})
_ATTACHMENTS_DIR = _DATA_DIR / "empfang_attachments"
_ATTACHMENTS_META_FILE = _DATA_DIR / "empfang_attachments_meta.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"
_DEVICES_FILE = _DATA_DIR / "empfang_devices.json"
_CHANNELS_FILE = _DATA_DIR / "empfang_channels.json"
_CONNECTIONS_FILE = _DATA_DIR / "empfang_connections.json"
_LEGACY_DEFAULT_PID = "default"
SESSION_MAX_AGE = 30 * 24 * 3600 # 30 Tage
RESET_LINK_TTL_SEC = min(
max(3600, int(os.environ.get("EMPFANG_RESET_TTL_SECONDS", str(86400)))),
7 * 86400,
)
def _generate_practice_id() -> str:
return f"prac_{uuid.uuid4().hex[:12]}"
def _resolve_practice_id(request: Request) -> str:
"""Ermittelt die practice_id aus Session, Header oder Query.
Kein stiller Fallback auf eine Default-Praxis."""
s = _session_from_request(request)
if s:
return s["practice_id"]
pid = request.headers.get("X-Practice-Id", "").strip()
if pid:
return pid
pid = request.query_params.get("practice_id", "").strip()
if pid:
return pid
return ""
def _require_practice_id(request: Request) -> str:
"""Wie _resolve_practice_id, aber wirft 400 wenn keine practice_id."""
pid = _resolve_practice_id(request)
if not pid:
raise HTTPException(
status_code=400,
detail="practice_id erforderlich (X-Practice-Id Header, Session oder Query)")
return pid
def _ensure_data_dir():
_DATA_DIR.mkdir(parents=True, exist_ok=True)
# =====================================================================
# 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(path, "r", encoding="utf-8") as f:
return json.load(f)
except Exception:
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 _invite_code_key(raw: str) -> str:
"""Vergleicht Einladungscodes unabhaengig von Leerzeichen und Gedankenstrich-Varianten."""
s = (raw or "").strip().upper().replace(" ", "")
for ch in ("\u2011", "\u2013", "\u2014", "\u2212"):
s = s.replace(ch, "-")
return s
def _generate_chat_invite_code() -> str:
"""Lesbarer Chat-Einladungscode im Format CHAT-XXXX-XXXX."""
import random
chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
part1 = "".join(random.choices(chars, k=4))
part2 = "".join(random.choices(chars, k=4))
return f"CHAT-{part1}-{part2}"
def _ensure_practice(practice_id: str, name: str = "Meine Praxis",
admin_email: str = "") -> dict:
"""Stellt sicher, dass eine Praxis mit dieser ID existiert."""
practices = _load_practices()
if practice_id not in practices:
practices[practice_id] = {
"practice_id": practice_id,
"name": name,
"specialty": "",
"invite_code": _generate_chat_invite_code(),
"admin_email": admin_email,
"created": time.strftime("%Y-%m-%d %H:%M:%S"),
}
_save_practices(practices)
return practices[practice_id]
def _migrate_legacy_to_practice(new_pid: str):
"""Migriert alle Daten von der alten 'default'-Praxis zur neuen practice_id.
Wird einmalig beim ersten Provisioning aufgerufen."""
accounts = _load_accounts()
migrated = False
for a in accounts.values():
if a.get("practice_id") == _LEGACY_DEFAULT_PID:
a["practice_id"] = new_pid
migrated = True
if migrated:
_save_accounts(accounts)
devices = _load_devices()
for d in devices.values():
if d.get("practice_id") == _LEGACY_DEFAULT_PID:
d["practice_id"] = new_pid
_save_devices(devices)
sessions = _load_sessions()
for s in sessions.values():
if s.get("practice_id") == _LEGACY_DEFAULT_PID:
s["practice_id"] = new_pid
_save_sessions(sessions)
messages = _load_messages()
for m in messages:
if m.get("practice_id", _LEGACY_DEFAULT_PID) == _LEGACY_DEFAULT_PID:
m["practice_id"] = new_pid
_save_messages(messages)
practices = _load_practices()
if _LEGACY_DEFAULT_PID in practices:
old = practices.pop(_LEGACY_DEFAULT_PID)
if new_pid not in practices:
old["practice_id"] = new_pid
practices[new_pid] = old
_save_practices(practices)
try:
channels = _load_channels()
for c in channels:
if c.get("practice_id") == _LEGACY_DEFAULT_PID:
c["practice_id"] = new_pid
_save_channels(channels)
except Exception:
pass
_migrate_old_users(new_pid)
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())
ln_mu = _preferred_unique_login_for_display(
accounts, practice_id, name, "",
)
accounts[uid] = {
"user_id": uid,
"practice_id": practice_id,
"display_name": name,
"login_name": ln_mu,
"role": "mpa",
"pw_hash": pw_hash,
"pw_salt": pw_salt,
"created": time.strftime("%Y-%m-%d %H:%M:%S"),
"status": "active",
"last_login": "",
"email": "",
}
_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"],
"login_name": (a.get("login_name") or "").strip(),
"email": (a.get("email") or "").strip(),
"has_password_hash": bool((a.get("pw_hash") or "").strip()),
"specialty": (a.get("specialty") or "").strip(),
"title": (a.get("title") or "").strip(),
}
for a in accounts.values()
if a.get("practice_id") == practice_id
]
# =====================================================================
# Login / Passwort-Reset Hilfen (Mandant, Mehrfach-E-Mail)
# =====================================================================
def _is_likely_email(s: str) -> bool:
"""Grobe Erkennung E-Mail vs. Benutzername (Anzeigename)."""
s = (s or "").strip()
if "@" not in s or len(s) < 5:
return False
parts = s.split("@", 1)
if len(parts) != 2 or not parts[0] or not parts[1]:
return False
return "." in parts[1]
def _norm_email(e: str) -> str:
return (e or "").strip().lower()
def _practice_id_from_client(request: Request, body: dict) -> str:
"""practice_id fuer Login/Forgot ohne Session: Body, Header, Query."""
pid = (body.get("practice_id") or "").strip()
if pid:
return pid
pid = request.headers.get("X-Practice-Id", "").strip()
if pid:
return pid
return request.query_params.get("practice_id", "").strip()
def _audit_invite_tail(raw: str) -> str:
"""Letzte Zeichen des Codes fuer Logs (kein vollstaendiger Invite/CHAT-Code)."""
s = (raw or "").strip().upper().replace(" ", "")
if len(s) < 5:
return "----"
return s[-4:]
def _lookup_practice_id_by_invite(invite_raw: str) -> str:
"""Liefert practice_id fuer einen Einladungscode (normalisierter Vergleich) oder ''."""
if not (invite_raw or "").strip():
return ""
practices = _load_practices()
want = _invite_code_key(invite_raw)
for pida, pdata in practices.items():
if _invite_code_key(pdata.get("invite_code")) == want:
return pida
return ""
def _practice_id_for_login(request: Request, body: dict) -> tuple[str, str]:
"""Login: practice_id ohne Session-Cookie (sonst ueberholt alte Session die Einladung).
Reihenfolge: invite_code > Body > Header > Query.
Rueckgabe: (practice_id, quelle) mit quelle in
'invite'|'body'|'header'|'query'|''|'invite_invalid'
"""
invite_raw = (body.get("invite_code") or "").strip()
if invite_raw:
got = _lookup_practice_id_by_invite(invite_raw)
if got:
return got, "invite"
return "", "invite_invalid"
pid = (body.get("practice_id") or "").strip()
if pid:
return pid, "body"
pid = request.headers.get("X-Practice-Id", "").strip()
if pid:
return pid, "header"
pid = request.query_params.get("practice_id", "").strip()
if pid:
return pid, "query"
return "", ""
def _practice_label(practices: dict, pid: str) -> str:
p = practices.get(pid) or {}
return (p.get("name") or "").strip() or pid
def _invite_join_clone_account(
accounts: dict,
source: dict,
target_pid: str,
now: str,
) -> dict:
"""Second account in target practice with same credentials (cross-practice chat join).
Verhindert stillen Login in der alten Praxis, wenn ein gueltiger Einladungscode
die Ziel-practice_id festlegt. Keine Praxis-Migration: Quellkonto bleibt unveraendert.
"""
display = (source.get("display_name") or "").strip()
if not display:
raise HTTPException(
status_code=400,
detail="Ungueltiges Quellkonto.",
)
if any(
_normalize_login_username(a.get("display_name") or "")
== _normalize_login_username(display)
and a.get("practice_id") == target_pid
for a in accounts.values()
):
raise HTTPException(
status_code=409,
detail=(
"In dieser Praxis existiert bereits ein Benutzer mit diesem Namen. "
"Bitte melden Sie sich mit diesem Konto an."
),
)
uid = uuid.uuid4().hex[:12]
pref_ln = (source.get("login_name") or "").strip() or display
ln_assign = _preferred_unique_login_for_display(accounts, target_pid, pref_ln, "")
role_s = (source.get("role") or "mpa").strip()
if role_s == "admin":
role_s = "mpa"
elif role_s not in ("arzt", "mpa", "empfang"):
role_s = "mpa"
accounts[uid] = {
"user_id": uid,
"practice_id": target_pid,
"display_name": display,
"email": (source.get("email") or "").strip(),
"login_name": ln_assign,
"role": role_s,
"pw_hash": source["pw_hash"],
"pw_salt": source["pw_salt"],
"created": now,
"status": "active",
"last_login": now,
}
if source.get("must_change_password"):
accounts[uid]["must_change_password"] = True
return accounts[uid]
def _mask_email_for_response(addr: str) -> str:
"""Kurze Maskierung fuer API-Antworten (kein Klartext der vollstaendigen Adresse)."""
s = (addr or "").strip()
if "@" not in s:
return ""
local, _, domain = s.partition("@")
dom = domain.strip()
loc = local.strip()
if not loc or not dom:
return ""
head = loc[0] if loc else "?"
return f"{head}***@{dom}"
def _forgot_password_neutral_payload() -> dict:
"""Gleiche Nutzer-Meldung wie zuvor, aber ohne falsche Zustellungs-Zusicherung."""
return {
"success": True,
"step": "none",
"message": (
"Wenn ein passendes Konto existiert, wurde ein Link an die hinterlegte "
"E-Mail-Adresse gesendet."
),
"reset_token_created": False,
"target_email_masked": "",
"mail_delivered": False,
"attempted_delivery": False,
}
def _send_reset_for_account(acc: dict) -> dict:
"""Token erstellen, Mail senden — ehrliche Statusfelder ohne Token-Leak."""
pid_acc = (acc.get("practice_id") or "").strip()
practices = _load_practices()
email_to = (acc.get("email") or "").strip()
if not email_to and pid_acc:
email_to = ((practices.get(pid_acc) or {}).get("admin_email") or "").strip()
if not email_to and pid_acc:
try:
from stripe_routes import lookup_license_email_for_practice
le = (lookup_license_email_for_practice(pid_acc) or "").strip()
if le:
email_to = le
except Exception:
pass
if not email_to:
return {
"success": False,
"step": "no_email",
"message": (
"Fuer dieses Konto ist keine direkte E-Mail hinterlegt und keine "
"gueltige Praxis-/Lizenz-E-Mail wurde gefunden. Bitte einen Administrator "
"in der Hauptinstallation bitten oder Passwort dort setzen."
),
"reset_token_created": False,
"target_email_masked": "",
"mail_delivered": False,
"attempted_delivery": False,
}
masked = _mask_email_for_response(email_to)
reset_token = secrets.token_urlsafe(32)
resets = _load_json(_DATA_DIR / "empfang_resets.json", {})
resets[reset_token] = {
"user_id": acc["user_id"],
"email": _norm_email(email_to),
"display_name": (acc.get("display_name") or "").strip(),
"practice_id": pid_acc,
"created": time.time(),
"delivery_email": email_to.strip(),
}
for k in list(resets.keys()):
if time.time() - resets[k].get("created", 0) > RESET_LINK_TTL_SEC:
del resets[k]
_save_json(_DATA_DIR / "empfang_resets.json", resets)
_web_base = os.environ.get(
"EMPFANG_WEB_BASE", "https://empfang.aza-medwork.ch/empfang"
).rstrip("/")
reset_link = f"{_web_base}/?reset_token={reset_token}"
ok = _send_reset_email(email_to, acc.get("display_name", ""), reset_link)
if not ok:
if reset_token in resets:
del resets[reset_token]
_save_json(_DATA_DIR / "empfang_resets.json", resets)
return {
"success": False,
"step": "mail_failed",
"message": (
"Der Reset-Link konnte nicht per E-Mail zugestellt werden. "
"Bitte konfigurieren Sie SMTP oder RESEND_API_KEY auf dem Server "
"oder wenden Sie sich an Ihren Administrator."
),
"reset_token_created": False,
"target_email_masked": masked,
"mail_delivered": False,
"attempted_delivery": True,
}
return {
"success": True,
"step": "sent",
"message": (
"Ein Link zum Zurücksetzen wurde an die hinterlegte E-Mail-Adresse gesendet."
),
"reset_token_created": True,
"target_email_masked": masked,
"mail_delivered": True,
"attempted_delivery": True,
}
# =====================================================================
# Sessions (mit device_id)
# =====================================================================
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, device_id: str = None,
user_agent: str = "", ip_addr: 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,
"device_id": device_id or "",
"created": time.time(),
"last_active": time.time(),
}
_save_sessions(sessions)
if device_id:
_register_or_update_device(
device_id=device_id,
user_id=user_id,
practice_id=practice_id,
user_agent=user_agent,
ip_addr=ip_addr,
)
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
# =====================================================================
# Devices (Geraeteverwaltung)
# =====================================================================
def _load_devices() -> dict:
return _load_json(_DEVICES_FILE, {})
def _save_devices(data: dict):
_save_json(_DEVICES_FILE, data)
def _parse_device_info(user_agent: str) -> dict:
"""Einfache Heuristik zum Erkennen von Plattform, Geraetetyp und Name."""
ua = user_agent.lower()
if "iphone" in ua:
platform, device_type = "iOS", "mobile"
elif "ipad" in ua:
platform, device_type = "iOS", "tablet"
elif "android" in ua:
if "mobile" in ua:
platform, device_type = "Android", "mobile"
else:
platform, device_type = "Android", "tablet"
elif "macintosh" in ua or "mac os" in ua:
platform, device_type = "macOS", "browser"
elif "windows" in ua:
platform, device_type = "Windows", "browser"
elif "linux" in ua:
platform, device_type = "Linux", "browser"
else:
platform, device_type = "Unbekannt", "browser"
if "electron" in ua or "cursor" in ua:
device_type = "desktop"
browser = "Browser"
if "edg/" in ua:
browser = "Edge"
elif "chrome" in ua and "chromium" not in ua:
browser = "Chrome"
elif "firefox" in ua:
browser = "Firefox"
elif "safari" in ua and "chrome" not in ua:
browser = "Safari"
device_name = f"{browser} auf {platform}"
if device_type == "desktop":
device_name = f"Desktop-App auf {platform}"
elif device_type in ("mobile", "tablet"):
device_name = f"{platform} {device_type.capitalize()}"
return {
"device_name": device_name,
"platform": platform,
"device_type": device_type,
}
def _make_device_id(user_id: str, user_agent: str) -> str:
raw = f"{user_id}:{user_agent}"
return hashlib.sha256(raw.encode()).hexdigest()[:12]
def _record_practice_new_device_notice(
practice_id: str, user_id: str, device_id: str, ip_addr: str
) -> None:
pid = (practice_id or "").strip()
if not pid or not device_id:
return
practices = _load_practices()
p = practices.get(pid)
if not isinstance(p, dict):
return
alerts = list(p.get("pdevice_alerts") or [])
alerts.append(
{
"ts": time.time(),
"user_id": (user_id or "").strip(),
"device_suffix": str(device_id)[-8:],
"ip": (ip_addr or "").strip(),
}
)
p["pdevice_alerts"] = alerts[-80:]
practices[pid] = p
_save_practices(practices)
def _register_or_update_device(device_id: str, user_id: str,
practice_id: str, user_agent: str,
ip_addr: str):
devices = _load_devices()
now = time.strftime("%Y-%m-%d %H:%M:%S")
info = _parse_device_info(user_agent)
if device_id in devices:
dev = devices[device_id]
dev["last_active"] = now
dev["ip_last"] = ip_addr
dev["user_agent"] = user_agent
dev["device_name"] = info["device_name"]
dev["platform"] = info["platform"]
dev["device_type"] = info["device_type"]
else:
devices[device_id] = {
"device_id": device_id,
"user_id": user_id,
"practice_id": practice_id,
"device_name": info["device_name"],
"platform": info["platform"],
"device_type": info["device_type"],
"user_agent": user_agent,
"first_seen": now,
"last_active": now,
"trust_status": "trusted",
"ip_last": ip_addr,
}
try:
_record_practice_new_device_notice(practice_id, user_id, device_id, ip_addr)
except Exception:
pass
_save_devices(devices)
def _extract_client_ip(request: Request) -> str:
forwarded = request.headers.get("X-Forwarded-For", "")
if forwarded:
return forwarded.split(",")[0].strip()
if request.client:
return request.client.host
return ""
# =====================================================================
# Messages (practice-scoped)
# =====================================================================
EMPFANG_MESSAGE_RETENTION_DAYS = 14
def _utc_now_iso_z() -> str:
"""UTC-Zeitstempel fuer Chat-Nachrichten, ISO-8601 mit Z (eindeutig UTC)."""
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
def _parse_msg_instant_utc_ts(raw: str) -> float:
"""Parse eines gespeicherten Chat-Zeitstempels zu POSIX-Sekunden (UTC).
Naive Legacy-Strings ('YYYY-MM-DD HH:MM:SS' / '...T...' ohne TZ) werden
wie in Produktion ueblich als UTC interpretiert (Server/VPS typisch)."""
s = (raw or "").strip()
if not s:
return 0.0
s_norm = s.replace(" ", "T", 1)
try:
if s_norm.endswith("Z"):
dt = datetime.fromisoformat(s_norm.replace("Z", "+00:00"))
return dt.timestamp()
dt = datetime.fromisoformat(s_norm)
if dt.tzinfo is not None:
return dt.timestamp()
return dt.replace(tzinfo=timezone.utc).timestamp()
except Exception:
return 0.0
def _msg_timestamp_for_retention(m: dict) -> str:
return (m.get("empfangen") or m.get("zeitstempel") or "").strip()
def _msg_chrono_sort_key(m: dict) -> tuple[float, str]:
raw = _msg_timestamp_for_retention(m)
return (_parse_msg_instant_utc_ts(raw), str(m.get("id") or ""))
def _message_within_retention(m: dict, cutoff_ts: float) -> bool:
"""Behalten wenn Zeitstempel unbekannt oder Augenblick >= cutoff (UTC-Sekunden)."""
t = _msg_timestamp_for_retention(m)
if not t:
return True
ts = _parse_msg_instant_utc_ts(t)
if ts <= 0:
return True
return ts >= cutoff_ts
def _prune_messages_by_retention(messages: list[dict]) -> tuple[list[dict], int]:
cutoff_ts = time.time() - EMPFANG_MESSAGE_RETENTION_DAYS * 86400
kept = [m for m in messages if _message_within_retention(m, cutoff_ts)]
return kept, len(messages) - len(kept)
def _load_messages() -> list[dict]:
messages = _load_json(_EMPFANG_FILE, [])
kept, removed = _prune_messages_by_retention(messages)
if removed > 0:
_save_messages(kept)
return kept
def _save_messages(messages: list[dict]):
_save_json(_EMPFANG_FILE, messages)
def _load_external_dm_messages() -> list[dict]:
"""Cross-Praxis-Direktchat (separate Datei, kein Mix mit internen DMs)."""
data = _load_json(_EXTERNAL_DM_MESSAGES_FILE, [])
if not isinstance(data, list):
return []
kept, removed = _prune_messages_by_retention(data)
if removed > 0:
_save_external_dm_messages(kept)
return kept
def _save_external_dm_messages(messages: list[dict]):
_save_json(_EXTERNAL_DM_MESSAGES_FILE, messages)
def _external_dm_conversation_id(pid_a: str, uid_a: str, pid_b: str, uid_b: str) -> str:
"""Stabile, symmetrische Konversations-ID; Kollision mit internem direct_conv_key ausgeschlossen."""
t1 = f"{(pid_a or '').strip()}\t{(uid_a or '').strip()}"
t2 = f"{(pid_b or '').strip()}\t{(uid_b or '').strip()}"
lo, hi = (t1, t2) if t1 <= t2 else (t2, t1)
raw = f"external_dm|{lo}|{hi}"
return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:32]
def _account_record_for_practice(uid: str, pid: str) -> Optional[dict]:
if not uid or not pid:
return None
accounts = _load_accounts()
acc = accounts.get(uid)
if not isinstance(acc, dict):
return None
if (acc.get("practice_id") or "").strip() != pid:
return None
return acc
def _account_is_sendable(acc: Optional[dict]) -> bool:
if not acc:
return False
st = str(acc.get("status") or "active").strip().lower()
return st not in ("deactivated", "deleted", "inactive")
def _external_dm_authorize_send_direction(
sender_pid: str,
sender_uid: str,
recipient_pid: str,
recipient_uid: str,
) -> tuple[str, dict]:
"""Richtungsbezogen: darf sender an recipient schreiben? Liefert (link_id, link)."""
sender_pid = (sender_pid or "").strip()
sender_uid = (sender_uid or "").strip()
recipient_pid = (recipient_pid or "").strip()
recipient_uid = (recipient_uid or "").strip()
if not sender_pid or not sender_uid or not recipient_pid or not recipient_uid:
raise HTTPException(status_code=400, detail="Adressaten unvollstaendig")
if sender_pid == recipient_pid:
raise HTTPException(
status_code=400,
detail="Innerhalb einer Praxis bitte den bestehenden Direktchat nutzen.",
)
s_acc = _account_record_for_practice(sender_uid, sender_pid)
r_acc = _account_record_for_practice(recipient_uid, recipient_pid)
if not _account_is_sendable(s_acc):
raise HTTPException(status_code=403, detail="Absenderkonto nicht zulaessig")
if not _account_is_sendable(r_acc):
raise HTTPException(status_code=403, detail="Empfaenger nicht erreichbar")
store = _load_practice_links_store()
practice_link: Optional[dict] = None
person_link: Optional[dict] = None
for link in store.get("links") or []:
if not isinstance(link, dict):
continue
st = str(link.get("status") or "").strip().lower()
if st != "accepted":
continue
a = (link.get("source_practice_id") or "").strip()
b = (link.get("target_practice_id") or "").strip()
if {a, b} != {sender_pid, recipient_pid}:
continue
ct = _link_contact_type(link)
if ct == _CONTACT_TYPE_PRACTICE:
practice_link = link
elif ct == _CONTACT_TYPE_PERSON:
person_link = link
if practice_link is not None:
lid = str(practice_link.get("id") or "").strip()
if not lid:
raise HTTPException(status_code=500, detail="Verbindungsdaten ungueltig")
return lid, practice_link
if person_link is None:
raise HTTPException(status_code=403, detail="Keine freigegebene Verbindung")
sp = (person_link.get("source_practice_id") or "").strip()
tp = (person_link.get("target_practice_id") or "").strip()
su = (person_link.get("source_user_id") or "").strip()
tu = _effective_person_target_user_id(person_link)
if not su or not tu:
raise HTTPException(status_code=403, detail="Keine freigegebene Verbindung")
# Strikt 1:1 — nur su darf mit tu schreiben und umgekehrt.
if sender_pid == sp and sender_uid == su and recipient_pid == tp and recipient_uid == tu:
lid = str(person_link.get("id") or "").strip()
if lid:
return lid, person_link
if sender_pid == tp and sender_uid == tu and recipient_pid == sp and recipient_uid == su:
lid = str(person_link.get("id") or "").strip()
if lid:
return lid, person_link
raise HTTPException(status_code=403, detail="Keine freigegebene Verbindung")
def _external_dm_authorize_pair(
a_pid: str,
a_uid: str,
b_pid: str,
b_uid: str,
) -> tuple[str, dict]:
"""Thread-Zugriff: darf die Konversation zwischen a und b existieren (in eine Richtung)."""
try:
return _external_dm_authorize_send_direction(a_pid, a_uid, b_pid, b_uid)
except HTTPException:
pass
return _external_dm_authorize_send_direction(b_pid, b_uid, a_pid, a_uid)
def _external_dm_to_client_message(m: dict) -> dict:
"""Gleiches Kerndatenformat wie interne DMs fuer die Web-UI."""
ex0 = m.get("extras") if isinstance(m.get("extras"), dict) else {}
ex = dict(ex0)
mid = str(m.get("id") or "").strip()
s_pid = str(m.get("sender_practice_id") or "").strip()
s_uid = str(m.get("sender_user_id") or "").strip()
s_dn = str(m.get("sender_display_name") or "").strip()
r_pid = str(m.get("recipient_practice_id") or "").strip()
r_uid = str(m.get("recipient_user_id") or "").strip()
r_dn = str(m.get("recipient_display_name") or "").strip()
ex.setdefault("sender_user_id", s_uid)
ex.setdefault("recipient_user_id", r_uid)
ex.setdefault("sender_practice_id", s_pid)
ex.setdefault("recipient_practice_id", r_pid)
ex.setdefault("conversation_id", str(m.get("conversation_id") or ""))
ex.setdefault("external_link_id", str(m.get("external_link_id") or ""))
ex.setdefault("external_dm", True)
ex.setdefault("conv_type", "external_dm")
ts = str(m.get("empfangen") or m.get("zeitstempel") or "").strip()
return {
"id": mid,
"thread_id": mid,
"kommentar": str(m.get("kommentar") or ""),
"absender": (s_dn + " (Empfang)") if s_dn else " (Empfang)",
"zeitstempel": ts,
"empfangen": ts,
"status": str(m.get("status") or "offen"),
"extras": ex,
}
def _msg_practice(m: dict) -> str:
return m.get("practice_id") or _LEGACY_DEFAULT_PID
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)
# =====================================================================
# Channels (Kanaele, practice-scoped)
# =====================================================================
def _load_channels() -> list[dict]:
return _load_json(_CHANNELS_FILE, [])
def _save_channels(channels: list[dict]):
_save_json(_CHANNELS_FILE, channels)
_DEFAULT_CHANNEL_DEFS = [
{"name": "Allgemein", "scope": "internal", "channel_type": "group", "allowed_roles": []},
{"name": "Aerzte", "scope": "internal", "channel_type": "group", "allowed_roles": ["arzt", "admin"]},
{"name": "MPA", "scope": "internal", "channel_type": "group", "allowed_roles": ["mpa", "admin"]},
{"name": "Empfang", "scope": "internal", "channel_type": "group", "allowed_roles": ["empfang", "admin"]},
]
def _ensure_default_channels(practice_id: str):
channels = _load_channels()
practice_channels = [c for c in channels if c.get("practice_id") == practice_id]
if practice_channels:
return
now = time.strftime("%Y-%m-%d %H:%M:%S")
for defn in _DEFAULT_CHANNEL_DEFS:
channels.append({
"channel_id": uuid.uuid4().hex[:12],
"practice_id": practice_id,
"name": defn["name"],
"scope": defn["scope"],
"channel_type": defn["channel_type"],
"allowed_roles": defn["allowed_roles"],
"connection_id": "",
"created": now,
"created_by": "",
})
_save_channels(channels)
# =====================================================================
# Connections / Federation (Praxis-zu-Praxis)
# =====================================================================
def _load_connections() -> list[dict]:
return _load_json(_CONNECTIONS_FILE, [])
def _save_connections(conns: list[dict]):
_save_json(_CONNECTIONS_FILE, conns)
# =====================================================================
# AUTH ENDPOINTS
# =====================================================================
@router.post("/auth/setup")
async def auth_setup(request: Request):
"""Erstellt den ersten Admin-Benutzer und eine neue Praxis."""
try:
body = await request.json()
except Exception:
body = {}
name = (body.get("name") or "").strip()
password = (body.get("password") or "").strip()
practice_name = (body.get("practice_name") or "").strip() or "Meine Praxis"
admin_email = (body.get("email") or "").strip()
pid = (body.get("practice_id") 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")
if not pid:
pid = _generate_practice_id()
practice = _ensure_practice(pid, name=practice_name, admin_email=admin_email)
accounts = _load_accounts()
practice_accounts = [a for a in accounts.values()
if a.get("practice_id") == pid]
if practice_accounts:
raise HTTPException(status_code=409,
detail="Setup bereits abgeschlossen. Bitte Login verwenden.")
if practice_name:
practices = _load_practices()
practices[pid]["name"] = practice_name
if admin_email:
practices[pid]["admin_email"] = admin_email
_save_practices(practices)
practice = practices[pid]
uid = uuid.uuid4().hex[:12]
pw_hash, pw_salt = _hash_password(password)
now = time.strftime("%Y-%m-%d %H:%M:%S")
ln_u = _allocate_unique_login_name(accounts, pid, name)
accounts[uid] = {
"user_id": uid,
"practice_id": pid,
"display_name": name,
"email": admin_email,
"login_name": ln_u,
"role": "admin",
"pw_hash": pw_hash,
"pw_salt": pw_salt,
"created": now,
"status": "active",
"last_login": now,
}
_save_accounts(accounts)
_ensure_default_channels(pid)
ua = request.headers.get("User-Agent", "")
ip = _extract_client_ip(request)
dev_id = _make_device_id(uid, ua)
token = _create_session(uid, pid, name, "admin",
device_id=dev_id, user_agent=ua, ip_addr=ip)
resp = JSONResponse(content={
"success": True, "user_id": uid, "role": "admin",
"display_name": name, "practice_id": pid,
"practice_name": practice.get("name", ""),
"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 Benutzername (Anzeigename) oder E-Mail + Passwort, mandantenbewusst."""
try:
body = await request.json()
except Exception:
body = {}
raw = (body.get("name") or "").strip()
password = (body.get("password") or "").strip()
pid, pid_src = _practice_id_for_login(request, body)
if pid_src == "invite_invalid":
raise HTTPException(
status_code=403,
detail="Ungueltiger Einladungscode — bitte aktuellen Link aus der Hauptinstallation verwenden.",
)
if not raw or not password:
raise HTTPException(
status_code=400,
detail="Login-Name oder E-Mail und Passwort erforderlich",
)
accounts = _load_accounts()
scoped = (
[a for a in accounts.values() if a.get("practice_id") == pid]
if pid
else list(accounts.values())
)
target = None
login_recovered_practice = False
if _is_likely_email(raw):
em = _norm_email(raw)
matches = [
a
for a in scoped
if em and _norm_email(a.get("email") or "") == em
]
if len(matches) == 1:
target = matches[0]
elif len(matches) == 0:
target = None
else:
if pid:
raise HTTPException(
status_code=409,
detail={
"code": "ambiguous_email",
"message": (
"Diese E-Mail ist mehreren Benutzern zugeordnet. "
"Bitte melden Sie sich mit Ihrem Benutzernamen an."
),
"candidates": [
{
"display_name": (a.get("display_name") or ""),
"login_name": (a.get("login_name") or "").strip(),
}
for a in matches
],
},
)
raise HTTPException(
status_code=409,
detail={
"code": "ambiguous_email",
"message": (
"Diese E-Mail ist mehreren Benutzern zugeordnet. "
"Bitte melden Sie sich mit Ihrem Benutzernamen an."
),
},
)
else:
matches, _via_login = _resolve_browser_login_matches(scoped, raw)
if len(matches) == 1:
target = matches[0]
elif len(matches) == 0:
target = None
else:
raise HTTPException(
status_code=409,
detail={
"code": "ambiguous_username",
"message": (
"Diese Anmeldedaten sind in dieser Praxis nicht eindeutig. "
"Ein Administrator muss im Hauptfenster fuer die betroffenen Konten einen "
"eindeutigen Login-Namen festlegen. Bitte verwenden Sie danach diesen "
"Benutzernamen fuer die Anmeldung."
),
},
)
if not target and pid and not _is_likely_email(raw):
gmatches, _via_global = _resolve_browser_login_matches(
list(accounts.values()), raw,
)
if len(gmatches) == 1:
target = gmatches[0]
if pid_src != "invite":
login_recovered_practice = True
if not target:
raise HTTPException(
status_code=401,
detail="Benutzer nicht gefunden oder falsches Passwort",
)
tpid = (target.get("practice_id") or "").strip()
invite_will_clone = bool(pid_src == "invite" and pid and tpid and tpid != pid)
if pid and tpid != pid and not login_recovered_practice and not invite_will_clone:
raise HTTPException(
status_code=403,
detail=(
"Anmeldung passt nicht zur gewaehlten Praxis (Einladungscode / gespeicherte Praxis-ID). "
"Bitte den Einladungslink der Hauptinstallation erneut oeffnen."
),
)
if not pid or login_recovered_practice:
if not invite_will_clone:
pid = tpid
if target.get("status") == "deactivated":
raise HTTPException(
status_code=403,
detail="Konto deaktiviert. Bitte Administrator kontaktieren.",
)
if not _verify_password(password, target["pw_hash"], target["pw_salt"]):
raise HTTPException(
status_code=401,
detail="Benutzer nicht gefunden oder falsches Passwort",
)
did_invite_clone = False
now = time.strftime("%Y-%m-%d %H:%M:%S")
old_practice_before = ""
source_user_before = ""
if invite_will_clone:
old_practice_before = (target.get("practice_id") or "").strip()
source_user_before = (target.get("user_id") or "").strip()
if invite_will_clone:
_ensure_practice(pid)
_ensure_default_channels(pid)
target = _invite_join_clone_account(accounts, target, pid, now)
did_invite_clone = True
_log.info(
"AZA_EMPFANG_PRACTICE_JOIN_CLONE src_user=%s old_practice=%s new_practice=%s "
"clone_user=%s role=%s admin_assigned=%s invite_tail=%s",
(source_user_before or "")[:12],
(old_practice_before or "")[:16],
(pid or "")[:16],
(target.get("user_id") or "")[:12],
(target.get("role") or ""),
str(_account_has_practice_admin_privileges(target)).lower(),
_audit_invite_tail(body.get("invite_code")),
)
else:
target["last_login"] = now
_save_accounts(accounts)
dn = (target.get("display_name") or raw).strip()
ua = request.headers.get("User-Agent", "")
ip = _extract_client_ip(request)
dev_id = body.get("device_id") or _make_device_id(target["user_id"], ua)
token = _create_session(
target["user_id"], pid, dn, target["role"],
device_id=dev_id, user_agent=ua, ip_addr=ip,
)
bind_src = (
"invite_code_join"
if did_invite_clone
else (
"invite_code"
if pid_src == "invite"
else (
"username_recovered_practice"
if login_recovered_practice
else (
"stored_practice_id"
if pid_src in ("body", "header", "query")
else "account"
)
)
)
)
result = {
"success": True,
"user_id": target["user_id"],
"role": target["role"],
"display_name": dn,
"practice_id": pid,
"practice_bind_source": bind_src,
}
if target.get("must_change_password"):
result["must_change_password"] = True
resp = JSONResponse(content=result)
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()
email = (body.get("email") or "").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 ("arzt", "mpa", "empfang"):
role = "mpa"
target_pid = _lookup_practice_id_by_invite(invite_code)
if not target_pid:
raise HTTPException(status_code=403, detail="Ungueltiger Einladungscode")
accounts = _load_accounts()
exists = any(
_normalize_login_username(a.get("display_name") or "") == _normalize_login_username(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)
now = time.strftime("%Y-%m-%d %H:%M:%S")
ln_assign = _preferred_unique_login_for_display(accounts, target_pid, name, "")
accounts[uid] = {
"user_id": uid,
"practice_id": target_pid,
"display_name": name,
"email": email,
"login_name": ln_assign,
"role": role,
"pw_hash": pw_hash,
"pw_salt": pw_salt,
"created": now,
"status": "active",
"last_login": now,
}
_save_accounts(accounts)
_ensure_default_channels(target_pid)
ua = request.headers.get("User-Agent", "")
ip = _extract_client_ip(request)
dev_id = _make_device_id(uid, ua)
token = _create_session(uid, target_pid, name, role,
device_id=dev_id, user_agent=ua, ip_addr=ip)
resp = JSONResponse(content={
"success": True, "user_id": uid, "role": role,
"display_name": name, "practice_id": target_pid,
"practice_bind_source": "invite_code",
})
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", "")
s = _session_from_request(request)
if s:
try:
_presence_clear_user(
(s.get("practice_id") or "").strip(),
(s.get("user_id") or "").strip(),
)
except Exception:
pass
_delete_session(token)
resp = JSONResponse(content={"success": True})
resp.delete_cookie("aza_session")
return resp
@router.get("/auth/resolve_invite")
async def auth_resolve_invite(code: str = Query("")):
"""Loesst einen Chat-Einladungscode in practice_id auf (ohne Login). Fuer Browser-Start mit ?invite=."""
raw = (code or "").strip()
if not raw:
return JSONResponse(content={"valid": False})
pid = _lookup_practice_id_by_invite(raw)
if not pid:
return JSONResponse(
content={"valid": False, "detail": "Ungueltiger oder veralteter Einladungscode"},
)
practices = _load_practices()
pdata = practices.get(pid, {})
return JSONResponse(content={
"valid": True,
"practice_id": pid,
"practice_name": (pdata.get("name") or "").strip(),
"invite_code": (pdata.get("invite_code") or "").strip(),
})
# =====================================================================
# Externe Praxen (Praxis-zu-Praxis-Verbindungen)
# =====================================================================
#
# Datenmodell:
# data/empfang_practice_links.json = {"links": [{
# "id":"lnk_...",
# "source_practice_id":"prac_A",
# "target_practice_id":"prac_B",
# "source_practice_name":"...",
# "target_practice_name":"...",
# "status":"accepted"|"pending_outgoing"|"pending_incoming"|
# "rejected"|"removed",
# "created_by_user_id":"u_...",
# "created_at":"...Z",
# "updated_at":"...Z",
# "invite_code_used":"CHAT-..."
# }]}
#
# Semantik:
# - Eine externe Praxis-Verbindung wird ausschliesslich UEBER DEN CHAT-
# EINLADUNGSCODE der ZIELPRAXIS angelegt. Der Code gilt als
# beidseitige Genehmigung, weil ihn die Zielpraxis bewusst aus der
# Adminverwaltung exportiert hat. Status nach Anlegen: "accepted".
# - Eine externe Verbindung ANDERT NICHT die eigene practice_id und
# legt KEINE Benutzer in der Zielpraxis an. Rollen wie admin/mpa/arzt
# werden NICHT praxisuebergreifend uebernommen.
# - Beide Praxen sehen den Link in ihrer eigenen GET-Liste.
# - Auth: Browser-Cookie-Session oder Desktop mit X-API-Token +
# X-Practice-Id + X-AzA-Empfang-User-Id (wie Presence/Desktop-Shell).
_PRACTICE_LINKS_FILE = _DATA_DIR / "empfang_practice_links.json"
def _load_practice_links_store() -> dict:
data = _load_json(_PRACTICE_LINKS_FILE, {"links": []})
if not isinstance(data, dict):
return {"links": []}
links = data.get("links")
if not isinstance(links, list):
data["links"] = []
return data
def _save_practice_links_store(data: dict) -> None:
_save_json(_PRACTICE_LINKS_FILE, data)
def _generate_practice_link_id() -> str:
return f"lnk_{int(time.time() * 1000)}_{secrets.token_hex(4)}"
def _practice_links_visible_to(pid: str) -> list:
"""Alle Links, in denen pid entweder source oder target ist und die
nicht "removed" sind (removed bleibt fuer Audit erhalten, ist aber UI-
seitig unsichtbar)."""
store = _load_practice_links_store()
out = []
for link in store.get("links") or []:
if not isinstance(link, dict):
continue
if (link.get("status") or "") == "removed":
continue
if link.get("source_practice_id") == pid or link.get("target_practice_id") == pid:
out.append(link)
return out
def _serialize_practice_link_for(pid: str, link: dict) -> dict:
"""Fuer GET-Antworten: wir markieren explizit, ob pid in source oder
target steht, damit der Client schnell rendern kann ("eingehend"
vs. "ausgehend"). Wir geben absichtlich Display-Namen mit."""
is_outgoing = link.get("source_practice_id") == pid
peer_pid = link.get("target_practice_id") if is_outgoing else link.get("source_practice_id")
peer_name = link.get("target_practice_name") if is_outgoing else link.get("source_practice_name")
return {
"id": str(link.get("id") or ""),
"status": str(link.get("status") or ""),
"direction": "outgoing" if is_outgoing else "incoming",
"peer_practice_id": str(peer_pid or ""),
"peer_practice_name": str(peer_name or ""),
"created_at": str(link.get("created_at") or ""),
"updated_at": str(link.get("updated_at") or ""),
"created_by_user_id": str(link.get("created_by_user_id") or ""),
"last_message_at": str(link.get("last_message_at") or ""),
}
def _find_practice_link(link_id: str, pid: str) -> Optional[dict]:
"""Liefert den Roh-Link, wenn pid Teilhaber ist; sonst None."""
if not link_id or not pid:
return None
store = _load_practice_links_store()
for link in store.get("links") or []:
if not isinstance(link, dict):
continue
if link.get("id") != link_id:
continue
if link.get("source_practice_id") == pid or link.get("target_practice_id") == pid:
return link
return None
def _practice_name_safe(pid: str) -> str:
if not pid:
return ""
try:
practices = _load_practices()
return str((practices.get(pid) or {}).get("name") or "").strip()
except Exception:
return ""
@router.get("/external-practices")
async def empfang_external_practices_list(request: Request):
"""Liste der eigenen externen Praxis-Verbindungen."""
s = _require_session(request)
pid = (s.get("practice_id") or "").strip()
if not pid:
raise HTTPException(status_code=400, detail="Session unvollstaendig")
own_links = [
x for x in _practice_links_visible_to(pid)
if _link_contact_type(x) == _CONTACT_TYPE_PRACTICE
]
own_links.sort(key=lambda x: str(x.get("updated_at") or x.get("created_at") or ""), reverse=True)
return JSONResponse(content={
"success": True,
"external_practices": [_serialize_practice_link_for(pid, link) for link in own_links],
})
@router.post("/external-practices/link-by-code")
async def empfang_external_practices_link_by_code(request: Request):
"""Verbindet die eigene Praxis mit einer fremden Praxis via Chat-
Einladungscode der Zielpraxis. Andert NICHT die eigene practice_id.
Body: {"code": "CHAT-...-..."}
Sicherheit:
- Code ist beidseitige Genehmigung (kommt aus B-Adminverwaltung).
- Selbst-Link (source==target) wird abgelehnt.
- Mehrfach-Linken wird idempotent gehandhabt (Update statt Duplikat).
"""
s = _require_session(request)
source_pid = (s.get("practice_id") or "").strip()
source_uid = (s.get("user_id") or "").strip()
if not source_pid or not source_uid:
raise HTTPException(status_code=400, detail="Session unvollstaendig")
try:
body = await request.json()
except Exception:
body = {}
raw_code = ""
if isinstance(body, dict):
raw_code = (body.get("code") or body.get("invite_code") or "").strip()
if not raw_code:
raise HTTPException(status_code=400, detail="code erforderlich")
target_pid = _lookup_practice_id_by_invite(raw_code)
if not target_pid:
raise HTTPException(
status_code=404,
detail="Code ist keiner Praxis zugeordnet oder bereits abgelaufen.",
)
if target_pid == source_pid:
raise HTTPException(
status_code=400,
detail="Sie koennen Ihre eigene Praxis nicht als externen Kontakt hinzufuegen.",
)
practices = _load_practices()
src_name = str((practices.get(source_pid) or {}).get("name") or "").strip()
tgt_name = str((practices.get(target_pid) or {}).get("name") or "").strip()
store = _load_practice_links_store()
now = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
# Suche existierenden Link (egal in welcher Richtung gespeichert).
existing = None
for link in store.get("links") or []:
if not isinstance(link, dict):
continue
a = link.get("source_practice_id")
b = link.get("target_practice_id")
if {a, b} == {source_pid, target_pid} \
and _link_contact_type(link) == _CONTACT_TYPE_PRACTICE:
existing = link
break
if existing is not None:
existing["contact_type"] = _CONTACT_TYPE_PRACTICE
existing["status"] = "accepted"
existing["updated_at"] = now
if not existing.get("invite_code_used"):
existing["invite_code_used"] = raw_code
# Display-Namen aktualisieren (falls Umbenennung).
if existing.get("source_practice_id") == source_pid:
existing["source_practice_name"] = src_name
existing["target_practice_name"] = tgt_name
else:
existing["source_practice_name"] = tgt_name
existing["target_practice_name"] = src_name
_save_practice_links_store(store)
out = existing
_log.info(
"AZA_EMPFANG_EXTLINK_UPDATED source=%s target=%s status=%s",
(source_pid or "")[:16], (target_pid or "")[:16], out["status"],
)
else:
out = {
"id": _generate_practice_link_id(),
"contact_type": _CONTACT_TYPE_PRACTICE,
"source_practice_id": source_pid,
"target_practice_id": target_pid,
"source_practice_name": src_name,
"target_practice_name": tgt_name,
"status": "accepted", # Code = beidseitige Genehmigung
"created_by_user_id": source_uid,
"created_at": now,
"updated_at": now,
"invite_code_used": raw_code,
}
store.setdefault("links", []).append(out)
_save_practice_links_store(store)
_log.info(
"AZA_EMPFANG_EXTLINK_CREATED source=%s target=%s",
(source_pid or "")[:16], (target_pid or "")[:16],
)
return JSONResponse(content={
"success": True,
"external_practice": _serialize_practice_link_for(source_pid, out),
})
@router.post("/external-practices/{link_id}/accept")
async def empfang_external_practices_accept(link_id: str, request: Request):
"""Markiert eine eingehende externe Verbindung als accepted.
Erlaubt fuer Mitglieder der Zielpraxis (die der ankommenden Seite).
Idempotent: bereits accepted -> bleibt accepted.
"""
s = _require_session(request)
pid = (s.get("practice_id") or "").strip()
if not pid:
raise HTTPException(status_code=400, detail="Session unvollstaendig")
link = _find_practice_link(link_id, pid)
if not link or _link_contact_type(link) != _CONTACT_TYPE_PRACTICE:
raise HTTPException(status_code=404, detail="Verbindung nicht gefunden")
link["status"] = "accepted"
link["updated_at"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
store = _load_practice_links_store()
for i, x in enumerate(store.get("links") or []):
if isinstance(x, dict) and x.get("id") == link_id:
store["links"][i] = link
break
_save_practice_links_store(store)
return JSONResponse(content={
"success": True,
"external_practice": _serialize_practice_link_for(pid, link),
})
@router.post("/external-practices/{link_id}/reject")
async def empfang_external_practices_reject(link_id: str, request: Request):
"""Lehnt eine externe Verbindung ab. Sichtbar fuer beide Seiten als
'rejected'. Kein Datenverlust auf der Gegenseite."""
s = _require_session(request)
pid = (s.get("practice_id") or "").strip()
if not pid:
raise HTTPException(status_code=400, detail="Session unvollstaendig")
link = _find_practice_link(link_id, pid)
if not link or _link_contact_type(link) != _CONTACT_TYPE_PRACTICE:
raise HTTPException(status_code=404, detail="Verbindung nicht gefunden")
link["status"] = "rejected"
link["updated_at"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
store = _load_practice_links_store()
for i, x in enumerate(store.get("links") or []):
if isinstance(x, dict) and x.get("id") == link_id:
store["links"][i] = link
break
_save_practice_links_store(store)
return JSONResponse(content={
"success": True,
"external_practice": _serialize_practice_link_for(pid, link),
})
@router.delete("/external-practices/{link_id}")
async def empfang_external_practices_remove(link_id: str, request: Request):
"""Entfernt die externe Verbindung aus der eigenen Sicht (status=removed).
Die Gegenseite bleibt informiert (sie sieht den Link weiterhin, aber
mit status=removed -- so kann sie ggf. erneut anfragen ohne Spam).
"""
s = _require_session(request)
pid = (s.get("practice_id") or "").strip()
if not pid:
raise HTTPException(status_code=400, detail="Session unvollstaendig")
link = _find_practice_link(link_id, pid)
if not link:
return JSONResponse(content={"success": True, "removed": 0})
if _link_contact_type(link) != _CONTACT_TYPE_PRACTICE:
raise HTTPException(
status_code=400,
detail="Persoenliche externe Kontakte bitte ueber /external-contacts entfernen.",
)
link["status"] = "removed"
link["updated_at"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
store = _load_practice_links_store()
for i, x in enumerate(store.get("links") or []):
if isinstance(x, dict) and x.get("id") == link_id:
store["links"][i] = link
break
_save_practice_links_store(store)
return JSONResponse(content={"success": True, "removed": 1})
# =====================================================================
# Persoenliche Benutzer-Notizen (pro practice_id + user_id)
# =====================================================================
#
# Strikte Trennung von Aufgaben, Briefen, Chatnachrichten und externen
# Kontakten:
# - Notizen sind PERSOENLICH des angemeldeten Benutzers.
# - Speicherung in data/empfang_user_notes.json:
# {"notes":[{
# "id":"note_...","practice_id":"prac_...","user_id":"u_...",
# "title":"...optional","body":"...","pinned":false,
# "client_id":"...optional","source":"manual",
# "status":"active"|"deleted",
# "created_at":"...Z","updated_at":"...Z","deleted_at":"...Z"|""
# }]}
# - Endpunkte verwenden ausschliesslich practice_id + user_id aus
# der serverseitigen Session. Body/Header-Felder zur User- oder
# Praxis-Identitaet werden ignoriert.
# - Soft-Delete: status=deleted, deleted_at gesetzt; GET filtert
# gelosechte Notizen aus.
# - Audit-Log: nur id und Anzahl, niemals Inhalt der Notiz.
_USER_NOTES_FILE = _DATA_DIR / "empfang_user_notes.json"
_NOTES_MAX_TITLE = 200
_NOTES_MAX_BODY = 8000
_NOTES_MAX_PER_USER = 500
def _load_notes_store() -> dict:
data = _load_json(_USER_NOTES_FILE, {"notes": []})
if not isinstance(data, dict):
return {"notes": []}
if not isinstance(data.get("notes"), list):
data["notes"] = []
return data
def _save_notes_store(data: dict) -> None:
_save_json(_USER_NOTES_FILE, data)
def _generate_note_id() -> str:
return f"note_{int(time.time() * 1000)}_{secrets.token_hex(4)}"
def _public_note(note: dict) -> dict:
return {
"id": str(note.get("id") or ""),
"title": str(note.get("title") or ""),
"body": str(note.get("body") or ""),
"pinned": bool(note.get("pinned")),
"source": str(note.get("source") or "manual"),
"created_at": str(note.get("created_at") or ""),
"updated_at": str(note.get("updated_at") or ""),
}
def _user_notes(pid: str, uid: str) -> list:
"""Aktive Notizen eines Benutzers in seiner Praxis."""
store = _load_notes_store()
out = []
for n in store.get("notes") or []:
if not isinstance(n, dict):
continue
if (n.get("status") or "active") == "deleted":
continue
if n.get("practice_id") != pid:
continue
if n.get("user_id") != uid:
continue
out.append(n)
return out
@router.get("/notes")
async def empfang_notes_list(request: Request):
"""Liste der eigenen aktiven Notizen, sortiert: gepinnte zuerst, dann
nach updated_at desc (Fallback created_at)."""
s = _require_session(request)
pid = (s.get("practice_id") or "").strip()
uid = (s.get("user_id") or "").strip()
if not pid or not uid:
raise HTTPException(status_code=400, detail="Session unvollstaendig")
notes = _user_notes(pid, uid)
notes.sort(
key=lambda n: (
0 if n.get("pinned") else 1,
str(n.get("updated_at") or n.get("created_at") or ""),
),
reverse=False,
)
# `reverse=False` mit Tupel (pinned-Schluessel, ts) waere falsch sortiert
# bei der Zeit-Komponente; daher explizit nachsortieren:
notes.sort(key=lambda n: str(n.get("updated_at") or n.get("created_at") or ""), reverse=True)
notes.sort(key=lambda n: 0 if n.get("pinned") else 1)
return JSONResponse(content={
"success": True,
"notes": [_public_note(n) for n in notes],
})
@router.post("/notes")
async def empfang_notes_create(request: Request):
"""Erstellt eine neue Notiz fuer (eigene practice_id, eigene user_id).
Body: {"title": "...", "body": "...", "client_id": "...", "pinned": false}
`practice_id` und `user_id` werden aus der Session entnommen.
"""
s = _require_session(request)
pid = (s.get("practice_id") or "").strip()
uid = (s.get("user_id") or "").strip()
if not pid or not uid:
raise HTTPException(status_code=400, detail="Session unvollstaendig")
try:
body = await request.json()
except Exception:
body = {}
if not isinstance(body, dict):
body = {}
title = _clip_text(body.get("title", ""), _NOTES_MAX_TITLE).strip()
content = _clip_text(body.get("body", ""), _NOTES_MAX_BODY)
if not content.strip() and not title:
raise HTTPException(status_code=400, detail="body oder title erforderlich")
client_id = str(body.get("client_id") or "").strip()
if client_id and not client_id.startswith("note_"):
client_id = ""
pinned = bool(body.get("pinned"))
store = _load_notes_store()
existing_for_user = [
n for n in store.get("notes") or []
if isinstance(n, dict)
and n.get("practice_id") == pid
and n.get("user_id") == uid
and (n.get("status") or "active") == "active"
]
if len(existing_for_user) >= _NOTES_MAX_PER_USER:
raise HTTPException(
status_code=400,
detail=f"Maximal {_NOTES_MAX_PER_USER} Notizen pro Benutzer.",
)
if client_id and any(
isinstance(n, dict) and n.get("id") == client_id for n in store.get("notes") or []
):
client_id = ""
note_id = client_id or _generate_note_id()
now = _now_z()
note = {
"id": note_id,
"practice_id": pid,
"user_id": uid,
"title": title,
"body": content,
"pinned": pinned,
"source": "manual",
"status": "active",
"created_at": now,
"updated_at": now,
"deleted_at": "",
}
store.setdefault("notes", []).append(note)
_save_notes_store(store)
_log.info(
"AZA_EMPFANG_NOTE_CREATED practice=%s user=%s note=%s",
(pid or "")[:16], (uid or "")[:16], (note_id or "")[:18],
)
return JSONResponse(content={"success": True, "note": _public_note(note)})
@router.patch("/notes/{note_id}")
async def empfang_notes_update(note_id: str, request: Request):
"""Aktualisiert title/body/pinned einer eigenen Notiz."""
s = _require_session(request)
pid = (s.get("practice_id") or "").strip()
uid = (s.get("user_id") or "").strip()
if not pid or not uid:
raise HTTPException(status_code=400, detail="Session unvollstaendig")
try:
body = await request.json()
except Exception:
body = {}
if not isinstance(body, dict):
body = {}
store = _load_notes_store()
target = None
target_idx = -1
for i, n in enumerate(store.get("notes") or []):
if not isinstance(n, dict):
continue
if n.get("id") != note_id:
continue
if n.get("practice_id") != pid or n.get("user_id") != uid:
continue
if (n.get("status") or "active") == "deleted":
continue
target = n
target_idx = i
break
if target is None:
raise HTTPException(status_code=404, detail="Notiz nicht gefunden")
changed = False
if "title" in body:
target["title"] = _clip_text(body.get("title", ""), _NOTES_MAX_TITLE).strip()
changed = True
if "body" in body:
content = _clip_text(body.get("body", ""), _NOTES_MAX_BODY)
target["body"] = content
changed = True
if "pinned" in body:
target["pinned"] = bool(body.get("pinned"))
changed = True
if changed:
target["updated_at"] = _now_z()
store["notes"][target_idx] = target
_save_notes_store(store)
_log.info(
"AZA_EMPFANG_NOTE_UPDATED practice=%s user=%s note=%s",
(pid or "")[:16], (uid or "")[:16], (note_id or "")[:18],
)
return JSONResponse(content={"success": True, "note": _public_note(target)})
@router.delete("/notes/{note_id}")
async def empfang_notes_delete(note_id: str, request: Request):
"""Soft-Delete einer eigenen Notiz."""
s = _require_session(request)
pid = (s.get("practice_id") or "").strip()
uid = (s.get("user_id") or "").strip()
if not pid or not uid:
raise HTTPException(status_code=400, detail="Session unvollstaendig")
store = _load_notes_store()
found = False
for i, n in enumerate(store.get("notes") or []):
if not isinstance(n, dict):
continue
if n.get("id") != note_id:
continue
if n.get("practice_id") != pid or n.get("user_id") != uid:
continue
if (n.get("status") or "active") == "deleted":
return JSONResponse(content={"success": True, "removed": 0})
n["status"] = "deleted"
n["deleted_at"] = _now_z()
n["updated_at"] = n["deleted_at"]
store["notes"][i] = n
found = True
break
if not found:
raise HTTPException(status_code=404, detail="Notiz nicht gefunden")
_save_notes_store(store)
_log.info(
"AZA_EMPFANG_NOTE_DELETED practice=%s user=%s note=%s",
(pid or "")[:16], (uid or "")[:16], (note_id or "")[:18],
)
return JSONResponse(content={"success": True, "removed": 1})
# =====================================================================
# Externe Kontakte (Praxis-Verbindungen UND einzelne externe Personen)
# =====================================================================
#
# Aufbauend auf dem bestehenden practice-links-Store werden Eintraege um
# das Feld ``contact_type`` ergaenzt:
# - "external_practice": Praxis-zu-Praxis-Verbindung
# (Code = beidseitige Genehmigung -> accepted)
# - "external_person" (Alias API: personal_external_contact):
# Persoenlicher 1:1-Kontakt Praxisgrenzen ueber
# genau zwei Benutzer. Keine Praxisliste der
# Gegenseite; keine automatischen Neukontakte.
#
# Bestehende Eintraege ohne contact_type werden weiterhin als
# external_practice interpretiert (Backward-Compat).
#
# Sicherheitsmodell (external_person):
# - Person bleibt Benutzer ihrer Quellpraxis — kein locals.json-Eintrag
# in der Zielpraxis, keine Rollenuebernahme.
# - Sichtbarkeit: nur source_user_id und target_user_id (Legacy ohne
# target_user_id: siehe _person_link_visible_to_user).
# - Annahme/Ablehnung: Zielbenutzer (target_user_id), nicht „alle Admins“.
# Legacy-Anfragen ohne target_user_id: weiterhin Admin der Zielpraxis.
# - Entfernen: die beiden Parteien (bzw. Legacy-Admin); Praxisbeitritt
# entsteht dadurch nicht.
_CONTACT_TYPE_PRACTICE = "external_practice"
_CONTACT_TYPE_PERSON = "external_person"
# API-Alias (Produktsprache); wird beim Lesen normalisiert, intern weiterhin
# ``external_person`` als JSON-Wert gespeichert.
_CONTACT_TYPE_PERSON_ALIAS = "personal_external_contact"
_VALID_CONTACT_TYPES = {
_CONTACT_TYPE_PRACTICE,
_CONTACT_TYPE_PERSON,
_CONTACT_TYPE_PERSON_ALIAS,
}
_VALID_LINK_STATUS = {
"pending_outgoing", "pending_incoming",
"accepted", "rejected", "blocked", "removed",
}
def _link_contact_type(link: dict) -> str:
"""Liest contact_type robust; Defaults auf external_practice."""
if not isinstance(link, dict):
return _CONTACT_TYPE_PRACTICE
t = (link.get("contact_type") or "").strip()
if t == _CONTACT_TYPE_PERSON_ALIAS:
return _CONTACT_TYPE_PERSON
if t in (_CONTACT_TYPE_PRACTICE, _CONTACT_TYPE_PERSON):
return t
return _CONTACT_TYPE_PRACTICE
def _effective_person_target_user_id(link: dict) -> str:
"""Zielbenutzer einer persoenlichen externen Verbindung (1:1).
Neu: explizites ``target_user_id``. Legacy: bei ``accepted`` ohne Ziel-ID
wird ``approved_by_user_id`` als Gegenpart angenommen (Admin hat frueher
angenommen).
"""
if not isinstance(link, dict):
return ""
tu = (link.get("target_user_id") or "").strip()
if tu:
return tu
st = str(link.get("status") or "").strip().lower()
if st == "accepted":
return (
(link.get("approved_by_user_id") or link.get("accepted_by_user_id") or "")
.strip()
)
return ""
def _person_link_visible_to_user(
link: dict, pid: str, uid: str, session_is_admin: bool,
) -> bool:
"""Sichtbarkeit strikt 1:1; Admins sehen keine neuen Personenanfragen, ausser Legacy."""
if _link_contact_type(link) != _CONTACT_TYPE_PERSON:
return True
pid = (pid or "").strip()
uid = (uid or "").strip()
sp = (link.get("source_practice_id") or "").strip()
tp = (link.get("target_practice_id") or "").strip()
su = (link.get("source_user_id") or "").strip()
if pid == sp and su and uid == su:
return True
if pid != tp:
return False
tu_eff = (link.get("target_user_id") or "").strip()
st = str(link.get("status") or "").strip().lower()
if tu_eff:
return uid == tu_eff
# Legacy: keine explizite Zielperson — eingehend nur fuer Admins der Zielpraxis
if st in ("pending_outgoing", "pending_incoming", "pending"):
return bool(session_is_admin)
if st == "accepted":
ap = (link.get("approved_by_user_id") or "").strip()
return bool(ap and uid == ap)
return False
def _practice_links_visible_for_user(
pid: str, uid: str, session_is_admin: bool,
) -> list:
"""Wie _practice_links_visible_to, aber Persoenliche Kontakte nur fuer die zwei Parteien."""
pid = (pid or "").strip()
uid = (uid or "").strip()
store = _load_practice_links_store()
out: list = []
for link in store.get("links") or []:
if not isinstance(link, dict):
continue
if (link.get("status") or "") == "removed":
continue
if link.get("source_practice_id") != pid and link.get("target_practice_id") != pid:
continue
if _link_contact_type(link) == _CONTACT_TYPE_PERSON:
if not _person_link_visible_to_user(link, pid, uid, session_is_admin):
continue
out.append(link)
return out
def _find_practice_link_for_user(
link_id: str, pid: str, uid: str, session_is_admin: bool,
) -> Optional[dict]:
link = _find_practice_link(link_id, pid)
if not link:
return None
if _link_contact_type(link) == _CONTACT_TYPE_PERSON:
if not _person_link_visible_to_user(link, pid, uid, session_is_admin):
return None
return link
def _person_link_may_remove(
link: dict, pid: str, uid: str, session_is_admin: bool,
) -> bool:
if _link_contact_type(link) != _CONTACT_TYPE_PERSON:
return True
sp = (link.get("source_practice_id") or "").strip()
tp = (link.get("target_practice_id") or "").strip()
su = (link.get("source_user_id") or "").strip()
tu = (link.get("target_user_id") or "").strip()
if pid == sp and su and uid == su:
return True
if pid == tp and tu and uid == tu:
return True
if pid == tp and (not tu) and session_is_admin:
return True
ap = (link.get("approved_by_user_id") or "").strip()
if pid == tp and (not tu) and ap and uid == ap:
return True
return False
def _person_may_moderate_incoming(link: dict, uid: str, session_is_admin: bool) -> bool:
"""accept/reject/block: Zielbenutzer oder Legacy-Admin."""
if _link_contact_type(link) != _CONTACT_TYPE_PERSON:
return session_is_admin
uid = (uid or "").strip()
if link.get("target_practice_id") and (link.get("source_practice_id") == link.get("target_practice_id")):
return False
tu = (link.get("target_user_id") or "").strip()
if tu:
return uid == tu
return session_is_admin
def _serialize_external_contact_for(pid: str, uid: str, link: dict) -> dict:
"""Generischer Serializer fuer beide Kontaktarten.
Dreht 'direction' aus pid-Sicht (outgoing wenn source==pid, sonst incoming),
und liefert je nach contact_type passende 'peer_*'-Felder:
- external_practice: peer = Gegenpraxis
- external_person: bei pid==target: peer_user_display = Anzeige der
Person; bei pid==source: peer_user_display ist
entweder leer (vor accept) oder ein evtl. spaeter
eingetragener Zielname; peer_practice_name immer
die Gegenpraxis.
"""
is_outgoing = link.get("source_practice_id") == pid
ctype = _link_contact_type(link)
peer_pid = link.get("target_practice_id") if is_outgoing else link.get("source_practice_id")
peer_pname = link.get("target_practice_name") if is_outgoing else link.get("source_practice_name")
own_pname = link.get("source_practice_name") if is_outgoing else link.get("target_practice_name")
peer_user_display = ""
if ctype == _CONTACT_TYPE_PERSON:
if is_outgoing:
peer_user_display = str(link.get("target_display_name") or "").strip()
if not peer_user_display:
tuid = (link.get("target_user_id") or "").strip()
if tuid:
acc_t = (_load_accounts().get(tuid) or {})
if isinstance(acc_t, dict) and (acc_t.get("practice_id") or "").strip() == str(
link.get("target_practice_id") or "",
).strip():
peer_user_display = str(acc_t.get("display_name") or "").strip()
else:
peer_user_display = str(link.get("source_display_name") or "").strip()
out = {
"id": str(link.get("id") or ""),
"contact_type": ctype,
"status": str(link.get("status") or ""),
"direction": "outgoing" if is_outgoing else "incoming",
"peer_practice_id": str(peer_pid or ""),
"peer_practice_name": str(peer_pname or ""),
"peer_user_display": peer_user_display,
"own_practice_name": str(own_pname or ""),
"note": str(link.get("note") or "")[:500],
"created_at": str(link.get("created_at") or ""),
"updated_at": str(link.get("updated_at") or ""),
"requested_by_user_id": str(link.get("requested_by_user_id") or link.get("created_by_user_id") or ""),
"approved_by_user_id": str(link.get("approved_by_user_id") or ""),
"accepted_by_user_id": str(link.get("accepted_by_user_id") or link.get("approved_by_user_id") or ""),
"target_user_id": str(link.get("target_user_id") or ""),
"last_message_at": str(link.get("last_message_at") or ""),
}
# Nur bei akzeptierter Personen-Verbindung: Gegen-user_id fuer 1:1-Chat.
if (
ctype == _CONTACT_TYPE_PERSON
and str(link.get("status") or "").strip().lower() == "accepted"
):
sp = (link.get("source_practice_id") or "").strip()
tp = (link.get("target_practice_id") or "").strip()
su = (link.get("source_user_id") or "").strip()
tu_eff = _effective_person_target_user_id(link)
if pid == tp and su:
out["peer_user_id"] = su
elif pid == sp and tu_eff:
out["peer_user_id"] = tu_eff
else:
out["peer_user_id"] = ""
return out
def _is_admin_session(s: dict) -> bool:
try:
uid = (s.get("user_id") or "").strip()
if not uid:
return False
accounts = _load_accounts()
acc = accounts.get(uid)
return _account_has_practice_admin_privileges(acc)
except Exception:
return False
def _now_z() -> str:
return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
def _persist_link_update(link_id: str, updated: dict) -> None:
store = _load_practice_links_store()
for i, x in enumerate(store.get("links") or []):
if isinstance(x, dict) and x.get("id") == link_id:
store["links"][i] = updated
break
else:
store.setdefault("links", []).append(updated)
_save_practice_links_store(store)
@router.get("/external-contacts")
async def empfang_external_contacts_list(
request: Request,
contact_type: Optional[str] = Query(None),
):
"""Liste aller externen Kontakte (beide Typen) aus eigener Praxis-Sicht.
Optional ``?contact_type=external_practice`` oder ``external_person``.
Eintraege mit status=removed werden ausgeblendet.
"""
s = _require_session(request)
pid = (s.get("practice_id") or "").strip()
uid = (s.get("user_id") or "").strip()
if not pid:
raise HTTPException(status_code=400, detail="Session unvollstaendig")
is_adm = _is_admin_session(s)
own_links = _practice_links_visible_for_user(pid, uid, is_adm)
if contact_type:
ct = contact_type.strip().lower()
if ct == _CONTACT_TYPE_PERSON_ALIAS:
ct = _CONTACT_TYPE_PERSON
if ct in (_CONTACT_TYPE_PRACTICE, _CONTACT_TYPE_PERSON):
own_links = [x for x in own_links if _link_contact_type(x) == ct]
own_links.sort(
key=lambda x: str(x.get("updated_at") or x.get("created_at") or ""),
reverse=True,
)
return JSONResponse(content={
"success": True,
"external_contacts": [
_serialize_external_contact_for(pid, uid, x) for x in own_links
],
})
def _external_peer_practice_user_rows(peer_pid: str) -> list[dict]:
"""Aktuelle Benutzer der Peer-Praxis aus der Account-DB (nicht als Snapshot am Link).
Nur sendbare Konten (_account_is_sendable): keine deaktivierten/geloeschten.
"""
peer_pid = (peer_pid or "").strip()
out: list[dict] = []
if not peer_pid:
return out
accounts = _load_accounts()
for a in accounts.values():
if not isinstance(a, dict):
continue
if (a.get("practice_id") or "").strip() != peer_pid:
continue
if not _account_is_sendable(a):
continue
uid = str(a.get("user_id") or "").strip()
if not uid:
continue
acc_st = str(a.get("status") or "active").strip().lower()
out.append({
"user_id": uid,
"display_name": str(a.get("display_name") or "").strip() or uid[:12],
"role": str(a.get("role") or "").strip(),
"status": acc_st or "active",
"external_practice_id": peer_pid,
})
out.sort(key=lambda x: (
(x.get("display_name") or "").strip().lower(),
x.get("user_id") or "",
))
return out
@router.get("/external-contacts/{link_id}/peer-users")
async def empfang_external_contact_peer_users(link_id: str, request: Request):
"""Minimaldaten der Gegenstelle fuer eine **accepted** Verbindung.
- external_practice: **immer live** alle sendbaren Benutzer der Gegenpraxis
(aktueller Stand aus der Account-DB; kein Snapshot am Link, kein Code neu).
- external_person: auf **beiden** Seiten genau **ein** Benutzer (1:1), nie
die gesamte Gegenpraxis-Liste.
Keine E-Mail, keine Tokens. Keine Uebernahme von Rollen in die andere Praxis.
"""
s = _session_or_shell_identity(request)
my_pid = (s.get("practice_id") or "").strip()
my_uid = (s.get("user_id") or "").strip()
if not my_pid or not my_uid:
raise HTTPException(status_code=400, detail="Session unvollstaendig")
is_adm = _is_admin_session(s)
link = _find_practice_link_for_user(link_id, my_pid, my_uid, is_adm)
if not link:
raise HTTPException(status_code=404, detail="Verbindung nicht gefunden")
st = str(link.get("status") or "").strip().lower()
if st != "accepted":
raise HTTPException(
status_code=403,
detail="Verbindung nicht freigegeben",
)
ctype = _link_contact_type(link)
peer_pid = ""
out: list[dict] = []
if ctype == _CONTACT_TYPE_PRACTICE:
is_outgoing = link.get("source_practice_id") == my_pid
peer_pid = (
link.get("target_practice_id") if is_outgoing else link.get("source_practice_id")
)
peer_pid = str(peer_pid or "").strip()
if not peer_pid or peer_pid == my_pid:
raise HTTPException(status_code=400, detail="Peer-Praxis ungueltig")
out = _external_peer_practice_user_rows(peer_pid)
elif ctype == _CONTACT_TYPE_PERSON:
sp = (link.get("source_practice_id") or "").strip()
tp = (link.get("target_practice_id") or "").strip()
su = (link.get("source_user_id") or "").strip()
tu_eff = _effective_person_target_user_id(link)
if not sp or not tp or not su or not tu_eff:
raise HTTPException(status_code=400, detail="Verbindung unvollstaendig")
if my_pid == tp:
peer_pid = sp
acc = _account_record_for_practice(su, sp)
if acc and _account_is_sendable(acc):
acc_st = str(acc.get("status") or "active").strip().lower()
out = [{
"user_id": su,
"display_name": str(acc.get("display_name") or "").strip() or su[:12],
"role": str(acc.get("role") or "").strip(),
"status": acc_st or "active",
"external_practice_id": sp,
}]
elif my_pid == sp:
if my_uid != su:
raise HTTPException(status_code=403, detail="Keine Berechtigung")
peer_pid = tp
acc = _account_record_for_practice(tu_eff, tp)
if acc and _account_is_sendable(acc):
acc_st = str(acc.get("status") or "active").strip().lower()
out = [{
"user_id": tu_eff,
"display_name": str(acc.get("display_name") or "").strip() or tu_eff[:12],
"role": str(acc.get("role") or "").strip(),
"status": acc_st or "active",
"external_practice_id": tp,
}]
else:
raise HTTPException(status_code=404, detail="Verbindung nicht gefunden")
else:
raise HTTPException(status_code=400, detail="Unbekannter Kontakttyp")
return JSONResponse(
content={
"success": True,
"link_id": link_id,
"external_practice_id": peer_pid,
"users": out,
},
headers={"Cache-Control": "no-store, no-cache, must-revalidate"},
)
# ---------------------------------------------------------------------
# Externe DM: Lesestatus (serverseitig, mehrere Geraete)
# ---------------------------------------------------------------------
def _load_external_dm_reads_store() -> dict:
data = _load_json(_EXTERNAL_DM_READS_FILE, {"by_user": {}})
if not isinstance(data.get("by_user"), dict):
data["by_user"] = {}
return data
def _save_external_dm_reads_store(data: dict) -> None:
_save_json(_EXTERNAL_DM_READS_FILE, data)
def _external_dm_reads_actor_key(pid: str, uid: str) -> str:
return f"{(pid or '').strip()}|{(uid or '').strip()}"
def _external_dm_get_last_read_iso(pid: str, uid: str, conv_id: str) -> str:
store = _load_external_dm_reads_store()
d = (store.get("by_user") or {}).get(_external_dm_reads_actor_key(pid, uid)) or {}
return str(d.get((conv_id or "").strip()) or "").strip()
def _external_dm_set_last_read_iso(pid: str, uid: str, conv_id: str, iso: str) -> None:
if not pid or not uid or not conv_id or not iso:
return
store = _load_external_dm_reads_store()
bucket = store.setdefault("by_user", {})
key = _external_dm_reads_actor_key(pid, uid)
inner = bucket.setdefault(key, {})
inner[conv_id] = iso
bucket[key] = inner
store["by_user"] = bucket
_save_external_dm_reads_store(store)
def _external_dm_unread_aggregate(my_pid: str, my_uid: str) -> tuple[int, list[dict]]:
grouped: dict[str, list[dict]] = defaultdict(list)
for m in _load_external_dm_messages():
if not isinstance(m, dict) or str(m.get("conv_type") or "") != "external_dm":
continue
cid = str(m.get("conversation_id") or "").strip()
if not cid:
continue
sp = str(m.get("sender_practice_id") or "").strip()
su = str(m.get("sender_user_id") or "").strip()
rp = str(m.get("recipient_practice_id") or "").strip()
ru = str(m.get("recipient_user_id") or "").strip()
if (sp == my_pid and su == my_uid) or (rp == my_pid and ru == my_uid):
pass
else:
continue
peer_p = rp if sp == my_pid and su == my_uid else sp
peer_u = ru if sp == my_pid and su == my_uid else su
try:
_external_dm_authorize_pair(my_pid, my_uid, peer_p, peer_u)
except HTTPException:
continue
grouped[cid].append(m)
out: list[dict] = []
total = 0
for cid, msgs in grouped.items():
if not msgs:
continue
m0 = msgs[0]
sp = str(m0.get("sender_practice_id") or "").strip()
su = str(m0.get("sender_user_id") or "").strip()
rp = str(m0.get("recipient_practice_id") or "").strip()
ru = str(m0.get("recipient_user_id") or "").strip()
if rp == my_pid and ru == my_uid:
peer_p, peer_u = sp, su
else:
peer_p, peer_u = rp, ru
lr_iso = _external_dm_get_last_read_iso(my_pid, my_uid, cid)
lr_ts = _parse_msg_instant_utc_ts(lr_iso)
last_ts = 0.0
last_iso = ""
unread = 0
peer_dn = ""
for m in msgs:
ts_raw = str(m.get("empfangen") or m.get("zeitstempel") or "").strip()
ts_val = _parse_msg_instant_utc_ts(ts_raw)
if ts_val >= last_ts:
last_ts = ts_val
last_iso = ts_raw
snd = str(m.get("sender_user_id") or "").strip()
if snd and snd != my_uid and ts_val > lr_ts:
unread += 1
if not peer_dn:
acc = _account_record_for_practice(peer_u, peer_p) or {}
peer_dn = str(acc.get("display_name") or "").strip()
if not peer_dn:
for m in msgs:
if str(m.get("sender_user_id") or "").strip() == peer_u:
peer_dn = str(m.get("sender_display_name") or "").strip()
break
if str(m.get("recipient_user_id") or "").strip() == peer_u:
peer_dn = str(m.get("recipient_display_name") or "").strip()
break
if unread > 0:
total += unread
out.append({
"conversation_id": cid,
"peer_practice_id": peer_p,
"peer_practice_name": _practice_name_safe(peer_p),
"peer_user_id": peer_u,
"peer_display_name": peer_dn or peer_u[:12],
"last_message_at": last_iso,
"unread_count": unread,
})
out.sort(key=lambda x: _parse_msg_instant_utc_ts(str(x.get("last_message_at") or "")), reverse=True)
return total, out
# ---------------------------------------------------------------------
# Chat-Anhaenge (Datei, Auth beim Abruf)
# ---------------------------------------------------------------------
def _ensure_attachments_dir() -> None:
_ATTACHMENTS_DIR.mkdir(parents=True, exist_ok=True)
def _load_attachment_meta_store() -> dict:
data = _load_json(_ATTACHMENTS_META_FILE, {"attachments": {}})
if not isinstance(data.get("attachments"), dict):
data["attachments"] = {}
return data
def _save_attachment_meta_store(data: dict) -> None:
_save_json(_ATTACHMENTS_META_FILE, data)
def _generate_attachment_id() -> str:
return f"att_{uuid.uuid4().hex[:16]}"
def _normalize_upload_mime(raw: str, filename: str) -> str:
s = (raw or "").split(";", 1)[0].strip().lower()
if s in _ATTACHMENT_ALLOWED_MIME:
return s
fn = (filename or "").lower()
if fn.endswith(".png"):
return "image/png"
if fn.endswith(".jpg") or fn.endswith(".jpeg"):
return "image/jpeg"
if fn.endswith(".webp"):
return "image/webp"
return ""
def _safe_attachment_basename(name: str) -> str:
base = Path(str(name or "")).name.strip()
if not base or base in (".", ".."):
return "bild"
return base[:180]
def _attachment_meta_get(att_id: str) -> Optional[dict]:
store = _load_attachment_meta_store()
rec = (store.get("attachments") or {}).get(att_id)
return rec if isinstance(rec, dict) else None
def _attachment_can_view(rec: dict, my_pid: str, my_uid: str) -> bool:
if not rec or not my_pid or not my_uid:
return False
scope = str(rec.get("scope") or "")
if scope == "internal_dm":
if str(rec.get("practice_id") or "") != my_pid:
return False
if rec.get("status") != "committed":
return str(rec.get("user_id") or "") == my_uid
dck = str(rec.get("direct_conv_key") or "").strip()
parts = dck.split("|")
if len(parts) >= 4 and parts[1] == "direct":
return my_uid in (parts[2], parts[3])
return True
if scope == "external_dm":
if str(rec.get("practice_id") or "") != my_pid:
return False
if rec.get("status") != "committed":
return str(rec.get("user_id") or "") == my_uid
peer_p = str(rec.get("peer_practice_id") or "").strip()
peer_u = str(rec.get("peer_user_id") or "").strip()
conv = str(rec.get("conversation_id") or "").strip()
if not peer_p or not peer_u or not conv:
return False
try:
_external_dm_authorize_pair(my_pid, my_uid, peer_p, peer_u)
except HTTPException:
return False
return conv == _external_dm_conversation_id(my_pid, my_uid, peer_p, peer_u)
return False
def _attachment_row_for_message(rec: dict) -> dict:
return {
"attachment_id": str(rec.get("id") or ""),
"name": str(rec.get("original_name") or "bild"),
"mime_type": str(rec.get("mime_type") or "application/octet-stream"),
"size": int(rec.get("size") or 0),
"kind": "stored",
}
def _finalize_attachment_ids_internal(
ids: list[str],
pid: str,
sender_uid: str,
recipient_uid: str,
conv_key: str,
) -> list[dict]:
out: list[dict] = []
if not ids:
return out
if len(ids) > _ATTACHMENT_MAX_PER_MESSAGE:
raise HTTPException(status_code=400, detail="Zu viele Anhaenge")
store = _load_attachment_meta_store()
bucket = store.setdefault("attachments", {})
for raw_id in ids:
aid = str(raw_id or "").strip()
if not aid:
continue
rec = bucket.get(aid)
if not isinstance(rec, dict):
raise HTTPException(status_code=400, detail="Anhang nicht gefunden")
if rec.get("status") != "pending":
raise HTTPException(status_code=400, detail="Anhang bereits verwendet")
if str(rec.get("scope") or "") != "internal_dm":
raise HTTPException(status_code=400, detail="Anhang ungueltig")
if str(rec.get("practice_id") or "") != pid or str(rec.get("user_id") or "") != sender_uid:
raise HTTPException(status_code=403, detail="Anhang nicht erlaubt")
rec["status"] = "committed"
rec["direct_conv_key"] = conv_key
rec["recipient_user_id"] = recipient_uid
bucket[aid] = rec
out.append(_attachment_row_for_message(rec))
store["attachments"] = bucket
_save_attachment_meta_store(store)
return out
def _finalize_attachment_ids_external(
ids: list[str],
sender_pid: str,
sender_uid: str,
recipient_pid: str,
recipient_uid: str,
conv_id: str,
) -> list[dict]:
out: list[dict] = []
if not ids:
return out
if len(ids) > _ATTACHMENT_MAX_PER_MESSAGE:
raise HTTPException(status_code=400, detail="Zu viele Anhaenge")
store = _load_attachment_meta_store()
bucket = store.setdefault("attachments", {})
for raw_id in ids:
aid = str(raw_id or "").strip()
if not aid:
continue
rec = bucket.get(aid)
if not isinstance(rec, dict):
raise HTTPException(status_code=400, detail="Anhang nicht gefunden")
if rec.get("status") != "pending":
raise HTTPException(status_code=400, detail="Anhang bereits verwendet")
if str(rec.get("scope") or "") != "external_dm":
raise HTTPException(status_code=400, detail="Anhang ungueltig")
if str(rec.get("practice_id") or "") != sender_pid or str(rec.get("user_id") or "") != sender_uid:
raise HTTPException(status_code=403, detail="Anhang nicht erlaubt")
if str(rec.get("peer_practice_id") or "") != recipient_pid \
or str(rec.get("peer_user_id") or "") != recipient_uid:
raise HTTPException(status_code=400, detail="Anhang passt nicht zum Empfaenger")
rec["status"] = "committed"
rec["conversation_id"] = conv_id
bucket[aid] = rec
out.append(_attachment_row_for_message(rec))
store["attachments"] = bucket
_save_attachment_meta_store(store)
return out
# =====================================================================
# Cross-Praxis Direktnachrichten (eigenes Speicherfile, eigene Endpunkte)
# =====================================================================
class ExternalDmSendIn(BaseModel):
recipient_practice_id: str = ""
recipient_user_id: str = ""
text: str = ""
attachment_ids: list[str] = Field(default_factory=list)
@router.post("/external-messages/send")
async def empfang_external_dm_send(request: Request, payload: ExternalDmSendIn):
"""Sendet eine DM an einen Benutzer einer anderen Praxis (nur mit accepted Link).
practice_id / sender_user_id ausschliesslich aus Session bzw. Shell-Identitaet.
"""
s = _session_or_shell_identity(request)
sender_pid = (s.get("practice_id") or "").strip()
sender_uid = (s.get("user_id") or "").strip()
if not sender_pid or not sender_uid:
raise HTTPException(status_code=400, detail="Session unvollstaendig")
recipient_pid = (payload.recipient_practice_id or "").strip()
recipient_uid = (payload.recipient_user_id or "").strip()
raw_text = (payload.text or "").strip()
text = _clip_text(raw_text, _EXTERNAL_DM_TEXT_MAX)
att_ids = [str(x or "").strip() for x in (payload.attachment_ids or []) if str(x or "").strip()]
link_id, _link = _external_dm_authorize_send_direction(
sender_pid, sender_uid, recipient_pid, recipient_uid,
)
if not text and not att_ids:
raise HTTPException(
status_code=400,
detail="Nachrichtstext oder Anhang erforderlich",
)
s_acc = _account_record_for_practice(sender_uid, sender_pid) or {}
r_acc = _account_record_for_practice(recipient_uid, recipient_pid) or {}
s_dn = str(s_acc.get("display_name") or "").strip() or sender_uid[:12]
r_dn = str(r_acc.get("display_name") or "").strip() or recipient_uid[:12]
conv_id = _external_dm_conversation_id(
sender_pid, sender_uid, recipient_pid, recipient_uid,
)
att_rows = _finalize_attachment_ids_external(
att_ids, sender_pid, sender_uid, recipient_pid, recipient_uid, conv_id,
)
msg_id = uuid.uuid4().hex[:12]
now = _utc_now_iso_z()
extras = {
"audience": "direct",
"conv_type": "external_dm",
"external_dm": True,
"conversation_id": conv_id,
"external_link_id": link_id,
"sender_user_id": sender_uid,
"recipient_user_id": recipient_uid,
"sender_practice_id": sender_pid,
"recipient_practice_id": recipient_pid,
}
if att_rows:
extras["attachments"] = att_rows
row = {
"id": msg_id,
"conv_type": "external_dm",
"conversation_id": conv_id,
"external_link_id": link_id,
"sender_practice_id": sender_pid,
"sender_user_id": sender_uid,
"sender_display_name": s_dn,
"recipient_practice_id": recipient_pid,
"recipient_user_id": recipient_uid,
"recipient_display_name": r_dn,
"kommentar": text or ("\u200b" if att_rows else ""),
"zeitstempel": now,
"empfangen": now,
"status": "offen",
"extras": extras,
}
msgs = _load_external_dm_messages()
msgs.insert(0, row)
_save_external_dm_messages(msgs)
try:
_pulse_bump(sender_pid, sender="external_dm")
_pulse_bump(recipient_pid, sender="external_dm")
except Exception:
pass
_log.info(
"AZA_EXT_DM_SEND conv=%s from=%s/%s to=%s/%s",
conv_id[:16], sender_pid[:12], (sender_uid or "")[:12],
recipient_pid[:12], (recipient_uid or "")[:12],
)
return JSONResponse(content={
"success": True,
"message_id": msg_id,
"conversation_id": conv_id,
"created_at": now,
})
@router.get("/external-messages/thread")
async def empfang_external_dm_thread(
request: Request,
peer_practice_id: str = Query("", alias="peer_practice_id"),
peer_user_id: str = Query("", alias="peer_user_id"),
):
"""Liefert Nachrichten einer externen 1:1-Konversation."""
s = _session_or_shell_identity(request)
my_pid = (s.get("practice_id") or "").strip()
my_uid = (s.get("user_id") or "").strip()
if not my_pid or not my_uid:
raise HTTPException(status_code=400, detail="Session unvollstaendig")
peer_pid = (peer_practice_id or "").strip()
peer_uid = (peer_user_id or "").strip()
link_id, _link = _external_dm_authorize_pair(my_pid, my_uid, peer_pid, peer_uid)
conv_id = _external_dm_conversation_id(my_pid, my_uid, peer_pid, peer_uid)
stored = _load_external_dm_messages()
thread_raw = [
m for m in stored
if isinstance(m, dict)
and str(m.get("conversation_id") or "") == conv_id
]
thread_raw.sort(key=_msg_chrono_sort_key)
out_msgs = [_external_dm_to_client_message(m) for m in thread_raw]
pulse = _pulse_get(my_pid)
return JSONResponse(content={
"success": True,
"conversation_id": conv_id,
"external_link_id": link_id,
"messages": out_msgs,
"tick": int(pulse.get("tick", 0)),
})
@router.get("/external-messages/conversations")
async def empfang_external_dm_conversations(request: Request):
"""Letzte externe 1:1-Konversationen des angemeldeten Benutzers (serverseitig)."""
s = _session_or_shell_identity(request)
my_pid = (s.get("practice_id") or "").strip()
my_uid = (s.get("user_id") or "").strip()
if not my_pid or not my_uid:
raise HTTPException(status_code=400, detail="Session unvollstaendig")
stored = _load_external_dm_messages()
conv_last: dict[str, dict] = {}
for m in stored:
if not isinstance(m, dict):
continue
if str(m.get("conv_type") or "") != "external_dm":
continue
cid = str(m.get("conversation_id") or "").strip()
if not cid:
continue
sp = str(m.get("sender_practice_id") or "").strip()
su = str(m.get("sender_user_id") or "").strip()
rp = str(m.get("recipient_practice_id") or "").strip()
ru = str(m.get("recipient_user_id") or "").strip()
if sp == my_pid and su == my_uid:
peer_p, peer_u = rp, ru
peer_dn = str(m.get("recipient_display_name") or "").strip()
elif rp == my_pid and ru == my_uid:
peer_p, peer_u = sp, su
peer_dn = str(m.get("sender_display_name") or "").strip()
else:
continue
try:
_external_dm_authorize_pair(my_pid, my_uid, peer_p, peer_u)
except HTTPException:
continue
ts = str(m.get("empfangen") or m.get("zeitstempel") or "").strip()
prev = conv_last.get(cid)
if prev and prev.get("last_message_at", "") >= ts:
continue
conv_last[cid] = {
"conversation_id": cid,
"kind": "external_dm",
"peer_practice_id": peer_p,
"peer_practice_name": _practice_name_safe(peer_p),
"peer_user_id": peer_u,
"peer_display_name": peer_dn or peer_u[:12],
"external_link_id": str(m.get("external_link_id") or ""),
"last_message_at": ts,
}
rows = sorted(
conv_last.values(),
key=lambda x: str(x.get("last_message_at") or ""),
reverse=True,
)
return JSONResponse(content={"success": True, "conversations": rows})
class ExternalDmMarkReadIn(BaseModel):
peer_practice_id: str = ""
peer_user_id: str = ""
@router.get("/external-messages/unread-summary")
async def empfang_external_dm_unread_summary(request: Request):
"""Ungelesene externe 1:1-Threads (serverseitiger Lesestatus)."""
s = _session_or_shell_identity(request)
my_pid = (s.get("practice_id") or "").strip()
my_uid = (s.get("user_id") or "").strip()
if not my_pid or not my_uid:
raise HTTPException(status_code=400, detail="Session unvollstaendig")
total, items = _external_dm_unread_aggregate(my_pid, my_uid)
return JSONResponse(
content={"success": True, "total_unread": total, "items": items},
headers={"Cache-Control": "no-store, no-cache, must-revalidate"},
)
@router.post("/external-messages/mark-read")
async def empfang_external_dm_mark_read(request: Request, body: ExternalDmMarkReadIn):
"""Setzt Lesestatus fuer einen externen Thread auf die juengste bekannte Nachricht."""
s = _session_or_shell_identity(request)
my_pid = (s.get("practice_id") or "").strip()
my_uid = (s.get("user_id") or "").strip()
if not my_pid or not my_uid:
raise HTTPException(status_code=400, detail="Session unvollstaendig")
peer_pid = (body.peer_practice_id or "").strip()
peer_uid = (body.peer_user_id or "").strip()
if not peer_pid or not peer_uid:
raise HTTPException(status_code=400, detail="peer_practice_id und peer_user_id erforderlich")
_external_dm_authorize_pair(my_pid, my_uid, peer_pid, peer_uid)
conv_id = _external_dm_conversation_id(my_pid, my_uid, peer_pid, peer_uid)
max_iso = _utc_now_iso_z()
max_ts = _parse_msg_instant_utc_ts(max_iso)
for m in _load_external_dm_messages():
if not isinstance(m, dict):
continue
if str(m.get("conversation_id") or "") != conv_id:
continue
ts_raw = str(m.get("empfangen") or m.get("zeitstempel") or "").strip()
t = _parse_msg_instant_utc_ts(ts_raw)
if t >= max_ts:
max_ts = t
max_iso = ts_raw
_external_dm_set_last_read_iso(my_pid, my_uid, conv_id, max_iso)
return JSONResponse(content={"success": True, "conversation_id": conv_id})
@router.post("/attachments/upload")
async def empfang_attachment_upload(
request: Request,
file: UploadFile = File(...),
scope: str = Form("internal_dm"),
peer_practice_id: str = Form(""),
peer_user_id: str = Form(""),
):
"""Laedt einen Bild-Anhang hoch (pending bis zur ersten zugehoerigen Nachricht)."""
s = _session_or_shell_identity(request)
pid = (s.get("practice_id") or "").strip()
uid = (s.get("user_id") or "").strip()
if not pid or not uid:
raise HTTPException(status_code=401, detail="Nicht angemeldet")
sc = (scope or "").strip().lower()
if sc not in ("internal_dm", "external_dm"):
raise HTTPException(status_code=400, detail="Ungueltiger scope")
mime = _normalize_upload_mime(file.content_type or "", file.filename or "")
if not mime:
raise HTTPException(status_code=400, detail="Dateityp nicht erlaubt")
peer_p = (peer_practice_id or "").strip()
peer_u = (peer_user_id or "").strip()
if sc == "external_dm":
_external_dm_authorize_send_direction(pid, uid, peer_p, peer_u)
_ensure_attachments_dir()
aid = _generate_attachment_id()
dest = _ATTACHMENTS_DIR / f"{aid}.bin"
safe_name = _safe_attachment_basename(file.filename)
sz = 0
try:
with open(dest, "wb") as outf:
while True:
chunk = await file.read(1024 * 1024)
if not chunk:
break
sz += len(chunk)
if sz > _ATTACHMENT_MAX_BYTES:
raise HTTPException(status_code=400, detail="Datei zu gross")
outf.write(chunk)
except HTTPException:
try:
dest.unlink(missing_ok=True)
except Exception:
pass
raise
except Exception:
try:
dest.unlink(missing_ok=True)
except Exception:
pass
raise HTTPException(status_code=500, detail="Speichern fehlgeschlagen")
now = _utc_now_iso_z()
store = _load_attachment_meta_store()
bucket = store.setdefault("attachments", {})
rec: dict = {
"id": aid,
"scope": sc,
"practice_id": pid,
"user_id": uid,
"status": "pending",
"original_name": safe_name,
"mime_type": mime,
"size": sz,
"created_at": now,
}
if sc == "external_dm":
rec["peer_practice_id"] = peer_p
rec["peer_user_id"] = peer_u
bucket[aid] = rec
store["attachments"] = bucket
_save_attachment_meta_store(store)
return JSONResponse(
content={
"success": True,
"attachment_id": aid,
"mime_type": mime,
"size": sz,
"name": safe_name,
},
headers={"Cache-Control": "no-store"},
)
@router.get("/attachments/{attachment_id}/file")
async def empfang_attachment_get_file(attachment_id: str, request: Request):
"""Liefert Dateiinhalt nur mit passender Session/Berechtigung."""
s = _session_or_shell_identity(request)
my_pid = (s.get("practice_id") or "").strip()
my_uid = (s.get("user_id") or "").strip()
if not my_pid or not my_uid:
raise HTTPException(status_code=401, detail="Nicht angemeldet")
aid = (attachment_id or "").strip()
rec = _attachment_meta_get(aid)
if not rec:
raise HTTPException(status_code=404, detail="Nicht gefunden")
if not _attachment_can_view(rec, my_pid, my_uid):
raise HTTPException(status_code=403, detail="Kein Zugriff")
path = _ATTACHMENTS_DIR / f"{aid}.bin"
if not path.is_file():
raise HTTPException(status_code=404, detail="Nicht gefunden")
return FileResponse(
path,
media_type=str(rec.get("mime_type") or "application/octet-stream"),
filename=str(rec.get("original_name") or "bild"),
headers={"Cache-Control": "private, no-store"},
)
@router.post("/external-contacts/request-practice")
async def empfang_external_contacts_request_practice(request: Request):
"""Praxis-zu-Praxis-Verbindung via CHAT-Code (Code = beidseitige Genehmigung).
Body: ``{"code": "CHAT-...-...", "note": "...optional..."}``
Wird intern auf den bestehenden practice-link-Pfad gemappt
(contact_type=external_practice, status=accepted).
"""
s = _session_or_shell_identity(request)
source_pid = (s.get("practice_id") or "").strip()
source_uid = (s.get("user_id") or "").strip()
if not source_pid or not source_uid:
raise HTTPException(status_code=400, detail="Session unvollstaendig")
try:
body = await request.json()
except Exception:
body = {}
if not isinstance(body, dict):
body = {}
raw_code = (body.get("code") or body.get("invite_code") or "").strip()
note = (body.get("note") or "")[:500]
if not raw_code:
raise HTTPException(status_code=400, detail="code erforderlich")
target_pid = _lookup_practice_id_by_invite(raw_code)
if not target_pid:
raise HTTPException(status_code=404, detail="Code ist keiner Praxis zugeordnet oder bereits abgelaufen.")
if target_pid == source_pid:
raise HTTPException(status_code=400, detail="Sie koennen Ihre eigene Praxis nicht als externen Kontakt hinzufuegen.")
practices = _load_practices()
src_name = str((practices.get(source_pid) or {}).get("name") or "").strip()
tgt_name = str((practices.get(target_pid) or {}).get("name") or "").strip()
store = _load_practice_links_store()
existing = None
for link in store.get("links") or []:
if not isinstance(link, dict):
continue
a = link.get("source_practice_id"); b = link.get("target_practice_id")
if {a, b} == {source_pid, target_pid} and _link_contact_type(link) == _CONTACT_TYPE_PRACTICE:
existing = link
break
now = _now_z()
if existing is not None:
existing["contact_type"] = _CONTACT_TYPE_PRACTICE
existing["status"] = "accepted"
existing["updated_at"] = now
if not existing.get("invite_code_used"):
existing["invite_code_used"] = raw_code
if note and not existing.get("note"):
existing["note"] = note
if existing.get("source_practice_id") == source_pid:
existing["source_practice_name"] = src_name
existing["target_practice_name"] = tgt_name
else:
existing["source_practice_name"] = tgt_name
existing["target_practice_name"] = src_name
_persist_link_update(existing["id"], existing)
out = existing
else:
out = {
"id": _generate_practice_link_id(),
"contact_type": _CONTACT_TYPE_PRACTICE,
"source_practice_id": source_pid,
"target_practice_id": target_pid,
"source_practice_name": src_name,
"target_practice_name": tgt_name,
"status": "accepted",
"requested_by_user_id": source_uid,
"created_by_user_id": source_uid,
"created_at": now,
"updated_at": now,
"invite_code_used": raw_code,
"note": note,
}
store.setdefault("links", []).append(out)
_save_practice_links_store(store)
_log.info(
"AZA_EMPFANG_EXTCONT_PRACTICE source=%s target=%s status=%s",
(source_pid or "")[:16], (target_pid or "")[:16], out["status"],
)
return JSONResponse(content={
"success": True,
"external_contact": _serialize_external_contact_for(source_pid, source_uid, out),
})
@router.post("/external-contacts/request-person")
async def empfang_external_contacts_request_person(request: Request):
"""Persoenliche 1:1-externe Kontaktanfrage an einen konkreten Benutzer.
Body: ``{"code": "CHAT-...", "target_user_id": "...", "note": "..."}``
- code: CHAT-Code der **Zielpraxis** (nur Mandanten-Schutz, keine
Praxisverknuepfung).
- target_user_id: verpflichtend — Benutzer-ID des Empfaengers in
der Zielpraxis (spaeter: persoenlicher Einladungscode-Token).
- note: optional.
``contact_type=external_person``, status ``pending_outgoing`` bis der
Zielbenutzer annimmt.
"""
s = _session_or_shell_identity(request)
source_pid = (s.get("practice_id") or "").strip()
source_uid = (s.get("user_id") or "").strip()
source_dn = (s.get("display_name") or "").strip()
if not source_pid or not source_uid:
raise HTTPException(status_code=400, detail="Session unvollstaendig")
try:
body = await request.json()
except Exception:
body = {}
if not isinstance(body, dict):
body = {}
raw_code = (body.get("code") or body.get("invite_code") or "").strip()
note = (body.get("note") or "")[:500]
target_user_id = (
body.get("target_user_id") or body.get("target_user") or ""
).strip()
if not raw_code:
raise HTTPException(status_code=400, detail="code erforderlich")
if not target_user_id:
raise HTTPException(
status_code=400,
detail=(
"target_user_id erforderlich: persoenliche Kontakte sind nur "
"personenbezogen. Ohne Ziel-Benutzer-ID bitte "
"«Externe Praxis verbinden» nutzen oder einen persoenlichen "
"Einladungslink (Folgeausbaustufe)."
),
)
target_pid = _lookup_practice_id_by_invite(raw_code)
if not target_pid:
raise HTTPException(status_code=404, detail="Code ist keiner Praxis zugeordnet oder bereits abgelaufen.")
if target_pid == source_pid:
raise HTTPException(status_code=400, detail="Sie koennen sich nicht als externen Kontakt Ihrer eigenen Praxis anfragen.")
tgt_acc = _account_record_for_practice(target_user_id, target_pid)
if not tgt_acc or not _account_is_sendable(tgt_acc):
raise HTTPException(
status_code=404,
detail="Zielbenutzer in dieser Praxis nicht gefunden oder nicht erreichbar.",
)
practices = _load_practices()
src_name = str((practices.get(source_pid) or {}).get("name") or "").strip()
tgt_name = str((practices.get(target_pid) or {}).get("name") or "").strip()
if not source_dn:
try:
accounts = _load_accounts()
acc = accounts.get(source_uid) or {}
source_dn = str(acc.get("display_name") or "").strip() or "Externer Benutzer"
except Exception:
source_dn = "Externer Benutzer"
target_display_name = str(tgt_acc.get("display_name") or "").strip()
store = _load_practice_links_store()
existing = None
for link in store.get("links") or []:
if not isinstance(link, dict):
continue
if _link_contact_type(link) != _CONTACT_TYPE_PERSON:
continue
if link.get("source_practice_id") == source_pid \
and link.get("target_practice_id") == target_pid \
and link.get("source_user_id") == source_uid \
and (link.get("target_user_id") or "").strip() == target_user_id \
and link.get("status") not in ("removed", "rejected"):
existing = link
break
now = _now_z()
if existing is not None:
existing["contact_type"] = _CONTACT_TYPE_PERSON
existing["status"] = "pending_outgoing"
existing["updated_at"] = now
existing["source_display_name"] = source_dn
existing["target_user_id"] = target_user_id
existing["target_display_name"] = target_display_name
if note:
existing["note"] = note
existing["source_practice_name"] = src_name
existing["target_practice_name"] = tgt_name
if not existing.get("invite_code_used"):
existing["invite_code_used"] = raw_code
_persist_link_update(existing["id"], existing)
out = existing
else:
out = {
"id": _generate_practice_link_id(),
"contact_type": _CONTACT_TYPE_PERSON,
"source_practice_id": source_pid,
"target_practice_id": target_pid,
"source_user_id": source_uid,
"source_display_name": source_dn,
"target_user_id": target_user_id,
"target_display_name": target_display_name,
"source_practice_name": src_name,
"target_practice_name": tgt_name,
"status": "pending_outgoing",
"requested_by_user_id": source_uid,
"created_by_user_id": source_uid,
"created_at": now,
"updated_at": now,
"invite_code_used": raw_code,
"note": note,
}
store.setdefault("links", []).append(out)
_save_practice_links_store(store)
_log.info(
"AZA_EMPFANG_EXTCONT_PERSON_REQ source=%s target=%s tgtuser=%s status=%s",
(source_pid or "")[:16],
(target_pid or "")[:16],
(target_user_id or "")[:12],
out["status"],
)
return JSONResponse(content={
"success": True,
"external_contact": _serialize_external_contact_for(source_pid, source_uid, out),
})
@router.post("/external-contacts/{link_id}/accept")
async def empfang_external_contacts_accept(link_id: str, request: Request):
"""Akzeptiert eine eingehende externe Anfrage.
- external_practice: Administrator der Zielpraxis.
- external_person: nur der adressierte Zielbenutzer (target_user_id);
Legacy ohne target_user_id: Administrator der Zielpraxis.
"""
s = _require_session(request)
pid = (s.get("practice_id") or "").strip()
uid = (s.get("user_id") or "").strip()
if not pid or not uid:
raise HTTPException(status_code=400, detail="Session unvollstaendig")
is_adm = _is_admin_session(s)
link = _find_practice_link_for_user(link_id, pid, uid, is_adm)
if not link:
raise HTTPException(status_code=404, detail="Verbindung nicht gefunden")
if link.get("target_practice_id") != pid:
raise HTTPException(status_code=403, detail="Nur die Zielpraxis darf Anfragen genehmigen.")
ctype = _link_contact_type(link)
if ctype == _CONTACT_TYPE_PRACTICE:
if not is_adm:
raise HTTPException(status_code=403, detail="Nur Administratoren der Zielpraxis duerfen Anfragen akzeptieren.")
elif ctype == _CONTACT_TYPE_PERSON:
if not _person_may_moderate_incoming(link, uid, is_adm):
raise HTTPException(
status_code=403,
detail="Nur der adressierte Benutzer darf diese persoenliche Anfrage annehmen.",
)
else:
raise HTTPException(status_code=400, detail="Unbekannter Kontakttyp")
link["status"] = "accepted"
link["approved_by_user_id"] = uid
link["accepted_by_user_id"] = uid
link["updated_at"] = _now_z()
_persist_link_update(link_id, link)
_log.info(
"AZA_EMPFANG_EXTCONT_ACCEPTED practice=%s link=%s type=%s",
(pid or "")[:16], (link_id or "")[:16], ctype,
)
return JSONResponse(content={
"success": True,
"external_contact": _serialize_external_contact_for(pid, uid, link),
})
@router.post("/external-contacts/{link_id}/reject")
async def empfang_external_contacts_reject(link_id: str, request: Request):
"""Lehnt eine externe Anfrage ab (Praxis: Admin; Person: Zielbenutzer)."""
s = _require_session(request)
pid = (s.get("practice_id") or "").strip()
uid = (s.get("user_id") or "").strip()
if not pid or not uid:
raise HTTPException(status_code=400, detail="Session unvollstaendig")
is_adm = _is_admin_session(s)
link = _find_practice_link_for_user(link_id, pid, uid, is_adm)
if not link:
raise HTTPException(status_code=404, detail="Verbindung nicht gefunden")
if link.get("target_practice_id") != pid:
raise HTTPException(status_code=403, detail="Nur die Zielpraxis darf Anfragen ablehnen.")
ctype = _link_contact_type(link)
if ctype == _CONTACT_TYPE_PRACTICE:
if not is_adm:
raise HTTPException(status_code=403, detail="Nur Administratoren der Zielpraxis duerfen Anfragen ablehnen.")
elif ctype == _CONTACT_TYPE_PERSON:
if not _person_may_moderate_incoming(link, uid, is_adm):
raise HTTPException(status_code=403, detail="Keine Berechtigung fuer diese persoenliche Anfrage.")
else:
raise HTTPException(status_code=400, detail="Unbekannter Kontakttyp")
link["status"] = "rejected"
link["approved_by_user_id"] = uid
link["updated_at"] = _now_z()
_persist_link_update(link_id, link)
_log.info(
"AZA_EMPFANG_EXTCONT_REJECTED practice=%s link=%s type=%s",
(pid or "")[:16], (link_id or "")[:16], ctype,
)
return JSONResponse(content={
"success": True,
"external_contact": _serialize_external_contact_for(pid, uid, link),
})
@router.post("/external-contacts/{link_id}/block")
async def empfang_external_contacts_block(link_id: str, request: Request):
"""Blockiert eine externe Verbindung (Praxis: Admin; Person: Zielbenutzer)."""
s = _require_session(request)
pid = (s.get("practice_id") or "").strip()
uid = (s.get("user_id") or "").strip()
if not pid or not uid:
raise HTTPException(status_code=400, detail="Session unvollstaendig")
is_adm = _is_admin_session(s)
link = _find_practice_link_for_user(link_id, pid, uid, is_adm)
if not link:
raise HTTPException(status_code=404, detail="Verbindung nicht gefunden")
if link.get("target_practice_id") != pid:
raise HTTPException(status_code=403, detail="Nur die Zielpraxis darf Verbindungen blockieren.")
ctype = _link_contact_type(link)
if ctype == _CONTACT_TYPE_PRACTICE:
if not is_adm:
raise HTTPException(status_code=403, detail="Nur Administratoren duerfen blockieren.")
elif ctype == _CONTACT_TYPE_PERSON:
if not _person_may_moderate_incoming(link, uid, is_adm):
raise HTTPException(status_code=403, detail="Keine Berechtigung.")
else:
raise HTTPException(status_code=400, detail="Unbekannter Kontakttyp")
link["status"] = "blocked"
link["approved_by_user_id"] = uid
link["blocked_by_practice_id"] = pid
link["updated_at"] = _now_z()
_persist_link_update(link_id, link)
_log.info(
"AZA_EMPFANG_EXTCONT_BLOCKED practice=%s link=%s type=%s",
(pid or "")[:16], (link_id or "")[:16], ctype,
)
return JSONResponse(content={
"success": True,
"external_contact": _serialize_external_contact_for(pid, uid, link),
})
@router.delete("/external-contacts/{link_id}")
async def empfang_external_contacts_remove(link_id: str, request: Request):
"""Entfernt die externe Verbindung aus eigener Sicht (status=removed).
Persoenliche Kontakte: nur die beiden beteiligten Benutzer (bzw. Legacy-Admin).
Praxis-zu-Praxis: jedes Mitglied der beteiligten Praxis.
"""
s = _require_session(request)
pid = (s.get("practice_id") or "").strip()
uid = (s.get("user_id") or "").strip()
if not pid or not uid:
raise HTTPException(status_code=400, detail="Session unvollstaendig")
is_adm = _is_admin_session(s)
link = _find_practice_link_for_user(link_id, pid, uid, is_adm)
if not link:
return JSONResponse(content={"success": True, "removed": 0})
if not _person_link_may_remove(link, pid, uid, is_adm):
raise HTTPException(status_code=403, detail="Keine Berechtigung zum Entfernen dieser Verbindung.")
link["status"] = "removed"
link["updated_at"] = _now_z()
_persist_link_update(link_id, link)
_log.info(
"AZA_EMPFANG_EXTCONT_REMOVED practice=%s link=%s type=%s",
(pid or "")[:16], (link_id or "")[:16], _link_contact_type(link),
)
return JSONResponse(content={"success": True, "removed": 1})
# =====================================================================
# Admin-Verwaltung pro Praxis (license-anchored, mit Last-Admin-Schutz)
# =====================================================================
#
# Sicherheitsmodell:
# - Adminrolle ist striktes practice-Property. Ein Admin von Praxis A
# ist NICHT automatisch Admin in Praxis B.
# - Der allererste Admin entsteht ueber das Hauptprogramm bei der
# Lizenzaktivierung (siehe auth/provision: bootstrap_admin_allowed).
# - Spaetere Admins werden nur durch einen bereits autorisierten
# Admin (rolle=admin in derselben practice_id) gesetzt oder
# entzogen. Ausnahme: ist eine Praxis komplett adminlos, kann ein
# Mitglied dieser Praxis sich selbst ueber /repair-no-admin als
# Admin festlegen ("Self-Repair", aber NUR in der eigenen Praxis).
# - Externe Kontakte werden niemals als lokale Admins gefuehrt:
# accounts.json enthaelt nur lokale Benutzer; externe Personen sind
# in practice_links.json gespeichert und nie Teil von accounts.
# - Letzter Admin kann nicht entzogen werden (HTTP 400). Ein anderer
# Admin muss erst gesetzt werden.
_ADMIN_SOURCE_LICENSE = "license_activation"
_ADMIN_SOURCE_MANUAL = "manual_admin_assignment"
_ADMIN_SOURCE_REPAIR = "manual_admin_assignment" # gleicher Quelltext im Audit
_ADMIN_SOURCE_LEGACY = "legacy"
_ADMIN_SOURCE_LICENSE_JOIN_EXISTING = "license_join_existing"
def _account_has_practice_admin_privileges(acc: Optional[dict]) -> bool:
"""Praxis-Administrator im Sinn der Admin-APIs (Rolle admin oder Arzt mit Office-Lizenz-Beitritt)."""
if not isinstance(acc, dict):
return False
r = (acc.get("role") or "").strip().lower()
if r == "admin":
return True
if r == "arzt" and (acc.get("admin_source") or "").strip() == _ADMIN_SOURCE_LICENSE_JOIN_EXISTING:
return True
return False
def _public_account_for_admin_ui(acc: dict) -> dict:
"""Pro Account ein kompakter Diagnose-/Admin-UI-Datensatz.
Es werden KEINE Passwort-Hashes oder sensiblen Felder preisgegeben.
`admin_source` wird mit "legacy" befuellt, wenn der Account Admin
ist, aber keine explizite Quelle gespeichert hat (historische Daten).
"""
role = (acc.get("role") or "").strip()
src_stored = (acc.get("admin_source") or "").strip()
if role == "admin" and not src_stored:
src_stored = _ADMIN_SOURCE_LEGACY
ap = _account_has_practice_admin_privileges(acc)
src_out = ""
if ap:
src_out = (acc.get("admin_source") or "").strip()
if role.lower() == "admin" and not src_out:
src_out = _ADMIN_SOURCE_LEGACY
return {
"user_id": str(acc.get("user_id") or ""),
"display_name": str(acc.get("display_name") or ""),
"login_name": str(acc.get("login_name") or ""),
"email": str(acc.get("email") or ""),
"role": role,
"status": str(acc.get("status") or "active"),
"admin_source": src_out,
"created": str(acc.get("created") or ""),
"last_login": str(acc.get("last_login") or ""),
}
def _admins_for_practice(accounts: dict, pid: str) -> list:
return [
a for a in accounts.values()
if a.get("practice_id") == pid
and _account_has_practice_admin_privileges(a)
and (a.get("status") or "active") != "deactivated"
]
def _members_for_practice(accounts: dict, pid: str) -> list:
return [a for a in accounts.values() if a.get("practice_id") == pid]
def _require_self_practice_admin(request: Request) -> dict:
"""Liefert die Session, wenn sie Admin-Rolle in der eigenen Praxis hat."""
s = _require_session(request)
if not _is_admin_session(s):
raise HTTPException(status_code=403, detail="Nur Administratoren der eigenen Praxis duerfen diese Aktion ausfuehren.")
return s
@router.get("/admin/diagnosis")
async def empfang_admin_diagnosis(request: Request):
"""Diagnose-Sicht der EIGENEN Praxis fuer den Profil-UI.
Erfordert eine bestehende Session; jeder Benutzer der Praxis darf
seine eigene Praxis-Diagnose lesen (auch MPA/Arzt). Es werden NUR
nicht-sensible Felder ausgegeben (keine Passwoerter, keine Patienten-
daten, keine externen Kontakte).
"""
s = _require_session(request)
pid = (s.get("practice_id") or "").strip()
if not pid:
raise HTTPException(status_code=400, detail="Session unvollstaendig")
practices = _load_practices()
pdata = practices.get(pid) or {}
accounts = _load_accounts()
members = _members_for_practice(accounts, pid)
members.sort(
key=lambda a: (
not _account_has_practice_admin_privileges(a),
str(a.get("display_name") or "").lower(),
),
)
admins = _admins_for_practice(accounts, pid)
return JSONResponse(content={
"success": True,
"practice": {
"practice_id": pid,
"practice_name": str(pdata.get("name") or "").strip(),
"admin_email_on_practice": str(pdata.get("admin_email") or "").strip(),
"created": str(pdata.get("created") or ""),
},
"has_admin": bool(admins),
"admin_count": len(admins),
"members": [_public_account_for_admin_ui(a) for a in members],
"own_user_id": str(s.get("user_id") or ""),
"own_role": str(s.get("role") or ""),
})
@router.get("/admin/admins")
async def empfang_admin_admins(request: Request):
"""Kurzliste der Admins der eigenen Praxis. Fuer Header/Quick-Info."""
s = _require_session(request)
pid = (s.get("practice_id") or "").strip()
if not pid:
raise HTTPException(status_code=400, detail="Session unvollstaendig")
accounts = _load_accounts()
admins = _admins_for_practice(accounts, pid)
return JSONResponse(content={
"success": True,
"admins": [_public_account_for_admin_ui(a) for a in admins],
})
@router.post("/admin/set-admin/{user_id}")
async def empfang_admin_set_admin(user_id: str, request: Request):
"""Bestehender Admin der Praxis setzt einen anderen Benutzer derselben
Praxis ebenfalls als Admin. Externe Kontakte (sind nicht in accounts)
sind automatisch ausgeschlossen.
"""
s = _require_self_practice_admin(request)
pid = (s.get("practice_id") or "").strip()
accounts = _load_accounts()
target = accounts.get(user_id)
if not target or target.get("practice_id") != pid:
raise HTTPException(status_code=404, detail="Benutzer nicht in dieser Praxis gefunden")
if (target.get("status") or "active") == "deactivated":
raise HTTPException(status_code=400, detail="Deaktivierte Konten koennen nicht zum Admin gemacht werden.")
if _account_has_practice_admin_privileges(target):
return JSONResponse(content={
"success": True,
"user": _public_account_for_admin_ui(target),
"no_op": True,
})
target["role"] = "admin"
target["admin_source"] = _ADMIN_SOURCE_MANUAL
target["admin_assigned_by_user_id"] = str(s.get("user_id") or "")
target["admin_assigned_at"] = _now_z()
accounts[user_id] = target
_save_accounts(accounts)
_log.info(
"AZA_EMPFANG_ADMIN_SET practice=%s by=%s target=%s",
(pid or "")[:16], (s.get("user_id") or "")[:16], (user_id or "")[:16],
)
return JSONResponse(content={
"success": True,
"user": _public_account_for_admin_ui(target),
})
@router.post("/admin/revoke-admin/{user_id}")
async def empfang_admin_revoke_admin(user_id: str, request: Request):
"""Adminrechte eines Benutzers in der eigenen Praxis entziehen.
Last-Admin-Schutz: wenn der Zielbenutzer der letzte verbleibende
Admin der Praxis waere, wird mit HTTP 400 abgelehnt.
"""
s = _require_self_practice_admin(request)
pid = (s.get("practice_id") or "").strip()
accounts = _load_accounts()
target = accounts.get(user_id)
if not target or target.get("practice_id") != pid:
raise HTTPException(status_code=404, detail="Benutzer nicht in dieser Praxis gefunden")
if not _account_has_practice_admin_privileges(target):
return JSONResponse(content={
"success": True,
"user": _public_account_for_admin_ui(target),
"no_op": True,
})
admins_now = _admins_for_practice(accounts, pid)
other_admins = [a for a in admins_now if a.get("user_id") != user_id]
if not other_admins:
raise HTTPException(
status_code=400,
detail="Letzter Admin kann nicht entzogen werden. Bitte zuerst einen anderen Benutzer als Admin festlegen.",
)
target["role"] = "arzt"
target["admin_source"] = ""
target["admin_revoked_by_user_id"] = str(s.get("user_id") or "")
target["admin_revoked_at"] = _now_z()
accounts[user_id] = target
_save_accounts(accounts)
_log.info(
"AZA_EMPFANG_ADMIN_REVOKE practice=%s by=%s target=%s",
(pid or "")[:16], (s.get("user_id") or "")[:16], (user_id or "")[:16],
)
return JSONResponse(content={
"success": True,
"user": _public_account_for_admin_ui(target),
})
@router.post("/admin/repair-no-admin")
async def empfang_admin_repair_no_admin(request: Request):
"""Self-Repair: Wenn die Praxis aktuell KEINEN Admin hat, darf
der aktuell angemeldete Benutzer sich selbst als Admin der eigenen
Praxis festlegen. Wenn bereits ein Admin existiert, wird mit
HTTP 400 abgelehnt (dann muss /set-admin durch den Admin verwendet
werden).
"""
s = _require_session(request)
pid = (s.get("practice_id") or "").strip()
uid = (s.get("user_id") or "").strip()
if not pid or not uid:
raise HTTPException(status_code=400, detail="Session unvollstaendig")
accounts = _load_accounts()
admins_now = _admins_for_practice(accounts, pid)
if admins_now:
raise HTTPException(
status_code=400,
detail=(
"Diese Praxis hat bereits einen Administrator. Bitte den "
"bestehenden Administrator bitten, Sie zum Administrator zu "
"machen."
),
)
target = accounts.get(uid)
if not target or target.get("practice_id") != pid:
raise HTTPException(status_code=403, detail="Benutzer gehoert nicht zur eigenen Praxis.")
if (target.get("status") or "active") == "deactivated":
raise HTTPException(status_code=400, detail="Deaktivierte Konten koennen sich nicht zum Admin machen.")
target["role"] = "admin"
target["admin_source"] = _ADMIN_SOURCE_REPAIR
target["admin_assigned_at"] = _now_z()
accounts[uid] = target
_save_accounts(accounts)
_log.info(
"AZA_EMPFANG_ADMIN_SELF_REPAIR practice=%s user=%s",
(pid or "")[:16], (uid or "")[:16],
)
return JSONResponse(content={
"success": True,
"user": _public_account_for_admin_ui(target),
})
@router.post("/auth/regenerate_invite")
async def auth_regenerate_invite(request: Request):
"""Erzeugt einen neuen Chat-Einladungscode (Admin-Session oder API-Token)."""
api_token = request.headers.get("X-API-Token", "")
s = _session_from_request(request)
if s:
if not _is_admin_session(s):
raise HTTPException(status_code=403, detail="Nur Admin darf Einladungscode erneuern")
pid = s["practice_id"]
elif api_token:
pid = request.headers.get("X-Practice-Id", "").strip()
if not pid:
raise HTTPException(status_code=400, detail="X-Practice-Id Header erforderlich")
else:
raise HTTPException(status_code=401, detail="Nicht authentifiziert")
_ensure_practice(pid)
practices = _load_practices()
if pid in practices:
practices[pid]["invite_code"] = _generate_chat_invite_code()
_save_practices(practices)
return JSONResponse(content={
"success": True,
"invite_code": practices.get(pid, {}).get("invite_code", ""),
})
def _auth_provision_profile_attach(
practices: dict, pid: str, account: Optional[dict],
) -> dict:
"""Lesbare Profil-Metadaten fuer Desktop-Sync (keine Secrets)."""
p = practices.get(pid) or {}
out: dict = {
"practice_name": str(p.get("name") or "").strip(),
"practice_specialty": str(p.get("specialty") or "").strip(),
"practice_phone": str(p.get("phone") or "").strip(),
"practice_contact_email": str(p.get("contact_email") or "").strip(),
}
try:
from stripe_routes import lookup_license_email_for_practice
lm = (lookup_license_email_for_practice(pid) or "").strip()
if lm:
out["license_customer_email"] = lm
except Exception:
pass
if account and isinstance(account, dict):
out["account_display_name"] = str(account.get("display_name") or "").strip()
out["account_email"] = str(account.get("email") or "").strip()
out["account_role"] = str(account.get("role") or "").strip()
out["account_specialty"] = str(
account.get("specialty") or account.get("desktop_specialty") or "",
).strip()
out["account_title"] = str(
account.get("title") or account.get("desktop_title") or "",
).strip()
out["account_job_function"] = str(
account.get("job_function") or account.get("function") or "",
).strip()
return out
def _weak_practice_public_name(name: str) -> bool:
s = " ".join((name or "").strip().lower().split())
return (not s) or s in frozenset({"meine praxis"}) or len(s) < 2
def _practice_profile_public_shape(p: dict, pid: str) -> dict:
return {
"practice_id": pid,
"name": str(p.get("name") or "").strip(),
"specialty": str(p.get("specialty") or "").strip(),
"phone": str(p.get("phone") or "").strip(),
"address": str(p.get("address") or "").strip(),
"website": str(p.get("website") or "").strip(),
"contact_email": str(p.get("contact_email") or "").strip(),
"admin_email": str(p.get("admin_email") or "").strip(),
"profile_updated_at": str(p.get("profile_updated_at") or "").strip(),
"profile_updated_by_user_id": str(
p.get("profile_updated_by_user_id") or "",
).strip(),
}
def _user_profile_public_shape(acc: dict) -> dict:
return {
"user_id": str(acc.get("user_id") or "").strip(),
"display_name": str(acc.get("display_name") or "").strip(),
"title": str(acc.get("title") or "").strip(),
"role": str(acc.get("role") or "").strip(),
"email": str(acc.get("email") or "").strip(),
"job_function": str(
acc.get("job_function") or acc.get("function") or "",
).strip(),
"specialty_user": str(acc.get("specialty") or "").strip(),
"profile_updated_at": str(acc.get("profile_updated_at") or "").strip(),
}
def _practice_profile_warnings_payload(
practice: dict,
user: Optional[dict],
license_customer_email: str,
) -> list[str]:
w: list[str] = []
nm = str(practice.get("name") or "").strip()
if _weak_practice_public_name(nm):
w.append("Praxisname fehlt oder ist nur Platzhalter")
if not str(practice.get("specialty") or "").strip():
w.append("Fachrichtung (Praxis) ist nicht hinterlegt")
if not str(practice.get("admin_email") or "").strip():
w.append("Admin-E-Mail der Praxis fehlt")
if isinstance(user, dict) and user:
if not str(user.get("display_name") or "").strip():
w.append("Benutzerprofil: Anzeigename fehlt")
if not str(user.get("email") or "").strip():
w.append("Benutzerprofil: E-Mail fehlt")
if not (
str(user.get("specialty_user") or "").strip()
or str(user.get("title") or "").strip()
):
w.append("Benutzerprofil: Titel oder Fachrichtung fehlt")
if (license_customer_email or "").strip() and _weak_practice_public_name(nm):
w.append(
"Lizenz-E-Mail ist bekannt, aber das Praxisprofil wirkt unvollstaendig",
)
return w
def _entitlements_from_lookup_key(lookup_key: Optional[str]) -> tuple[bool, bool]:
"""office_allowed, chat_allowed — Rueckwaertscompat: ohne Marker = Office+Chat.
Explizite Chat-only-Produkte erkennen wir nur an klar erkennbaren
lookup_key-Mustern (keineHeuristik aus Namen).
"""
lk = (lookup_key or "").strip().lower()
if not lk:
return True, True
chat_only_markers = (
"chat_only", "chat-only", "aza_chat_only", "empfang_only",
"minichat_only", "chatonly",
)
for m in chat_only_markers:
if m in lk:
return False, True
return True, True
def internal_license_join_existing_practice_account(
*,
target_practice_id: str,
name: str,
email: str,
password: str,
assign_admin: bool,
license_customer_email: str,
body: dict,
) -> dict:
"""Server-intern (ohne Empfang-API-Token): Konto in **bestehender** Praxis.
Wird von ``POST /license/join_existing_practice`` nach erfolgreicher
Lizenz-Anbindung aufgerufen. Legt keine neue Praxis an und benennt
bestehende Praxisdaten nicht um.
"""
target_practice_id = (target_practice_id or "").strip()
if not target_practice_id:
raise HTTPException(status_code=400, detail="Ziel-practice_id fehlt")
nm = (name or "").strip()
em = (email or "").strip()
pw = (password or "").strip()
if not nm or not em or not pw or len(pw) < 4:
raise HTTPException(
status_code=400,
detail="Name, E-Mail und Passwort (min. 4 Zeichen) erforderlich",
)
lce = (license_customer_email or "").strip().lower()
if lce and em.lower() != lce:
raise HTTPException(
status_code=403,
detail="E-Mail stimmt nicht mit der Lizenz-Kundenadresse ueberein.",
)
practices = _load_practices()
if target_practice_id not in practices:
raise HTTPException(
status_code=404,
detail="Praxis zum Einladungscode nicht gefunden oder nicht provisioniert.",
)
# Nur sicherstellen, dass Datensatz existiert (ohne Namen zu ueberschreiben).
_ensure_practice(target_practice_id)
accounts = _load_accounts()
scoped_pr = [a for a in accounts.values() if a.get("practice_id") == target_practice_id]
target = None
email_lower = em.lower() if em else ""
if email_lower:
email_matches = [
a for a in scoped_pr
if (a.get("email") or "").strip().lower() == email_lower
]
if len(email_matches) == 1:
target = email_matches[0]
if target is None:
for a in scoped_pr:
if _normalize_login_username(a.get("display_name") or "") == _normalize_login_username(nm):
target = a
break
if target:
pw_hash, pw_salt = _hash_password(pw)
target["pw_hash"] = pw_hash
target["pw_salt"] = pw_salt
if em:
target["email"] = em
incoming_dn = nm
cur_dn_existing = (target.get("display_name") or "").strip()
if incoming_dn and not cur_dn_existing:
target["display_name"] = incoming_dn
if not (target.get("login_name") or "").strip():
target["login_name"] = _preferred_unique_login_for_display(
accounts, target_practice_id, nm, str(target.get("user_id") or ""),
)
ds = " ".join(
(body.get("desktop_specialty") or body.get("specialty") or "").strip().split()
)
dt = " ".join(
(body.get("desktop_title") or body.get("title") or "").strip().split()
)
if ds and len(ds) <= 160 and not (str(target.get("specialty") or "").strip()):
target["specialty"] = ds
if dt and len(dt) <= 80 and not (str(target.get("title") or "").strip()):
target["title"] = dt
if assign_admin:
r0 = (target.get("role") or "").strip().lower()
if r0 != "admin":
target["role"] = "arzt"
target["admin_source"] = _ADMIN_SOURCE_LICENSE_JOIN_EXISTING
_save_accounts(accounts)
practices_snap = _load_practices()
out: dict = {
"success": True,
"user_id": target["user_id"],
"display_name": target.get("display_name"),
"role": target.get("role"),
"admin": _account_has_practice_admin_privileges(target),
"practice_id": target_practice_id,
"action": "updated",
}
out.update(_auth_provision_profile_attach(practices_snap, target_practice_id, target))
return out
role = "arzt"
admin_source = _ADMIN_SOURCE_LICENSE_JOIN_EXISTING if assign_admin else ""
uid = uuid.uuid4().hex[:12]
pw_hash, pw_salt = _hash_password(pw)
ln_pv = _preferred_unique_login_for_display(accounts, target_practice_id, nm, "")
new_account = {
"user_id": uid,
"practice_id": target_practice_id,
"display_name": nm,
"email": em,
"login_name": ln_pv,
"role": role,
"pw_hash": pw_hash,
"pw_salt": pw_salt,
"status": "active",
"created": time.strftime("%Y-%m-%d %H:%M:%S"),
}
ds2 = " ".join(
(body.get("desktop_specialty") or body.get("specialty") or "").strip().split()
)
dt2 = " ".join(
(body.get("desktop_title") or body.get("title") or "").strip().split()
)
if ds2 and len(ds2) <= 160:
new_account["specialty"] = ds2
if dt2 and len(dt2) <= 80:
new_account["title"] = dt2
if admin_source:
new_account["admin_source"] = admin_source
accounts[uid] = new_account
_save_accounts(accounts)
_log.info(
"AZA_EMPFANG_LICENSE_JOIN_ACCOUNT practice=%s uid=%s role=%s",
(target_practice_id or "")[:16], (uid or "")[:16], role,
)
practices_snap = _load_practices()
acc_ref = accounts.get(uid)
out2: dict = {
"success": True,
"user_id": uid,
"display_name": nm,
"role": role,
"admin": _account_has_practice_admin_privileges(acc_ref),
"practice_id": target_practice_id,
"action": "created",
}
out2.update(_auth_provision_profile_attach(practices_snap, target_practice_id, acc_ref))
return out2
@router.post("/auth/provision")
async def auth_provision(request: Request):
"""Provisioning: Desktop-App erstellt/findet Server-Account.
Authentifiziert via X-API-Token (Backend-Token), nicht via Session.
Erstellt bei Bedarf eine neue Praxis mit echter practice_id."""
api_token = request.headers.get("X-API-Token", "")
if not api_token:
raise HTTPException(status_code=401, detail="API-Token erforderlich")
try:
body = await request.json()
except Exception:
body = {}
name = (body.get("name") or "").strip()
email = (body.get("email") or "").strip()
password = (body.get("password") or "").strip()
practice_name = (body.get("practice_name") or "").strip()
invite_code_in = (body.get("invite_code") or "").strip()
if not name:
raise HTTPException(status_code=400, detail="Name erforderlich")
if not password or len(password) < 4:
raise HTTPException(status_code=400, detail="Passwort (min. 4 Zeichen) erforderlich")
pid = ""
# Bewusster Beitritt zu einem bestehenden Praxis-Chat per Einladungscode:
# ueberschreibt X-Practice-Id / practice_id im Body — sonst legt jedes neue
# Geraet ohne gespeicherte practice_id eine eigene Praxis an (Realbefund).
if invite_code_in:
practices = _load_practices()
want = _invite_code_key(invite_code_in)
target_pid = None
for pida, pdata in practices.items():
if _invite_code_key(pdata.get("invite_code")) == want:
target_pid = pida
break
if not target_pid:
raise HTTPException(
status_code=403,
detail="Ungueltiger Chat-Einladungscode — Praxis nicht gefunden.",
)
pid = target_pid
else:
pid = request.headers.get("X-Practice-Id", "").strip()
pid = pid or (body.get("practice_id") or "").strip()
resolved_from_license = False
resolved_practice_name = ""
if not pid:
license_key_in = (body.get("license_key") or "").strip()
cand_list: list[str] = []
seen_c: set[str] = set()
try:
from stripe_routes import (
list_distinct_practice_ids_for_license_email,
list_distinct_practice_ids_for_license_key,
)
for p in list_distinct_practice_ids_for_license_email(email):
p = (p or "").strip()
if p and p not in seen_c:
seen_c.add(p)
cand_list.append(p)
for p in list_distinct_practice_ids_for_license_key(license_key_in):
p = (p or "").strip()
if p and p not in seen_c:
seen_c.add(p)
cand_list.append(p)
except Exception as exc:
print(f"[EMPFANG] license practice resolution: {exc}")
if len(cand_list) > 1:
practices = _load_practices()
candidates_payload = []
for p in cand_list:
pdata = practices.get(p) or {}
pname = (pdata.get("name") or "").strip() or p
candidates_payload.append({"practice_id": p, "practice_name": pname})
return JSONResponse(content={
"success": False,
"step": "choose_practice",
"message": (
"Mehrere Praxen passen zu dieser Lizenz bzw. E-Mail. "
"Bitte waehlen Sie die Praxis aus, der dieses Geraet zugehoeren soll."
),
"candidates": candidates_payload,
})
if len(cand_list) == 1:
pid = cand_list[0]
resolved_from_license = True
practices = _load_practices()
pdata = practices.get(pid) or {}
resolved_practice_name = (pdata.get("name") or "").strip()
if not pid:
practices = _load_practices()
has_legacy = _LEGACY_DEFAULT_PID in practices
accounts = _load_accounts()
has_legacy_accounts = any(
a.get("practice_id") == _LEGACY_DEFAULT_PID for a in accounts.values())
if has_legacy or has_legacy_accounts:
pid = _generate_practice_id()
_migrate_legacy_to_practice(pid)
else:
pid = _generate_practice_id()
practice = _ensure_practice(pid, name=practice_name or "Meine Praxis",
admin_email=email)
if practice_name or email:
practices = _load_practices()
raw_entry = practices.get(pid)
entry = dict(raw_entry) if isinstance(raw_entry, dict) else {}
entry["practice_id"] = pid
changed_pr = False
pnm = " ".join((practice_name or "").strip().split())
cur_nm = str(entry.get("name") or "").strip()
if pnm and _weak_practice_public_name(cur_nm) and not _weak_practice_public_name(pnm):
entry["name"] = pnm[:240]
changed_pr = True
elif pnm and not _weak_practice_public_name(cur_nm) and pnm != cur_nm:
# Bewusste Umbenennung gehoert in PATCH /practice/profile, nicht in Provision.
pass
if email:
prev_ae = str(entry.get("admin_email") or "").strip()
if not prev_ae:
entry["admin_email"] = email.strip()[:240]
changed_pr = True
if changed_pr:
practices[pid] = entry
_save_practices(practices)
accounts = _load_accounts()
target = None
scoped_pr = [a for a in accounts.values() if a.get("practice_id") == pid]
email_lower = email.lower() if email else ""
if email_lower:
email_matches = [
a for a in scoped_pr
if (a.get("email") or "").strip().lower() == email_lower
]
if len(email_matches) == 1:
target = email_matches[0]
if target is None:
for a in scoped_pr:
if _normalize_login_username(a.get("display_name") or "") == _normalize_login_username(name):
target = a
break
if target:
pw_hash, pw_salt = _hash_password(password)
target["pw_hash"] = pw_hash
target["pw_salt"] = pw_salt
if email:
target["email"] = email
incoming_dn = (name or "").strip()
cur_dn_existing = (target.get("display_name") or "").strip()
if incoming_dn and not cur_dn_existing:
target["display_name"] = incoming_dn
if not (target.get("login_name") or "").strip():
target["login_name"] = _preferred_unique_login_for_display(
accounts, pid, name, str(target.get("user_id") or ""),
)
ds = " ".join(
(body.get("desktop_specialty") or body.get("specialty") or "").strip().split()
)
dt = " ".join(
(body.get("desktop_title") or body.get("title") or "").strip().split()
)
if ds and len(ds) <= 160 and not (str(target.get("specialty") or "").strip()):
target["specialty"] = ds
if dt and len(dt) <= 80 and not (str(target.get("title") or "").strip()):
target["title"] = dt
ps_practice = " ".join(
(
body.get("practice_specialty")
or body.get("desktop_practice_specialty")
or body.get("desktop_specialty")
or body.get("specialty")
or ""
)
.strip()
.split()
)
if ps_practice and len(ps_practice) <= 160:
practices_mut = _load_practices()
ex = practices_mut.get(pid)
if isinstance(ex, dict) and not (str(ex.get("specialty") or "").strip()):
ex2 = dict(ex)
ex2["specialty"] = ps_practice
practices_mut[pid] = ex2
_save_practices(practices_mut)
# STRENGE Admin-Safety bei bestehendem Account in der Zielpraxis:
# Ein bereits existierendes Konto soll NICHT mehr stillschweigend zum
# Admin promoviert werden, nur weil aktuell kein Admin in der Praxis
# existiert. Auto-Admin gilt ausschliesslich beim erstmaligen Bootstrap
# einer neuen Praxis ueber Lizenzaktivierung (siehe Block unten beim
# erstmaligen Anlegen eines Accounts). Adminlose Praxen werden ueber
# /empfang/admin/repair-no-admin oder /empfang/admin/set-admin
# nachtraeglich versorgt.
_save_accounts(accounts)
practices_snap = _load_practices()
body_out = {
"success": True, "user_id": target["user_id"],
"display_name": target["display_name"], "role": target["role"],
"practice_id": pid,
"action": "updated",
}
body_out.update(_auth_provision_profile_attach(practices_snap, pid, target))
if resolved_from_license:
body_out["resolved_existing_practice"] = True
if resolved_practice_name:
body_out["resolved_practice_name"] = resolved_practice_name
return JSONResponse(content=body_out)
# STRENGE Auto-Admin-Regel bei NEU angelegtem Account:
# Admin nur, wenn ALLE Bedingungen erfuellt sind:
# (1) Die Praxis hat aktuell KEINEN einzigen Account
# (echter Bootstrap, nicht nur "kein Admin").
# (2) KEIN invite_code im Request (Beitritts-Pfad ausgeschlossen).
# (3) license_key wurde mitgeschickt -> bindet den Auto-Admin an
# die Lizenzaktivierung des Hauptprogramms.
# Andernfalls bekommt der neue Account `role="arzt"` (nicht Admin).
# Adminlose Praxen koennen ueber /empfang/admin/repair-no-admin oder
# /empfang/admin/set-admin durch einen bestehenden Admin (oder durch
# den ersten legitimen Benutzer) versorgt werden.
has_admin = any(
_account_has_practice_admin_privileges(a) and a.get("practice_id") == pid
for a in accounts.values()
)
has_any_account_in_practice = any(
a.get("practice_id") == pid for a in accounts.values()
)
license_key_provided = bool((body.get("license_key") or "").strip())
bootstrap_admin_allowed = (
not has_any_account_in_practice
and not invite_code_in
and license_key_provided
)
if bootstrap_admin_allowed:
role = "admin"
admin_source = "license_activation"
else:
role = "arzt"
admin_source = ""
uid = uuid.uuid4().hex[:12]
pw_hash, pw_salt = _hash_password(password)
ln_pv = _preferred_unique_login_for_display(accounts, pid, name, "")
new_account = {
"user_id": uid,
"practice_id": pid,
"display_name": name,
"email": email,
"login_name": ln_pv,
"role": role,
"pw_hash": pw_hash,
"pw_salt": pw_salt,
"status": "active",
"created": time.strftime("%Y-%m-%d %H:%M:%S"),
}
ds = " ".join(
(body.get("desktop_specialty") or body.get("specialty") or "").strip().split()
)
dt = " ".join(
(body.get("desktop_title") or body.get("title") or "").strip().split()
)
if ds and len(ds) <= 160:
new_account["specialty"] = ds
if dt and len(dt) <= 80:
new_account["title"] = dt
if admin_source:
new_account["admin_source"] = admin_source
accounts[uid] = new_account
_save_accounts(accounts)
if bootstrap_admin_allowed:
_log.info(
"AZA_EMPFANG_ADMIN_BOOTSTRAP practice=%s uid=%s source=license_activation has_admin_before=%s",
(pid or "")[:16], (uid or "")[:16], int(has_admin),
)
else:
_log.info(
"AZA_EMPFANG_ACCOUNT_CREATED practice=%s uid=%s role=%s "
"has_admin=%s had_accounts=%s invite=%s license=%s",
(pid or "")[:16], (uid or "")[:16], role,
int(has_admin), int(has_any_account_in_practice),
int(bool(invite_code_in)), int(license_key_provided),
)
body_created = {
"success": True, "user_id": uid,
"display_name": name, "role": role,
"practice_id": pid,
"action": "created",
}
practices_snap = _load_practices()
acc_ref = accounts.get(uid)
body_created.update(_auth_provision_profile_attach(practices_snap, pid, acc_ref))
if resolved_from_license:
body_created["resolved_existing_practice"] = True
if resolved_practice_name:
body_created["resolved_practice_name"] = resolved_practice_name
return JSONResponse(content=body_created)
@router.post("/auth/forgot_password")
async def auth_forgot_password(request: Request):
"""Passwort-Reset mit Benutzername oder E-Mail; kein automatisches Konten-Waehlen bei gleicher E-Mail."""
try:
body = await request.json()
except Exception:
body = {}
raw = (body.get("login") or body.get("email") or body.get("name") or "").strip()
pid = _practice_id_from_client(request, body)
if not raw:
raise HTTPException(
status_code=400, detail="Benutzername oder E-Mail erforderlich",
)
accounts = _load_accounts()
ambiguous_email_body = {
"success": False,
"step": "ambiguous_email",
"message": (
"Diese E-Mail ist mehreren Benutzern zugeordnet. "
"Geben Sie bitte Ihren Benutzernamen ein, damit das richtige Konto eindeutig ist."
),
"reset_token_created": False,
"target_email_masked": "",
"mail_delivered": False,
"attempted_delivery": False,
}
if _is_likely_email(raw):
em = _norm_email(raw)
if pid:
scoped = [a for a in accounts.values() if a.get("practice_id") == pid]
matches = [
a for a in scoped
if _norm_email(a.get("email") or "") == em
]
else:
matches = [
a for a in accounts.values()
if _norm_email(a.get("email") or "") == em
]
if len(matches) == 0:
return JSONResponse(content=_forgot_password_neutral_payload())
if len(matches) == 1:
return JSONResponse(content=_send_reset_for_account(matches[0]))
return JSONResponse(content=ambiguous_email_body)
if pid:
scoped = [a for a in accounts.values() if a.get("practice_id") == pid]
matches, _via_forgot = _resolve_browser_login_matches(scoped, raw)
else:
matches, _via_forgot = _resolve_browser_login_matches(
list(accounts.values()), raw,
)
if len(matches) == 0:
return JSONResponse(content=_forgot_password_neutral_payload())
if len(matches) > 1:
if pid:
msg = (
"Dieser Benutzername ist in dieser Praxis nicht eindeutig. "
"Bitte verwenden Sie Ihren eindeutigen Login-Namen oder bitten Sie den "
"Administrator im Hauptfenster, fuer die Konten eindeutige Login-Namen zu setzen."
)
step_code = "ambiguous_username_in_practice"
else:
msg = (
"Dieser Benutzername ist ohne gespeicherte Praxis mehrdeutig. "
"Bitte laden Sie die Seite ueber den Einladungslink der Hauptinstallation "
"oder geben Sie Ihre gemeinschaftliche E-Mail ein, wenn sie nur einem Konto gilt."
)
step_code = "ambiguous_login_no_practice"
return JSONResponse(
content={
"success": False,
"step": step_code,
"message": msg,
"reset_token_created": False,
"target_email_masked": "",
"mail_delivered": False,
"attempted_delivery": False,
},
)
return JSONResponse(content=_send_reset_for_account(matches[0]))
@router.get("/auth/reset_verify")
async def auth_reset_verify(reset_token: str = Query("")):
"""Prüft, ob ein Reset-Token noch gültig ist (ohne Verbrauch)."""
token = (reset_token or "").strip()
if not token:
return JSONResponse(
content={"valid": False, "detail": "Kein Reset-Token angegeben."}
)
resets = _load_json(_DATA_DIR / "empfang_resets.json", {})
entry = resets.get(token)
if not entry:
return JSONResponse(
content={"valid": False, "detail": "Ungültiger oder abgelaufener Link."}
)
if time.time() - entry.get("created", 0) > RESET_LINK_TTL_SEC:
return JSONResponse(
content={
"valid": False,
"detail": "Der Link ist abgelaufen. Bitte neuen Reset anfordern.",
}
)
email_raw = (entry.get("email") or "").strip()
accounts = _load_accounts()
uid = entry.get("user_id")
acc = accounts.get(uid) if uid else None
display_name = (
entry.get("display_name")
or (acc or {}).get("display_name")
or ""
).strip()
return JSONResponse(
content={
"valid": True,
"display_name": display_name,
"email_masked": _mask_email_for_response(email_raw),
},
)
@router.post("/auth/reset_password")
async def auth_reset_password(request: Request):
"""Setzt das Passwort mit einem gültigen Reset-Token."""
try:
body = await request.json()
except Exception:
body = {}
token = (body.get("reset_token") or "").strip()
new_password = (body.get("password") or "").strip()
if not token or not new_password or len(new_password) < 4:
raise HTTPException(status_code=400,
detail="Reset-Token und neues Passwort (min. 4 Zeichen) erforderlich")
resets = _load_json(_DATA_DIR / "empfang_resets.json", {})
entry = resets.get(token)
if not entry:
raise HTTPException(
status_code=400, detail="Ungültiger oder abgelaufener Reset-Link"
)
if time.time() - entry.get("created", 0) > RESET_LINK_TTL_SEC:
del resets[token]
_save_json(_DATA_DIR / "empfang_resets.json", resets)
raise HTTPException(
status_code=400, detail="Reset-Link ist abgelaufen"
)
user_id = entry["user_id"]
accounts = _load_accounts()
if user_id not in accounts:
raise HTTPException(status_code=404, detail="Benutzer nicht gefunden")
pw_hash, pw_salt = _hash_password(new_password)
accounts[user_id]["pw_hash"] = pw_hash
accounts[user_id]["pw_salt"] = pw_salt
accounts[user_id].pop("must_change_password", None)
_save_accounts(accounts)
del resets[token]
_save_json(_DATA_DIR / "empfang_resets.json", resets)
acc = accounts[user_id]
dn_hint = (acc.get("display_name") or "").strip()
ln_saved = (acc.get("login_name") or "").strip()
em_raw = (acc.get("email") or "").strip()
return JSONResponse(
content={
"success": True,
"message": "Passwort wurde erfolgreich geändert.",
"display_name": dn_hint,
"login_name": ln_saved,
"email_masked": _mask_email_for_response(em_raw),
}
)
def _reset_email_subject_body(display_name: str, reset_link: str) -> Tuple[str, str, str]:
"""Betreff, Plain-Text, HTML für Passwort-Reset."""
subject = "AZA Praxis-Chat Passwort zurücksetzen"
text = (
f"Hallo {display_name},\n\n"
f"Sie haben eine Passwort-Zurücksetzung angefordert.\n\n"
f"Klicken Sie auf diesen Link, um Ihr Passwort neu zu setzen:\n"
f"{reset_link}\n\n"
f"Der Link ist 1 Stunde gültig.\n\n"
f"Falls Sie diese Anfrage nicht gestellt haben, ignorieren Sie diese E-Mail.\n\n"
f"AZA Praxis-Chat"
)
html = (
f"<div style='font-family:Segoe UI,sans-serif;max-width:480px;margin:0 auto'>"
f"<h2 style='color:#5B8DB3'>Passwort zurücksetzen</h2>"
f"<p>Hallo {display_name},</p>"
f"<p>Sie haben eine Passwort-Zurücksetzung angefordert.</p>"
f"<p><a href='{reset_link}' style='display:inline-block;background:#5B8DB3;"
f"color:white;padding:10px 24px;border-radius:6px;text-decoration:none;"
f"font-weight:600'>Neues Passwort wählen</a></p>"
f"<p style='color:#888;font-size:13px'>Der Link ist 1 Stunde gültig.</p>"
f"<p style='color:#888;font-size:12px'>Falls Sie diese Anfrage nicht gestellt haben, "
f"ignorieren Sie diese E-Mail.</p></div>"
)
return subject, text, html
def _send_reset_via_resend(to_email: str, subject: str, text: str, html: str) -> bool:
"""Resend HTTP API (gleiche Umgebung wie Lizenz-Mail in stripe_routes)."""
import json
import urllib.error
import urllib.request
api_key = os.environ.get("RESEND_API_KEY", "").strip()
sender = os.environ.get("MAIL_FROM", "AZA MedWork <noreply@aza-medwork.ch>").strip()
if not api_key:
return False
payload = json.dumps({
"from": sender,
"to": [to_email],
"subject": subject,
"html": html,
"text": text,
}).encode("utf-8")
req = urllib.request.Request(
"https://api.resend.com/emails",
data=payload,
headers={
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
"User-Agent": "AZA-MedWork/1.0",
},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=15) as resp:
if resp.status in (200, 201):
mask = _mask_email_for_response(to_email)
print(f"[RESET-MAIL] Resend OK -> {mask or '(addr)'}")
return True
body = resp.read().decode()[:300]
print(f"[RESET-MAIL] Resend HTTP {resp.status}: {body}")
return False
except urllib.error.HTTPError as exc:
body = exc.read().decode("utf-8", errors="replace")[:300] if exc.fp else ""
print(f"[RESET-MAIL] Resend HTTP {exc.code}: {body}")
return False
except Exception as exc:
print(f"[RESET-MAIL] Resend {type(exc).__name__}: {exc}")
return False
def _send_reset_email(to_email: str, display_name: str, reset_link: str) -> bool:
"""Sendet Passwort-Reset: zuerst SMTP (falls vollstaendig), sonst Resend API."""
import smtplib
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
subject, text, html = _reset_email_subject_body(display_name, reset_link)
host = os.environ.get("SMTP_HOST", "").strip()
port_str = os.environ.get("SMTP_PORT", "587").strip()
user = os.environ.get("SMTP_USER", "").strip()
password = os.environ.get("SMTP_PASS", "").strip()
sender = os.environ.get("SMTP_FROM", "").strip() or user
if all([host, user, password]):
try:
msg = MIMEMultipart("alternative")
msg["From"] = sender
msg["To"] = to_email
msg["Subject"] = subject
msg.attach(MIMEText(text, "plain", "utf-8"))
msg.attach(MIMEText(html, "html", "utf-8"))
port = int(port_str)
if port == 465:
with smtplib.SMTP_SSL(host, port, timeout=15) as srv:
srv.login(user, password)
srv.sendmail(sender, [to_email], msg.as_string())
else:
with smtplib.SMTP(host, port, timeout=15) as srv:
srv.ehlo()
srv.starttls()
srv.ehlo()
srv.login(user, password)
srv.sendmail(sender, [to_email], msg.as_string())
mask = _mask_email_for_response(to_email)
print(f"[RESET-MAIL] SMTP OK -> {mask or '(addr)'}")
return True
except Exception as exc:
print(f"[RESET-MAIL] SMTP FEHLER: {exc} versuche Resend …")
if _send_reset_via_resend(to_email, subject, text, html):
return True
print(
"[RESET-MAIL] Weder SMTP noch Resend erfolgreich. "
"Setzen Sie SMTP_HOST/SMTP_USER/SMTP_PASS oder RESEND_API_KEY (+ MAIL_FROM). "
"Reset-Link wurde nicht verschickt (Token im Fehlerpfad entsorgt)."
)
return False
@router.get("/auth/needs_setup")
async def auth_needs_setup(request: Request):
"""Pruefen ob Setup noetig ist (keine Accounts vorhanden)."""
pid = _resolve_practice_id(request)
if not pid:
accounts = _load_accounts()
return JSONResponse(content={
"needs_setup": len(accounts) == 0,
"invite_code": "",
})
_ensure_practice(pid)
accounts = _load_accounts()
has_accounts = any(a.get("practice_id") == pid for a in accounts.values())
practices = _load_practices()
invite_code = practices.get(pid, {}).get("invite_code", "")
return JSONResponse(content={
"needs_setup": not has_accounts,
"invite_code": invite_code if not has_accounts else "",
})
# =====================================================================
# ADMIN ENDPOINTS (nur Rolle admin)
# =====================================================================
def _require_admin(request: Request) -> dict:
s = _require_session(request)
if not _is_admin_session(s):
raise HTTPException(status_code=403, detail="Admin-Berechtigung erforderlich")
return s
@router.get("/admin/users")
async def admin_list_users(request: Request):
"""Alle Benutzer der Praxis mit vollen Details."""
s = _require_admin(request)
pid = s["practice_id"]
accounts = _load_accounts()
result = []
for a in accounts.values():
if a.get("practice_id") != pid:
continue
result.append({
"user_id": a["user_id"],
"display_name": a["display_name"],
"role": a.get("role", "mpa"),
"status": a.get("status", "active"),
"created": a.get("created", ""),
"last_login": a.get("last_login", ""),
"email": a.get("email", ""),
})
return JSONResponse(content={"success": True, "users": result})
@router.post("/admin/users/{user_id}/role")
async def admin_change_role(user_id: str, request: Request):
"""Rolle eines Benutzers aendern.
Zusaetzliche Sicherheit:
- Last-Admin-Schutz: wenn der Zielbenutzer aktuell Admin ist und auf
eine Nicht-Admin-Rolle gesetzt werden soll, prueft der Server, ob
danach noch mindestens ein Admin in derselben Praxis verbleibt.
- Beim Heraufstufen zu admin wird admin_source="manual_admin_assignment"
vermerkt; beim Herabstufen wird admin_source geloescht.
"""
s = _require_admin(request)
try:
body = await request.json()
except Exception:
body = {}
new_role = (body.get("role") or "").strip()
if not new_role:
raise HTTPException(status_code=400, detail="Rolle erforderlich")
if new_role not in ("admin", "arzt", "mpa", "empfang"):
raise HTTPException(status_code=400, detail="Ungueltige Rolle")
if user_id == s["user_id"] and new_role != "admin":
raise HTTPException(status_code=400,
detail="Eigene Admin-Rolle kann nicht entfernt werden")
accounts = _load_accounts()
if user_id not in accounts:
raise HTTPException(status_code=404, detail="Benutzer nicht gefunden")
target = accounts[user_id]
if target.get("practice_id") != s["practice_id"]:
raise HTTPException(status_code=403, detail="Benutzer gehoert zu anderer Praxis")
pid = s["practice_id"]
was_admin = _account_has_practice_admin_privileges(target)
will_be_admin = (new_role == "admin")
if was_admin and not will_be_admin:
admins_now = _admins_for_practice(accounts, pid)
other_admins = [a for a in admins_now if a.get("user_id") != user_id]
if not other_admins:
raise HTTPException(
status_code=400,
detail=(
"Letzter Admin kann nicht entzogen werden. Bitte zuerst "
"einen anderen Benutzer als Admin festlegen."
),
)
target["role"] = new_role
if will_be_admin and not was_admin:
target["admin_source"] = _ADMIN_SOURCE_MANUAL
target["admin_assigned_by_user_id"] = str(s.get("user_id") or "")
target["admin_assigned_at"] = _now_z()
elif was_admin and not will_be_admin:
target["admin_source"] = ""
target["admin_revoked_by_user_id"] = str(s.get("user_id") or "")
target["admin_revoked_at"] = _now_z()
accounts[user_id] = target
_save_accounts(accounts)
_log.info(
"AZA_EMPFANG_ROLE_CHANGE practice=%s by=%s target=%s role=%s",
(pid or "")[:16], (s.get("user_id") or "")[:16],
(user_id or "")[:16], new_role,
)
return JSONResponse(content={"success": True, "user_id": user_id, "role": new_role})
@router.post("/admin/users/{user_id}/deactivate")
async def admin_deactivate_user(user_id: str, request: Request):
"""Benutzer deaktivieren und alle Sessions loeschen."""
s = _require_admin(request)
if user_id == s["user_id"]:
raise HTTPException(status_code=400, detail="Eigenes Konto kann nicht deaktiviert werden")
accounts = _load_accounts()
if user_id not in accounts:
raise HTTPException(status_code=404, detail="Benutzer nicht gefunden")
if accounts[user_id].get("practice_id") != s["practice_id"]:
raise HTTPException(status_code=403, detail="Benutzer gehoert zu anderer Praxis")
accounts[user_id]["status"] = "deactivated"
_save_accounts(accounts)
sessions = _load_sessions()
sessions = {k: v for k, v in sessions.items() if v.get("user_id") != user_id}
_save_sessions(sessions)
return JSONResponse(content={"success": True, "user_id": user_id, "status": "deactivated"})
@router.post("/admin/users/{user_id}/activate")
async def admin_activate_user(user_id: str, request: Request):
"""Benutzer reaktivieren."""
s = _require_admin(request)
accounts = _load_accounts()
if user_id not in accounts:
raise HTTPException(status_code=404, detail="Benutzer nicht gefunden")
if accounts[user_id].get("practice_id") != s["practice_id"]:
raise HTTPException(status_code=403, detail="Benutzer gehoert zu anderer Praxis")
accounts[user_id]["status"] = "active"
_save_accounts(accounts)
return JSONResponse(content={"success": True, "user_id": user_id, "status": "active"})
@router.delete("/admin/users/{user_id}")
async def admin_delete_user(user_id: str, request: Request):
"""Benutzer permanent loeschen inkl. Sessions und Geraete."""
s = _require_admin(request)
if user_id == s["user_id"]:
raise HTTPException(status_code=400, detail="Eigenes Konto kann nicht geloescht werden")
accounts = _load_accounts()
if user_id not in accounts:
raise HTTPException(status_code=404, detail="Benutzer nicht gefunden")
if accounts[user_id].get("practice_id") != s["practice_id"]:
raise HTTPException(status_code=403, detail="Benutzer gehoert zu anderer Praxis")
acc_del = accounts.get(user_id)
if acc_del and _account_has_practice_admin_privileges(acc_del):
other_adm = [
a for a in accounts.values()
if a.get("practice_id") == s["practice_id"]
and a.get("user_id") != user_id
and _account_has_practice_admin_privileges(a)
]
if not other_adm:
raise HTTPException(
status_code=400,
detail="Letzter Administrator kann nicht geloescht werden.",
)
del accounts[user_id]
_save_accounts(accounts)
sessions = _load_sessions()
sessions = {k: v for k, v in sessions.items() if v.get("user_id") != user_id}
_save_sessions(sessions)
devices = _load_devices()
devices = {k: v for k, v in devices.items() if v.get("user_id") != user_id}
_save_devices(devices)
return JSONResponse(content={"success": True, "deleted": user_id})
@router.post("/admin/users/{user_id}/reset_password")
async def admin_reset_password(user_id: str, request: Request):
"""Setzt das Passwort fuer einen Benutzer (Passwort wird im Request mitgeliefert, nur gehasht gespeichert)."""
s = _require_admin(request)
try:
body = await request.json()
except Exception:
body = {}
new_pw = (body.get("new_password") or body.get("password") or "").strip()
if len(new_pw) < 4:
raise HTTPException(
status_code=400,
detail="Neues Passwort ist erforderlich (mindestens 4 Zeichen)",
)
pid = s["practice_id"]
accounts = _load_accounts()
if user_id not in accounts:
raise HTTPException(status_code=404, detail="Benutzer nicht gefunden")
if accounts[user_id].get("practice_id") != pid:
raise HTTPException(status_code=403, detail="Benutzer gehoert zu anderer Praxis")
pw_hash, pw_salt = _hash_password(new_pw)
accounts[user_id]["pw_hash"] = pw_hash
accounts[user_id]["pw_salt"] = pw_salt
accounts[user_id].pop("must_change_password", None)
_save_accounts(accounts)
return JSONResponse(
content={"success": True, "user_id": user_id, "message": "Passwort gespeichert"}
)
@router.get("/admin/devices")
async def admin_list_devices(request: Request):
"""Alle Geraete aller Benutzer der Praxis."""
s = _require_admin(request)
pid = s["practice_id"]
devices = _load_devices()
accounts = _load_accounts()
user_names = {a["user_id"]: a["display_name"] for a in accounts.values()}
result = []
for d in devices.values():
if d.get("practice_id") != pid:
continue
entry = dict(d)
entry["user_name"] = user_names.get(d.get("user_id"), d.get("user_id", ""))
result.append(entry)
result.sort(key=lambda d: d.get("last_active", ""), reverse=True)
return JSONResponse(content={"success": True, "devices": result})
@router.post("/admin/devices/{device_id}/block")
async def admin_block_device(device_id: str, request: Request):
"""Geraet blockieren und zugehoerige Sessions loeschen."""
s = _require_admin(request)
devices = _load_devices()
if device_id not in devices:
raise HTTPException(status_code=404, detail="Geraet nicht gefunden")
dev = devices[device_id]
if dev.get("practice_id") != s["practice_id"]:
raise HTTPException(status_code=403, detail="Geraet gehoert zu anderer Praxis")
dev["trust_status"] = "blocked"
_save_devices(devices)
sessions = _load_sessions()
sessions = {k: v for k, v in sessions.items()
if v.get("device_id") != device_id}
_save_sessions(sessions)
return JSONResponse(content={"success": True, "device_id": device_id, "trust_status": "blocked"})
@router.delete("/admin/devices/{device_id}")
async def admin_delete_device(device_id: str, request: Request):
"""Geraetedatensatz loeschen."""
s = _require_admin(request)
devices = _load_devices()
if device_id not in devices:
raise HTTPException(status_code=404, detail="Geraet nicht gefunden")
if devices[device_id].get("practice_id") != s["practice_id"]:
raise HTTPException(status_code=403, detail="Geraet gehoert zu anderer Praxis")
del devices[device_id]
_save_devices(devices)
return JSONResponse(content={"success": True, "deleted": device_id})
@router.post("/admin/users/{user_id}/logout_all")
async def admin_logout_all(user_id: str, request: Request):
"""Alle Sessions eines Benutzers loeschen."""
s = _require_admin(request)
accounts = _load_accounts()
if user_id not in accounts:
raise HTTPException(status_code=404, detail="Benutzer nicht gefunden")
if accounts[user_id].get("practice_id") != s["practice_id"]:
raise HTTPException(status_code=403, detail="Benutzer gehoert zu anderer Praxis")
sessions = _load_sessions()
removed = sum(1 for v in sessions.values() if v.get("user_id") == user_id)
sessions = {k: v for k, v in sessions.items() if v.get("user_id") != user_id}
_save_sessions(sessions)
return JSONResponse(content={"success": True, "user_id": user_id, "sessions_removed": removed})
# =====================================================================
# USER MANAGEMENT (admin only for invite/role changes)
# =====================================================================
def _attach_devices_to_practice_users(
users: list, pid: str, *, include_device_identity: bool = False,
) -> None:
"""Last aktiv / Presence pro Benutzer; identisch zum frueheren API-Token-Zweig."""
devices = _load_devices()
user_devices: dict[str, list] = {}
for d in devices.values():
if d.get("practice_id") != pid:
continue
uid = d.get("user_id", "")
entry = {
"device_name": d.get("device_name", ""),
"platform": d.get("platform", ""),
"last_active": d.get("last_active", ""),
"ip_last": d.get("ip_last", ""),
}
if include_device_identity and d.get("device_id"):
entry["device_suffix"] = str(d.get("device_id"))[-8:]
user_devices.setdefault(uid, []).append(entry)
for u in users:
u["devices"] = user_devices.get(u.get("user_id", ""), [])
def _attach_presence_to_practice_users(users: list, pid: str) -> None:
"""Reichert users_full mit serverseitiger Session-Presence an (RAM, TTL)."""
pid = (pid or "").strip()
if not pid:
return
for u in users:
if not isinstance(u, dict):
continue
uid = str(u.get("user_id") or "").strip()
snap = _presence_snapshot_for_user(pid, uid)
u["presence_online"] = snap["presence_online"]
u["presence_last_seen"] = snap["presence_last_seen"]
u["presence_source"] = snap["presence_source"]
u["presence_age_seconds"] = snap["presence_age_seconds"]
@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"])
_attach_devices_to_practice_users(users, s["practice_id"])
_attach_presence_to_practice_users(users, s["practice_id"])
return JSONResponse(content={
"users": [u["display_name"] for u in users],
"users_full": users,
"practice_id": s["practice_id"],
})
api_token = request.headers.get("X-API-Token", "")
pid = _resolve_practice_id(request)
if not pid:
return JSONResponse(content={"users": [], "practice_id": ""})
users = _practice_users(pid)
if users:
result: dict = {
"users": [u["display_name"] for u in users],
"practice_id": pid,
}
if api_token:
_attach_devices_to_practice_users(
users, pid, include_device_identity=True,
)
_attach_presence_to_practice_users(users, pid)
result["users_full"] = users
return JSONResponse(content=result)
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 = {}
action = (body.get("action") or "add").strip()
if action == "set_login_name":
uid_tgt = (body.get("user_id") or "").strip()
ln_new = " ".join((body.get("login_name") or "").strip().split())
if not uid_tgt or len(ln_new) < 2:
return JSONResponse(
content={
"success": False,
"detail": "user_id und login_name (mind. 2 Zeichen) erforderlich",
},
status_code=400,
)
pid_auth = _presence_debug_resolve_practice_auth(request)
accounts = _load_accounts()
tgt = accounts.get(uid_tgt)
if not tgt or (tgt.get("practice_id") or "").strip() != pid_auth:
return JSONResponse(
content={"success": False, "detail": "Benutzer nicht gefunden"},
status_code=404,
)
if _login_name_conflicts_existing(accounts, pid_auth, ln_new, uid_tgt):
return JSONResponse(
content={
"success": False,
"detail": "Login-Name in dieser Praxis bereits vergeben",
},
status_code=409,
)
tgt["login_name"] = ln_new
_save_accounts(accounts)
return JSONResponse(
content={"success": True, "user_id": uid_tgt, "login_name": ln_new},
)
if action == "set_password":
uid_tgt = (body.get("user_id") or "").strip()
new_pw = (body.get("new_password") or "").strip()
nw2 = (
body.get("new_password_repeat")
or body.get("password2")
or ""
).strip()
ln_req = " ".join(
(body.get("login_name") or body.get("browser_login_name") or "").strip().split()
)
if not uid_tgt or len(new_pw) < 4:
return JSONResponse(
content={"success": False, "detail": "user_id und Passwort (min. 4) erforderlich"},
status_code=400,
)
if new_pw != nw2:
return JSONResponse(
content={"success": False, "detail": "Passwoerter stimmen nicht ueberein"},
status_code=400,
)
pid_auth = _presence_debug_resolve_practice_auth(request)
accounts = _load_accounts()
tgt = accounts.get(uid_tgt)
if not tgt or (tgt.get("practice_id") or "").strip() != pid_auth:
return JSONResponse(
content={"success": False, "detail": "Benutzer nicht gefunden"},
status_code=404,
)
needs_ln = _needs_distinct_login_name(tgt, accounts, pid_auth)
has_ln = bool((tgt.get("login_name") or "").strip())
if needs_ln and not has_ln and not ln_req:
return JSONResponse(
content={
"success": False,
"detail": (
"Mehrere Konten mit gleichem Anzeigenamen: Bitte zuerst einen "
"eindeutigen login_name setzen (Feld login_name im gleichen Aufruf "
"oder action set_login_name)."
),
},
status_code=400,
)
if ln_req:
if len(ln_req) < 2:
return JSONResponse(
content={"success": False, "detail": "login_name zu kurz"},
status_code=400,
)
prev_nk = _normalize_login_username(
(tgt.get("login_name") or "").strip()
)
req_nk = _normalize_login_username(ln_req)
if prev_nk != req_nk and _login_name_conflicts_existing(
accounts, pid_auth, ln_req, uid_tgt,
):
return JSONResponse(
content={
"success": False,
"detail": "Login-Name in dieser Praxis bereits vergeben",
},
status_code=409,
)
tgt["login_name"] = ln_req
pw_hash, pw_salt = _hash_password(new_pw)
tgt["pw_hash"] = pw_hash
tgt["pw_salt"] = pw_salt
tgt.pop("must_change_password", None)
_save_accounts(accounts)
return JSONResponse(
content={
"success": True,
"user_id": uid_tgt,
"login_name": (tgt.get("login_name") or "").strip(),
},
)
if action == "patch_user":
uid_tgt = (body.get("user_id") or "").strip()
if not uid_tgt:
return JSONResponse(
content={"success": False, "detail": "user_id erforderlich"},
status_code=400,
)
dn_new = " ".join((body.get("display_name") or "").strip().split())
if not dn_new:
return JSONResponse(
content={"success": False, "detail": "display_name erforderlich"},
status_code=400,
)
pid_auth = _require_practice_admin_or_api_token(request)
accounts = _load_accounts()
tgt = accounts.get(uid_tgt)
if not tgt or (tgt.get("practice_id") or "").strip() != pid_auth:
return JSONResponse(
content={"success": False, "detail": "Benutzer nicht gefunden"},
status_code=404,
)
tgt["display_name"] = dn_new
if "login_name" in body:
ln_req = " ".join((body.get("login_name") or "").strip().split())
if len(ln_req) < 2:
return JSONResponse(
content={
"success": False,
"detail": "login_name erforderlich (mind. 2 Zeichen)",
},
status_code=400,
)
prev_nk = _normalize_login_username(
(tgt.get("login_name") or "").strip()
)
req_nk = _normalize_login_username(ln_req)
if prev_nk != req_nk and _login_name_conflicts_existing(
accounts, pid_auth, ln_req, uid_tgt,
):
return JSONResponse(
content={
"success": False,
"detail": "Login-Name in dieser Praxis bereits vergeben",
},
status_code=409,
)
tgt["login_name"] = ln_req
if "email" in body:
raw_em = body.get("email")
tgt["email"] = (raw_em or "").strip() if isinstance(raw_em, str) else ""
if "specialty" in body:
sp = " ".join(str(body.get("specialty") or "").strip().split())
if len(sp) > 160:
return JSONResponse(
content={"success": False, "detail": "specialty max. 160 Zeichen"},
status_code=400,
)
tgt["specialty"] = sp
if "title" in body:
ti = " ".join(str(body.get("title") or "").strip().split())
if len(ti) > 80:
return JSONResponse(
content={"success": False, "detail": "title max. 80 Zeichen"},
status_code=400,
)
tgt["title"] = ti
_save_accounts(accounts)
return JSONResponse(
content={
"success": True,
"user_id": uid_tgt,
"display_name": dn_new,
"login_name": (tgt.get("login_name") or "").strip(),
"email": (tgt.get("email") or "").strip(),
},
)
name = (body.get("name") or "").strip()
pid = _resolve_practice_id(request)
if not pid or not name:
return JSONResponse(content={"success": False})
accounts = _load_accounts()
if action == "delete":
sess = _session_from_request(request)
actor_name = (body.get("actor_display_name") or "").strip()
actor_uid = (body.get("actor_user_id") or "").strip()
target_uid = (body.get("user_id") or "").strip()
# Bevorzugt: gezielte user_id (verhindert versehentliches Loeschen
# gleichnamiger Konten). Fallback: Anzeigename-Gruppierung wie bisher.
if target_uid:
acc = accounts.get(target_uid)
if (
not acc
or (acc.get("practice_id") or "").strip() != pid
):
return JSONResponse(
content={"success": False, "detail": "Benutzer nicht gefunden"},
status_code=404,
)
to_del = [target_uid]
else:
to_del = [
uid for uid, a in accounts.items()
if _normalize_login_username(a.get("display_name") or "") == _normalize_login_username(name)
and a.get("practice_id") == pid
]
if not to_del:
return JSONResponse(content={"success": False, "detail": "Benutzer nicht gefunden"}, status_code=404)
for uid in list(to_del):
acc = accounts.get(uid)
if not acc:
continue
if sess and acc.get("user_id") == sess.get("user_id"):
return JSONResponse(
content={"success": False, "detail": "Eigenes Konto kann nicht geloescht werden"},
status_code=400,
)
# Selbst-Schutz primaer ueber technische user_id (vermeidet, dass
# gleichnamige Konten faelschlich als "eigenes Konto" gelten).
# actor_display_name bleibt nur Fallback, wenn keine actor_user_id
# mitkommt.
if actor_uid:
if str(acc.get("user_id") or "").strip() == actor_uid:
return JSONResponse(
content={"success": False,
"detail": "Eigenes Konto kann nicht geloescht werden"},
status_code=400,
)
elif actor_name and (acc.get("display_name") or "").strip() == actor_name:
return JSONResponse(
content={"success": False,
"detail": "Der aktive Benutzer kann hier nicht geloescht werden"},
status_code=400,
)
if _account_has_practice_admin_privileges(acc):
others = [
a for a in accounts.values()
if a.get("practice_id") == pid
and a.get("user_id") != uid
and _account_has_practice_admin_privileges(a)
]
if not others:
return JSONResponse(
content={"success": False,
"detail": "Letzter Administrator kann nicht geloescht werden"},
status_code=400,
)
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 (
_normalize_login_username(a.get("display_name") or "") == _normalize_login_username(name)
and a.get("practice_id") == pid
):
a["display_name"] = new_name
_save_accounts(accounts)
elif action == "add_secure":
s = _session_from_request(request)
sess_role = ""
pid_ctx = ""
if s:
sess_role = str(s.get("role") or "").strip().lower()
if not (_is_admin_session(s) or sess_role == "empfang"):
raise HTTPException(
status_code=403,
detail="Keine Berechtigung Benutzer anzulegen",
)
pid_ctx = str(s.get("practice_id") or "").strip()
elif (request.headers.get("X-API-Token") or "").strip():
pid_ctx = _presence_debug_resolve_practice_auth(request)
sess_role = "admin"
else:
raise HTTPException(status_code=401, detail="Nicht angemeldet")
if not pid_ctx:
return JSONResponse(content={"success": False, "detail": "Keine Praxis"}, status_code=400)
pid = pid_ctx
pw = (body.get("password") or "").strip()
pw2 = (body.get("password_repeat") or body.get("password2") or "").strip()
if pw != pw2:
return JSONResponse(content={"success": False, "detail": "Passwoerter stimmen nicht ueberein"}, status_code=400)
if len(pw) < 4:
return JSONResponse(content={"success": False, "detail": "Passwort mindestens 4 Zeichen"}, status_code=400)
allowed_roles = {"mpa", "arzt"}
admin_like = (s and _is_admin_session(s)) or sess_role == "admin"
if admin_like:
allowed_roles.update({"admin", "empfang"})
role_new = str(body.get("role") or "mpa").strip().lower()
if role_new not in allowed_roles:
role_new = "mpa"
exists = any(
_normalize_login_username(a.get("display_name") or "") == _normalize_login_username(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(pw)
ln_hint = body.get("login_name") or body.get("browser_login_name")
ln_as = " ".join(((ln_hint or "").strip()).split())
if ln_as:
if len(ln_as) < 2:
return JSONResponse(
content={
"success": False,
"detail": "login_name zu kurz (mind. 2 Zeichen)",
},
status_code=400,
)
if _login_name_conflicts_existing(accounts, pid, ln_as, ""):
return JSONResponse(
content={
"success": False,
"detail": "Login-Name in dieser Praxis bereits vergeben",
},
status_code=409,
)
else:
ln_as = _preferred_unique_login_for_display(accounts, pid, name, "")
accounts[uid] = {
"user_id": uid,
"practice_id": pid,
"display_name": name,
"role": role_new,
"login_name": ln_as,
"pw_hash": pw_hash,
"pw_salt": pw_salt,
"created": time.strftime("%Y-%m-%d %H:%M:%S"),
"status": "active",
"last_login": "",
"email": "",
}
_save_accounts(accounts)
else:
return JSONResponse(content={"success": False, "detail": "Name bereits vergeben"}, status_code=409)
else:
exists = any(
_normalize_login_username(a.get("display_name") or "") == _normalize_login_username(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())
ln_la = _preferred_unique_login_for_display(accounts, pid, name, "")
accounts[uid] = {
"user_id": uid,
"practice_id": pid,
"display_name": name,
"login_name": ln_la,
"role": "mpa",
"pw_hash": pw_hash,
"pw_salt": pw_salt,
"created": time.strftime("%Y-%m-%d %H:%M:%S"),
"status": "active",
"last_login": "",
"email": "",
}
_save_accounts(accounts)
users = _practice_users(pid)
return JSONResponse(content={
"success": True,
"users": [u["display_name"] for u in users],
"practice_id": pid,
})
def _require_practice_admin_or_api_token(request: Request) -> str:
"""Session: nur Admin. API-Token (Desktop) ohne Session gilt als Admin-Kontext."""
pid = _presence_debug_resolve_practice_auth(request)
sess = _session_from_request(request)
if sess and not _is_admin_session(sess):
raise HTTPException(status_code=403, detail="Nur fuer Praxis-Administratoren")
return pid
def _audit_login_identifiers_payload(pid: str) -> dict:
"""Diagnose: keine Hashes/Passwoerter/Tokens — nur Metadaten zur Anmeldung."""
accounts = _load_accounts()
scoped = [a for a in accounts.values() if a.get("practice_id") == pid]
def _row(a: dict) -> dict:
return {
"user_id": a.get("user_id"),
"display_name": a.get("display_name") or "",
"login_name": (a.get("login_name") or "").strip(),
"role": a.get("role") or "",
"status": a.get("status") or "active",
"has_email": bool((a.get("email") or "").strip()),
"has_password_hash": bool((a.get("pw_hash") or "").strip()),
}
by_ln: dict[str, list] = defaultdict(list)
for a in scoped:
ln = (a.get("login_name") or "").strip()
if not ln:
continue
by_ln[_normalize_login_username(ln)].append(_row(a))
dup_ln = [
{"normalized_key": k, "users": v}
for k, v in by_ln.items() if len(v) > 1
]
by_dn: dict[str, list] = defaultdict(list)
for a in scoped:
dnk = _normalize_login_username(a.get("display_name") or "")
if dnk:
by_dn[dnk].append(_row(a))
dup_dn = [
{"normalized_display_key": k, "users": v}
for k, v in by_dn.items() if len(v) > 1
]
missing_ln = [_row(a) for a in scoped if not (a.get("login_name") or "").strip()]
return {
"practice_id": pid,
"duplicate_login_name_groups": dup_ln,
"duplicate_normalized_display_name_groups": dup_dn,
"users_without_login_name": missing_ln,
}
@router.get("/admin/login_identifier_audit")
async def admin_login_identifier_audit(request: Request):
"""Nur Praxis-Admin oder Desktop-API: Dubletten-Analyse Login/Anzeige."""
pid = _require_practice_admin_or_api_token(request)
return JSONResponse(content=_audit_login_identifiers_payload(pid))
@router.get("/admin/forgot_password_probe")
async def admin_forgot_password_probe(request: Request, login: str = Query("")):
"""Diagnose-Endpunkt fuer den Passwort-Reset-Flow.
Auth: Admin-Session oder gueltiges X-API-Token + X-Practice-Id (gleiche Regel
wie ``login_identifier_audit``). Gibt nur strukturelle Statusfelder zurueck:
keine Reset-Tokens, keine vollstaendigen E-Mail-Adressen, keine Passwoerter,
keine Hashes. Es wird **keine** E-Mail gesendet und **kein** Token erzeugt.
"""
pid = _require_practice_admin_or_api_token(request)
raw = (login or "").strip()
if not raw:
return JSONResponse(
content={
"success": False,
"detail": "Query-Parameter 'login' erforderlich",
},
status_code=400,
)
accounts = _load_accounts()
scoped = [a for a in accounts.values() if a.get("practice_id") == pid]
is_email = _is_likely_email(raw)
matches: list = []
match_via = "none"
if is_email:
em = _norm_email(raw)
matches = [
a for a in scoped
if _norm_email(a.get("email") or "") == em
]
match_via = "email" if matches else "none"
else:
matches, match_via = _resolve_browser_login_matches(scoped, raw)
target_source = "none"
has_user_email = False
has_practice_admin_email = False
has_license_email = False
if len(matches) == 1:
acc = matches[0]
has_user_email = bool((acc.get("email") or "").strip())
practices = _load_practices()
pa_email = ((practices.get(pid) or {}).get("admin_email") or "").strip()
has_practice_admin_email = bool(pa_email)
license_email = ""
try:
from stripe_routes import lookup_license_email_for_practice
license_email = (lookup_license_email_for_practice(pid) or "").strip()
except Exception:
license_email = ""
has_license_email = bool(license_email)
if has_user_email:
target_source = "user_email"
elif has_practice_admin_email:
target_source = "practice_admin_email"
elif has_license_email:
target_source = "license_email"
else:
target_source = "none"
smtp_ok = bool(
os.environ.get("SMTP_HOST", "").strip()
and os.environ.get("SMTP_USER", "").strip()
and os.environ.get("SMTP_PASS", "").strip()
)
resend_ok = bool(os.environ.get("RESEND_API_KEY", "").strip())
mail_provider_configured = (
"smtp" if smtp_ok else ("resend" if resend_ok else "none")
)
would_attempt_mail = (
len(matches) == 1
and target_source != "none"
and mail_provider_configured != "none"
)
return JSONResponse(content={
"success": True,
"practice_id": pid,
"input_was_email": is_email,
"match_count": len(matches),
"match_via": match_via,
"target_source": target_source,
"has_user_email": has_user_email,
"has_practice_admin_email": has_practice_admin_email,
"has_license_email": has_license_email,
"mail_provider_configured": mail_provider_configured,
"would_attempt_mail": would_attempt_mail,
"note": (
"Nur strukturelle Diagnose: keine Tokens, keine Mails, "
"keine vollstaendigen Adressen."
),
})
# =====================================================================
# MESSAGE ROUTES (practice-scoped)
# =====================================================================
class EmpfangMessage(BaseModel):
medikamente: str = ""
therapieplan: str = ""
procedere: str = ""
kommentar: str = ""
patient: str = ""
absender: str = ""
zeitstempel: str = ""
practice_id: str = ""
extras: dict = Field(default_factory=dict)
@router.post("/send")
async def empfang_send(msg: EmpfangMessage, request: Request):
s = _session_from_request(request)
pid = msg.practice_id.strip() or _resolve_practice_id(request)
if not pid:
raise HTTPException(status_code=400, detail="practice_id erforderlich")
absender = msg.absender.strip()
if s and not absender:
absender = s["display_name"]
# Strikte Direktchat-Validierung: wenn der Client explizit einen Direktchat
# markiert (audience=direct ODER recipient_user_id gesetzt) MUSS die
# Empfaenger-User-ID auf ein Konto in dieser Praxis aufloesbar sein.
# Verhindert: stille Speicherung als Allgemein, wenn der Browser/Desktop
# einen Direktchat anzeigt.
_ex_in = msg.extras or {}
_aud_in = str(_ex_in.get("audience") or "").strip().lower()
_ru_in = str(_ex_in.get("recipient_user_id") or "").strip()
_r_in = str(_ex_in.get("recipient") or "").strip()
_is_multi_in = (
isinstance(_ex_in.get("recipients"), list)
and len(_ex_in.get("recipients") or []) >= 2
) or ("," in _r_in and _r_in.count(",") >= 1)
_claims_dm = (_aud_in == "direct") or (_ru_in and not _is_multi_in)
if _claims_dm:
_acc = _accounts_by_practice(pid)
_resolved_ru = _ru_in if _ru_in in _acc else (
_resolve_user_uid_in_practice(pid, _r_in) if _r_in else ""
)
_r_lower = _r_in.lower()
if not _resolved_ru or _r_lower in ("alle", "all", "allgemein", "an alle"):
raise HTTPException(
status_code=400,
detail=(
"Direktchat konnte technisch nicht eindeutig zugeordnet "
"werden (recipient_user_id fehlt oder ist ungueltig). "
"Bitte den Empfaenger erneut auswaehlen."
),
)
_sess_uid_chk = (s.get("user_id") if s else "") or ""
_claim_su_chk = str(_ex_in.get("sender_user_id") or "").strip()
if _sess_uid_chk:
pass
elif _claim_su_chk and _claim_su_chk in _acc:
pass
else:
raise HTTPException(
status_code=400,
detail=(
"Direktchat: gueltige Session oder sender_user_id fuer "
"diese Praxis erforderlich."
),
)
msg_id = uuid.uuid4().hex[:12]
messages = _load_messages()
thread_id = msg_id
reply_to = (msg.extras or {}).get("reply_to", "")
if reply_to:
for m in messages:
if m.get("id") == reply_to:
thread_id = m.get("thread_id", reply_to)
break
else:
thread_id = reply_to
_ts = _utc_now_iso_z()
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": absender,
"zeitstempel": _ts,
"empfangen": _ts,
"status": "offen",
"user_id": s["user_id"] if s else "",
}
ex_raw = dict(msg.extras or {})
session_uid_out = (s["user_id"] if s else "") or ""
entry["extras"] = _enrich_outgoing_direct_extras(pid, absender, ex_raw, session_uid_out)
exo_dbg = entry.get("extras") or {}
if _claims_dm:
_send_mode = "direct"
elif _extras_indicates_broadcast(exo_dbg):
_send_mode = "all"
else:
_send_mode = "other"
_log.info(
"empfang_send mode=%s practice_id=%s sender_uid=%s recipient_uid=%s "
"conv_key=%s msg_id=%s",
_send_mode,
pid,
str(exo_dbg.get("sender_user_id") or ""),
str(exo_dbg.get("recipient_user_id") or ""),
str(exo_dbg.get("direct_conv_key") or ""),
msg_id,
)
messages.insert(0, entry)
_save_messages(messages)
try:
_pulse_bump(pid, sender=absender)
except Exception:
pass
return JSONResponse(content={
"success": True, "id": msg_id, "thread_id": thread_id,
"practice_id": pid,
})
@router.get("/messages")
async def empfang_list(request: Request, practice_id: Optional[str] = Query(None)):
pid = (practice_id or "").strip() or _resolve_practice_id(request)
if not pid:
return JSONResponse(content={"success": True, "messages": []})
messages = _load_messages()
filtered = _filter_by_practice(messages, pid)
return JSONResponse(content={"success": True, "messages": filtered})
# =====================================================================
# CONVERSATION + LIVE-PULSE
# Eine einzige serverseitige Wahrheit fuer Browser, Hülle und
# "An Empfang senden". Kein Client-Filter, keine lokale Sonderwahrheit.
# =====================================================================
# In-Memory Pulse: bei jedem POST /send wird der Tick erhoeht.
# Clients koennen mit kurzen Polls (z. B. 800 ms) auf den Tick lauschen
# und nur dann die volle Conversation neu holen, wenn der Tick wechselt.
# Damit wirkt das Signal sofort, ohne traege Sekunden-Lags.
_PRACTICE_PULSE: dict[str, dict] = {}
def _pulse_bump(practice_id: str, sender: str = ""):
p = _PRACTICE_PULSE.setdefault(practice_id, {"tick": 0, "ts": 0.0, "last_sender": ""})
p["tick"] = int(p.get("tick", 0)) + 1
p["ts"] = time.time()
p["last_sender"] = sender or ""
_PRACTICE_PULSE[practice_id] = p
# Sofortige Benachrichtigung der WebSocket-Clients derselben practice_id.
# Best-effort: bei fehlendem Event-Loop o.ae. lautlos weiter (Polling-
# Fallback bleibt aktiv). Keine Patientendaten im Payload.
try:
_empfang_ws_notify_pulse(practice_id, int(p["tick"]), float(p["ts"]), str(p["last_sender"]))
except Exception:
pass
def _pulse_get(practice_id: str) -> dict:
p = _PRACTICE_PULSE.get(practice_id)
if not p:
# Beim ersten Abruf einen Tick aus den Daten ableiten, damit
# Clients nach Server-Restart nicht alle "neue Nachricht!" denken.
msgs = _filter_by_practice(_load_messages(), practice_id)
latest = ""
latest_ts = 0.0
for m in msgs:
t_raw = (m.get("empfangen") or m.get("zeitstempel") or "").strip()
ts = _parse_msg_instant_utc_ts(t_raw)
if ts >= latest_ts and t_raw:
latest_ts = ts
latest = t_raw
p = {"tick": 1, "ts": time.time(), "last_sender": "", "boot": latest}
_PRACTICE_PULSE[practice_id] = p
return p
# =====================================================================
# WebSocket-Live-Push (Beschleuniger, KEINE neue Quelle der Wahrheit)
# =====================================================================
#
# Architektur:
# - Server bleibt autoritativ: /empfang/send schreibt synchron und ruft
# _pulse_bump() auf. _pulse_bump() benachrichtigt zusaetzlich alle
# WebSocket-Clients derselben practice_id mit einem {"type":"pulse",...}.
# - Client behaelt seinen 1-s-Pulse-Poll als Fallback. Bei aktivem WS
# loest der Server-Push sofortiges loadMessages() aus, ohne den
# Polling-Tick abzuwarten.
# - Keine Patientendaten/Chat-Inhalte werden ueber den WS-Kanal
# gesendet. Nur tick/ts/last_sender als Signal.
# - Sicherheit: Cookie-Session wird beim WS-Upgrade geprueft. Eine
# Verbindung erhaelt ausschliesslich Pulses ihrer eigenen practice_id.
#
# In-Memory Registrierung. Bei Server-Restart muessen Clients reconnecten
# (sie tun das automatisch ueber den Backoff).
_WS_HEARTBEAT_SECONDS = 20.0
_WS_PRACTICE_CLIENTS: dict[str, set] = {}
_WS_LOOP: Optional[asyncio.AbstractEventLoop] = None
def _empfang_ws_register(practice_id: str, ws: "WebSocket") -> None:
if not practice_id:
return
bucket = _WS_PRACTICE_CLIENTS.setdefault(practice_id, set())
bucket.add(ws)
def _empfang_ws_unregister(practice_id: str, ws: "WebSocket") -> None:
if not practice_id:
return
bucket = _WS_PRACTICE_CLIENTS.get(practice_id)
if not bucket:
return
try:
bucket.discard(ws)
except Exception:
pass
if not bucket:
try:
del _WS_PRACTICE_CLIENTS[practice_id]
except KeyError:
pass
async def _empfang_ws_send_safe(ws: "WebSocket", payload: dict) -> bool:
try:
await ws.send_json(payload)
return True
except Exception:
return False
async def _empfang_ws_broadcast(practice_id: str, payload: dict) -> int:
"""Schickt ein JSON an alle Clients der Praxis. Liefert Anzahl
erfolgreicher Sendungen. Bei Fehlern wird der Client entfernt."""
bucket = list(_WS_PRACTICE_CLIENTS.get(practice_id) or [])
if not bucket:
return 0
dead: list = []
sent = 0
for ws in bucket:
ok = await _empfang_ws_send_safe(ws, payload)
if ok:
sent += 1
else:
dead.append(ws)
for ws in dead:
_empfang_ws_unregister(practice_id, ws)
return sent
def _empfang_ws_notify_pulse(practice_id: str, tick: int, ts: float, last_sender: str) -> None:
"""Wird von _pulse_bump() aufgerufen.
Best-effort: wenn kein laufender Event-Loop vorhanden ist (Server beim
Start, Test ohne async), still still und ueberlassen es dem Polling-
Fallback. Keine Patientendaten im Payload.
"""
if not practice_id:
return
payload = {
"type": "pulse",
"practice_id": practice_id,
"tick": int(tick),
"ts": float(ts),
"last_sender": str(last_sender or ""),
}
loop = _WS_LOOP
if loop is None:
try:
loop = asyncio.get_event_loop()
except RuntimeError:
loop = None
if loop is None or not loop.is_running():
return
try:
asyncio.run_coroutine_threadsafe(
_empfang_ws_broadcast(practice_id, payload),
loop,
)
except Exception:
pass
@router.websocket("/ws")
async def empfang_ws(websocket: WebSocket):
"""Live-Pulse-Kanal pro Praxis.
Auth via Cookie-Session (aza_session). Verbindung wird an
practice_id der Session gebunden. Andere Praxen erhalten NIE ein
Signal aus dieser Connection.
Pakettypen (Server -> Client):
{"type":"hello","tick":N,"ts":t,"practice_id":pid}
{"type":"pulse","tick":N,"ts":t,"last_sender":""}
{"type":"ping","ts":t} -- alle ~20 s
Pakettypen (Client -> Server):
{"type":"pong"} -- optional, wird nicht erzwungen
{"type":"hello"} -- optional Initial-Greeting
Keine Patientendaten / Chat-Inhalte werden uebertragen. Inhalte holen
sich Clients weiterhin ueber /empfang/messages.
"""
# Auth aus Cookie ziehen (FastAPI/Starlette: websocket.cookies).
token = ""
try:
token = websocket.cookies.get("aza_session") or ""
except Exception:
token = ""
if not token:
try:
token = websocket.query_params.get("session_token", "") or ""
except Exception:
token = ""
session = _get_session(token) if token else None
if not session:
# 1008 = Policy Violation
try:
await websocket.close(code=1008)
except Exception:
pass
return
pid = (session.get("practice_id") or "").strip()
uid = (session.get("user_id") or "").strip()
if not pid:
try:
await websocket.close(code=1008)
except Exception:
pass
return
await websocket.accept()
# Event-Loop-Referenz fuer _pulse_bump-Broadcast einfangen.
global _WS_LOOP
if _WS_LOOP is None:
try:
_WS_LOOP = asyncio.get_running_loop()
except RuntimeError:
_WS_LOOP = None
_empfang_ws_register(pid, websocket)
pulse = _pulse_get(pid)
_log.info(
"AZA_EMPFANG_WS_CONNECTED practice=%s uid=%s clients=%d",
(pid or "")[:16], (uid or "")[:16], len(_WS_PRACTICE_CLIENTS.get(pid, [])),
)
try:
await _empfang_ws_send_safe(websocket, {
"type": "hello",
"practice_id": pid,
"tick": int(pulse.get("tick", 0)),
"ts": float(pulse.get("ts", 0.0)),
"heartbeat_seconds": int(_WS_HEARTBEAT_SECONDS),
})
# Receive-Loop laeuft parallel zu Heartbeat. Beide ueberleben bei
# Fehler in einer der beiden Tasks.
async def _heartbeat():
while True:
await asyncio.sleep(_WS_HEARTBEAT_SECONDS)
ok = await _empfang_ws_send_safe(websocket, {
"type": "ping",
"ts": time.time(),
})
if not ok:
raise WebSocketDisconnect()
async def _recv():
while True:
try:
msg = await websocket.receive_text()
except WebSocketDisconnect:
raise
# Wir akzeptieren JSON oder beliebigen Text, aber tun
# nichts daran -- Reaktion bleibt server-getrieben.
# Verhindert nur, dass Sockets durch fehlendes Receive
# blockieren.
if not msg:
continue
hb_task = asyncio.create_task(_heartbeat())
rx_task = asyncio.create_task(_recv())
done, pending = await asyncio.wait(
{hb_task, rx_task},
return_when=asyncio.FIRST_EXCEPTION,
)
for t in pending:
t.cancel()
except WebSocketDisconnect:
pass
except Exception as exc:
_log.warning(
"AZA_EMPFANG_WS_ERROR practice=%s err=%s",
(pid or "")[:16], type(exc).__name__,
)
finally:
_empfang_ws_unregister(pid, websocket)
try:
await websocket.close()
except Exception:
pass
_log.info(
"AZA_EMPFANG_WS_DISCONNECTED practice=%s uid=%s remaining=%d",
(pid or "")[:16], (uid or "")[:16],
len(_WS_PRACTICE_CLIENTS.get(pid, [])),
)
# =====================================================================
# Client-Presence (Ping pro angemeldeter Empfang-Instanz, practice-scoped)
# RAM-beschraenkt wie Pulse; TTL definiert „online“ für /empfang/users.
# =====================================================================
EMPFANG_PRESENCE_TTL_SECONDS = 120
_PRACTICE_USER_PRESENCE: dict[str, dict] = {}
def _presence_key(pid: str, uid: str) -> str:
return f"{(pid or '').strip()}|{(uid or '').strip()}"
def _presence_record_ping(pid: str, uid: str, source: str = "web") -> None:
if not pid or not uid:
return
src = ((source or "web").strip() or "web")[:32]
_PRACTICE_USER_PRESENCE[_presence_key(pid, uid)] = {
"last_seen": time.time(),
"source": src,
}
def _presence_clear_user(pid: str, uid: str) -> None:
if not pid or not uid:
return
_PRACTICE_USER_PRESENCE.pop(_presence_key(pid, uid), None)
def _presence_iso_utc(ts: float) -> str:
if ts <= 0:
return ""
return datetime.fromtimestamp(ts, tz=timezone.utc).strftime(
"%Y-%m-%dT%H:%M:%SZ")
def _presence_snapshot_for_user(pid: str, uid: str) -> dict:
rec = _PRACTICE_USER_PRESENCE.get(_presence_key(pid, uid))
now = time.time()
absent = {
"presence_online": False,
"presence_last_seen": None,
"presence_source": "",
"presence_age_seconds": None,
}
if not rec:
return absent
last = float(rec.get("last_seen", 0))
age = max(0.0, now - last)
online = age <= float(EMPFANG_PRESENCE_TTL_SECONDS)
return {
"presence_online": online,
"presence_last_seen": _presence_iso_utc(last) if last > 0 else None,
"presence_source": str(rec.get("source") or ""),
"presence_age_seconds": int(age),
}
def _presence_iter_practice(pid: str) -> list[tuple[str, dict]]:
"""Liefert (user_id, Roh-Eintrag) fuer alle Keys practice_id|user_id."""
pid = (pid or "").strip()
rows: list[tuple[str, dict]] = []
if not pid:
return rows
for k, rec in _PRACTICE_USER_PRESENCE.items():
if not isinstance(rec, dict):
continue
parts = str(k).split("|", 1)
if len(parts) == 2 and parts[0] == pid:
rows.append((parts[1], rec))
rows.sort(key=lambda x: x[0])
return rows
def _presence_count_for_practice(pid: str) -> int:
return len(_presence_iter_practice(pid))
def _presence_debug_any_device_recent(devs: list, within_sec: int = 120) -> bool:
"""Vergleicht device last_active grob mit TTL-Logik (wie Frontend <120s)."""
if not devs:
return False
now = time.time()
for d in devs:
if not isinstance(d, dict):
continue
la = d.get("last_active")
if la is None or la == "":
continue
try:
s = str(la).strip().replace(" ", "T")
if s.endswith("Z"):
dt = datetime.fromisoformat(s.replace("Z", "+00:00"))
else:
dt = datetime.fromisoformat(s)
ts = dt.timestamp()
if now - ts < float(within_sec):
return True
except Exception:
continue
return False
def _presence_debug_resolve_practice_auth(request: Request) -> str:
"""practice_id fuer Debug: Session oder validiertes X-API-Token + X-Practice-Id."""
s = _session_from_request(request)
if s:
pid = (s.get("practice_id") or "").strip()
if not pid:
raise HTTPException(
status_code=400,
detail="practice_id in Session fehlt",
)
return pid
api_raw = (request.headers.get("X-API-Token") or "").strip()
if not api_raw:
raise HTTPException(status_code=401, detail="Nicht authentifiziert")
try:
from aza_security import get_required_api_tokens
allowed = get_required_api_tokens()
except RuntimeError:
raise HTTPException(status_code=503, detail="API token nicht konfiguriert")
if not any(hmac.compare_digest(api_raw, t) for t in allowed):
raise HTTPException(status_code=401, detail="Unauthorized")
pid = request.headers.get("X-Practice-Id", "").strip()
if not pid:
raise HTTPException(
status_code=400,
detail="X-Practice-Id Header erforderlich",
)
return pid
def _norm_name(s: str) -> str:
"""Vergleichts-String fuer Namen: lower, trim, Akzente ueber NFKD entfernen.
Hinweis: ``unicodedata.combining(ch)`` liefert einen ``int`` (0 = kein
kombinierendes Zeichen). Der Vergleich muss daher gegen ``0`` erfolgen,
nicht gegen den Leerstring. Andernfalls wuerde der Filter alle Zeichen
verwerfen und die Funktion stets einen leeren String liefern.
"""
t = (s or "").strip().lower()
t = unicodedata.normalize("NFKD", t)
return "".join(ch for ch in t if unicodedata.combining(ch) == 0)
def _normalize_login_username(s: str) -> str:
"""Gleiche Namensform wie in Chat-Routen, zusaetzlich zusammenhaengende Leerzeichen."""
collapsed = " ".join(((s or "").strip()).split())
return _norm_name(collapsed)
def _preferred_unique_login_for_display(
accounts: dict,
pid: str,
preferred_raw: str,
exclude_uid: str = "",
) -> str:
"""Bevorzugt menschenlesbaren Namen ohne Suffix-Zufall, wenn pro Praxis (norm.) noch frei."""
collapsed = " ".join(((preferred_raw or "").strip()).split())
if len(collapsed) < 2:
return _allocate_unique_login_name(accounts, pid, preferred_raw or "nutzer")
if _login_name_conflicts_existing(accounts, pid, collapsed, exclude_uid):
return _allocate_unique_login_name(accounts, pid, preferred_raw or collapsed)
return collapsed
def _allocate_unique_login_name(accounts: dict, pid: str, preferred_raw: str) -> str:
"""Vergibt einen pro Praxis (normalisiert) noch freien login_name."""
pref = " ".join((preferred_raw or "").strip().split())
if not pref:
pref = "nutzer"
def _taken(candidate: str) -> bool:
nk = _normalize_login_username(candidate)
if not nk:
return True
return any(
(a.get("practice_id") or "").strip() == pid
and (a.get("login_name") or "").strip()
and _normalize_login_username((a.get("login_name") or "").strip()) == nk
for a in accounts.values()
)
for _ in range(48):
if not _taken(pref):
return pref
pref = "%s-%s" % (" ".join((preferred_raw or "").strip().split()) or "nutzer",
uuid.uuid4().hex[:5])
base = (" ".join((preferred_raw or "").strip().split()) or "nutzer")
return f"{base}-{uuid.uuid4().hex[:8]}"
def _needs_distinct_login_name(tgt: dict, accounts: dict, pid: str) -> bool:
"""True, wenn mindestens ein anderes Konto in der Praxis denselben Anzeigenamen (norm.) hat."""
nd = _normalize_login_username(tgt.get("display_name") or "")
if not nd:
return False
uid = (tgt.get("user_id") or "").strip()
n = sum(
1
for a in accounts.values()
if a.get("practice_id") == pid
and _normalize_login_username(a.get("display_name") or "") == nd
and (a.get("user_id") or "").strip() != uid
)
return n > 0
def _login_name_conflicts_existing(
accounts: dict,
pid: str,
raw_login_name: str,
exclude_uid: str,
) -> bool:
"""True, wenn ein *anderes* Konto in der Praxis denselben Login (normiert) hat.
exclude_uid gleicht sowohl Feld user_id als auch den JSON-Schluessel — damit wird
der eigene Datensatz nie fälschlich als Konflikt gezählt, auch wenn user_id fehlt
oder abweicht (Legacy-/Import-Artefakte).
"""
nk = _normalize_login_username(raw_login_name)
if not nk:
return True
ex = (exclude_uid or "").strip()
for store_key, a in accounts.items():
if isinstance(a, dict) and a.get("practice_id") != pid:
continue
if not isinstance(a, dict):
continue
cand_uid = (str(a.get("user_id") or store_key or "")).strip()
if ex and cand_uid == ex:
continue
ln = (a.get("login_name") or "").strip()
if not ln:
continue
if _normalize_login_username(ln) == nk:
return True
return False
def _resolve_browser_login_matches(
scoped_accounts: list,
raw_login: str,
) -> tuple[list, str]:
"""Login per Benutzername. Liefert IMMER ``(matches, via)``.
``via`` ist eines von ``"login_name"``, ``"display_name_fallback"``,
``"email"`` (nicht hier — der Aufrufer behandelt E-Mail separat) oder
``"none"``. Konten ohne gesetzten ``login_name`` bleiben rueckwaerts-
kompatibel ueber den normierten ``display_name`` erreichbar.
Bei E-Mail-Eingabe wird ``([], "none")`` zurueckgegeben — der Aufrufer
nutzt dafuer einen separaten E-Mail-Lookup.
"""
if _is_likely_email(raw_login):
return [], "none"
nk = _normalize_login_username(raw_login)
if not nk:
return [], "none"
ln_bucket: list = []
for a in scoped_accounts:
if not isinstance(a, dict):
continue
ln_raw = (a.get("login_name") or "").strip()
if ln_raw and _normalize_login_username(ln_raw) == nk:
ln_bucket.append(a)
if ln_bucket:
return ln_bucket, "login_name"
dn_bucket: list = []
for a in scoped_accounts:
if not isinstance(a, dict):
continue
if (a.get("login_name") or "").strip():
continue
if _normalize_login_username(a.get("display_name") or "") == nk:
dn_bucket.append(a)
if dn_bucket:
return dn_bucket, "display_name_fallback"
return [], "none"
def _match_accounts_by_login_label(
scoped_accounts: list, raw_login: str,
) -> list:
"""Rueckwaertskompatibilitaet — nur Trefferliste, ohne ``via``-Info."""
matches, _via = _resolve_browser_login_matches(scoped_accounts, raw_login)
return matches
def _accounts_by_practice(pid: str) -> dict[str, dict]:
accounts = _load_accounts()
return {a["user_id"]: a for a in accounts.values() if a.get("practice_id") == pid}
def _resolve_user_uid_in_practice(pid: str, hint: str) -> str:
"""Loesst Kurz-ID oder display_name innerhalb einer Praxis zu user_id auf."""
hint = (hint or "").strip()
if not hint or not pid:
return ""
by_uid = _accounts_by_practice(pid)
if hint in by_uid:
return hint
hn = _normalize_login_username(hint)
best = ""
for uid, a in by_uid.items():
if _normalize_login_username(a.get("display_name") or "") == hn:
return uid
return best
def _resolve_user_uid_in_practice_loose(pid: str, hint: str) -> str:
"""Eindeutige Teilstring-Zuordnung display_name ↔ user_id innerhalb einer Praxis.
Nur wenn genau ein Konto matched; sonst leer — keine automatische Fusion.
Mindestlaenge fuer Hint, um kurze Artefakte wie ``test`` zu vermeiden.
"""
hint = (hint or "").strip()
if not hint or not pid:
return ""
ex = _resolve_user_uid_in_practice(pid, hint)
if ex:
return ex
hn = _norm_name(hint)
if len(hn) < 5:
return ""
cand: list[str] = []
for uid, a in _accounts_by_practice(pid).items():
dn = _norm_name(a.get("display_name") or "")
if not dn:
continue
if hn in dn or dn in hn:
cand.append(uid)
if len(cand) != 1:
return ""
return cand[0]
def _resolve_user_uid_unique_last_token(pid: str, full_name_core: str) -> str:
"""Wenn nach normiertem Nachnamen nur genau ein Konto in der Praxis matched."""
hn = _norm_name(full_name_core)
parts = [p for p in hn.split() if p]
if len(parts) < 2:
return ""
last = parts[-1]
if len(last) < 4:
return ""
cand: list[str] = []
for uid, a in _accounts_by_practice(pid).items():
dn = _norm_name(a.get("display_name") or "")
dp = [p for p in dn.split() if p]
if not dp:
continue
if dp[-1] == last:
cand.append(uid)
if len(cand) != 1:
return ""
return cand[0]
def _extras_indicates_broadcast(ex: dict) -> bool:
if not isinstance(ex, dict):
return False
if ex.get("rcpt_broadcast") in (True, "true", "1", 1):
return True
aud = str(ex.get("audience") or "").strip().lower()
if aud in ("all", "everyone", "broadcast", "general"):
return True
return False
def _thread_id_stable(m: dict) -> str:
return str(m.get("thread_id") or m.get("id") or "").strip()
def _thread_requires_broadcast_exclusion(
messages: list[dict],
pid: str,
tid: str,
) -> bool:
"""Threads mit klarem (Nicht-)Broadcast-Adressaten nicht unter «An alle» listen."""
if not tid or not pid:
return False
acc_map = _accounts_by_practice(pid)
for m in messages:
if _thread_id_stable(m) != tid:
continue
if _normalized_group_key_from_message(m):
continue
ex = m.get("extras") or {}
if _extras_indicates_broadcast(ex):
continue
su = str(ex.get("sender_user_id") or "").strip()
ru = str(ex.get("recipient_user_id") or "").strip()
if su in acc_map and ru in acc_map and su != ru:
return True
rcpt_raw = (ex.get("recipient") or "").strip()
rl = rcpt_raw.lower()
if rcpt_raw and rl not in ("alle", "all", "allgemein", "an alle"):
return True
dk = str(ex.get("direct_conv_key") or "").strip()
if dk and "|direct|" in dk:
return True
return False
def _uid_pair_from_message_for_practice(m: dict, pid: str) -> tuple[str, str]:
"""Liefert zwei user_ids wenn aus extras + Stammdaten konservativ ableitbar."""
acc_map = _accounts_by_practice(pid)
ex = m.get("extras") or {}
su_ex = str(ex.get("sender_user_id") or "").strip()
ru_ex = str(ex.get("recipient_user_id") or "").strip()
su = su_ex if su_ex in acc_map else ""
ru = ru_ex if ru_ex in acc_map else ""
core_s = _sender_core(m.get("absender", ""))
r_s = (ex.get("recipient") or "").strip()
rl = r_s.lower()
if rl in ("alle", "all", "allgemein", "an alle"):
return ("", "")
if _extras_indicates_broadcast(ex):
return ("", "")
rs = _resolve_user_uid_in_practice(pid, core_s)
rr = _resolve_user_uid_in_practice(pid, r_s) if r_s else ""
if not rs:
rs = _resolve_user_uid_in_practice_loose(pid, core_s)
if not rs:
rs = _resolve_user_uid_unique_last_token(pid, core_s)
if not rr and r_s:
rr = _resolve_user_uid_in_practice_loose(pid, r_s)
if not rr and r_s:
rr = _resolve_user_uid_unique_last_token(pid, r_s)
if not su and rs:
su = rs
if not ru and rr:
ru = rr
if _normalized_group_key_from_message(m):
return ("", "")
if su and ru and su != ru:
return (su, ru)
return ("", "")
def _direct_conv_key(pid: str, uid_a: str, uid_b: str) -> str:
ua, ub = sorted([uid_a, uid_b])
return f"{pid}|direct|{ua}|{ub}"
def _sender_core(absender: str) -> str:
"""Aus 'Vorname Nachname (HOST)' -> 'Vorname Nachname'."""
s = (absender or "").split("(")[0].strip()
return s
def _enrich_outgoing_direct_extras(pid: str, absender: str, extras: dict,
session_uid: str) -> dict:
"""DM: sender_user_id, recipient_user_id, direct_conv_key (stabil)."""
ex = dict(extras or {})
recipient_raw = (ex.get("recipient") or "").strip()
rlist = ex.get("recipients")
is_multi = (
isinstance(rlist, list) and len(rlist) >= 2
) or ("," in recipient_raw and recipient_raw.count(",") >= 1)
broadcast_rcpt = not recipient_raw or recipient_raw.lower() in ("alle", "all", "allgemein")
if broadcast_rcpt or is_multi:
return ex
by_uid = _accounts_by_practice(pid)
core = _sender_core(absender)
sender_uid = (session_uid or "").strip()
if not sender_uid:
su_claim = str(ex.get("sender_user_id") or "").strip()
resolved = _resolve_user_uid_in_practice(pid, core)
if su_claim and su_claim in by_uid and su_claim == resolved:
sender_uid = su_claim
elif su_claim and su_claim in by_uid and not resolved:
sender_uid = su_claim
else:
sender_uid = resolved
else:
if sender_uid not in by_uid:
fb = _resolve_user_uid_in_practice(pid, core)
if fb:
sender_uid = fb
if sender_uid:
ex["sender_user_id"] = sender_uid
recipient_uid = _resolve_user_uid_in_practice(pid, recipient_raw)
ru_claim = str(ex.get("recipient_user_id") or "").strip()
if ru_claim and ru_claim in by_uid:
if not recipient_uid or ru_claim == recipient_uid:
recipient_uid = ru_claim
if recipient_uid:
ex["recipient_user_id"] = recipient_uid
if sender_uid and recipient_uid:
ex["direct_conv_key"] = _direct_conv_key(pid, sender_uid, recipient_uid)
# Explizites DM-Tagging: garantiert, dass diese Nachricht nicht als
# Allgemein-/Broadcast-Inbox-Treffer ausgewertet wird.
if recipient_raw and not is_multi:
ex["audience"] = "direct"
ex["rcpt_broadcast"] = False
return ex
def _msg_recipient(m: dict) -> str:
extras = m.get("extras") or {}
return (extras.get("recipient") or "").strip()
def _normalized_group_key_from_extras(extras: dict) -> str:
"""Canonical key 'name|name|...' lowercase for multi-recipient threads."""
if not isinstance(extras, dict):
return ""
rlist = extras.get("recipients")
if isinstance(rlist, list) and len(rlist) >= 2:
parts = sorted({_norm_name(str(x)) for x in rlist if str(x).strip()})
return "|".join(parts) if parts else ""
rcpt = (extras.get("recipient") or "").strip()
if "," in rcpt:
parts = sorted({_norm_name(p) for p in rcpt.split(",") if p.strip()})
if len(parts) >= 2:
return "|".join(parts)
return ""
def _normalized_group_key_from_message(m: dict) -> str:
return _normalized_group_key_from_extras(m.get("extras") or {})
def _dm_message_matches_pair(m: dict, me_n: str, peer_n: str) -> bool:
"""True, wenn Nachricht zum 1:1-Paar (mit display_name-normalisierten Kernen) gehoert."""
if _normalized_group_key_from_message(m):
return False
sender_n = _norm_name(_sender_core(m.get("absender", "")))
rcpt_n = _norm_name(_msg_recipient(m))
ex = m.get("extras") or {}
has_reply = bool(str(ex.get("reply_to") or "").strip())
if me_n and peer_n:
if sender_n and sender_n not in (me_n, peer_n):
return False
if rcpt_n in ("", "alle"):
# Leerer Empfaenger: nur echte Thread-Antwort (kein Rundschreiben ohne Adresse).
return has_reply and bool(sender_n) and sender_n in (me_n, peer_n)
if rcpt_n not in (me_n, peer_n):
return False
return (
(sender_n == me_n and rcpt_n == peer_n)
or (sender_n == peer_n and rcpt_n == me_n)
)
# Wenig 'me' vom Client: sehr konservativ, keine Rundmails ohne reply_to reinziehen.
if not peer_n:
return False
if rcpt_n in ("", "alle"):
return has_reply and sender_n == peer_n
if sender_n == peer_n and rcpt_n and rcpt_n not in ("alle",) and rcpt_n != peer_n:
return True
if rcpt_n == peer_n and sender_n and sender_n != peer_n:
return True
return False
def _msg_by_id_index(messages: list[dict]) -> dict[str, dict]:
return {str(m.get("id")): m for m in messages if m.get("id")}
def _thread_root_msg(m: dict, by_id: dict[str, dict]) -> Optional[dict]:
cur: Optional[dict] = m
steps = 0
while cur is not None and steps < 500:
steps += 1
rto = str((cur.get("extras") or {}).get("reply_to") or "").strip()
if not rto:
return cur
nxt = by_id.get(rto)
if nxt is None:
return cur
cur = nxt
return cur
def _root_is_broadcast_inbox(root: Optional[dict], pid: str = "") -> bool:
if not root:
return False
if _normalized_group_key_from_message(root):
return False
pid = (pid or "").strip()
exroot = root.get("extras") or {}
if pid and isinstance(exroot, dict) and not _extras_indicates_broadcast(exroot):
acc_map = _accounts_by_practice(pid)
su = str(exroot.get("sender_user_id") or "").strip()
ru = str(exroot.get("recipient_user_id") or "").strip()
if su in acc_map and ru in acc_map and su != ru:
return False
rcpt_n = _norm_name(_msg_recipient(root))
return rcpt_n in ("", "alle")
def _dm_extras_uid_symmetric_match(
ex: dict, me_uid: str, peer_uid: str, acc_map: dict[str, dict]
) -> bool:
su = str(ex.get("sender_user_id") or "").strip()
ru = str(ex.get("recipient_user_id") or "").strip()
if su not in acc_map or ru not in acc_map or su == ru:
return False
return {su, ru} == {me_uid, peer_uid}
def _dm_uid_pair_matches_message(m: dict, pid: str, me_uid: str, peer_uid: str) -> bool:
"""True, wenn aus Absender-/Empfaenger-/extras eindeutig dasselbe 1:1-Paar wird."""
if not me_uid or not peer_uid:
return False
a, b = _uid_pair_from_message_for_practice(m, pid)
return bool(a and b and {a, b} == {me_uid, peer_uid})
def _conversation_dm_by_key_or_names(
messages: list[dict],
pid: str,
me_uid: str,
peer_uid: str,
me_display: str,
peer_display_fallback: str,
) -> list[dict]:
acc_map = _accounts_by_practice(pid)
peer_dn = peer_display_fallback
if peer_uid and peer_uid in acc_map:
peer_dn = (acc_map[peer_uid].get("display_name") or "").strip() or peer_dn
me_dn = me_display
if me_uid and me_uid in acc_map:
me_dn = (acc_map[me_uid].get("display_name") or "").strip() or me_dn
me_n = _norm_name(me_dn)
peer_n = _norm_name(peer_dn)
key_need = ""
if me_uid and peer_uid:
key_need = _direct_conv_key(pid, me_uid, peer_uid)
out: list[dict] = []
for m in messages:
if _normalized_group_key_from_message(m):
continue
ex = m.get("extras") or {}
rcpt_raw_l = (ex.get("recipient") or "").strip().lower()
if rcpt_raw_l in ("alle", "all", "allgemein", "an alle"):
continue
if _extras_indicates_broadcast(ex):
continue
matched = False
if key_need:
km = str(ex.get("direct_conv_key") or "").strip()
if km == key_need:
matched = True
elif _dm_extras_uid_symmetric_match(ex, me_uid, peer_uid, acc_map):
matched = True
elif km and km != key_need:
# Alter/falscher Key: nur bei Nachweis desselben Teilnehmerpaares
# oder konservativem Legacy-Namenmatch zulassen (nicht fremdes DM).
if _dm_uid_pair_matches_message(m, pid, me_uid, peer_uid):
matched = True
elif me_n and peer_n and _dm_message_matches_pair(m, me_n, peer_n):
matched = True
else:
continue
if not matched and me_uid and peer_uid:
if _dm_uid_pair_matches_message(m, pid, me_uid, peer_uid):
matched = True
if not matched and me_n and peer_n and _dm_message_matches_pair(m, me_n, peer_n):
matched = True
if matched:
out.append(m)
return out
def _conversation_for_audience(
messages: list[dict],
practice_id_scope: str,
me_display: str,
audience: str,
me_user_id: str = "",
peer_user_id: str = "",
) -> list[dict]:
"""
Audience-Modell:
- Optional me_user_id + peer_user_id: stabiler Direktchat (direct_conv_key).
- Sonst Fallback ueber normierte display_name-Paare.
"""
aud_raw = (audience or "").strip()
aud_lower = aud_raw.lower()
if aud_lower in ("__noop__", "__multi__"):
return []
is_broadcast = aud_lower in ("", "alle", "all", "allgemein")
pid = (practice_id_scope or "").strip()
def _tid(msg: dict) -> str:
return str(msg.get("thread_id") or msg.get("id") or "")
out: list[dict] = []
if is_broadcast:
by_id = _msg_by_id_index(messages)
for m in messages:
if _normalized_group_key_from_message(m):
continue
rcpt_n = _norm_name(_msg_recipient(m))
if rcpt_n not in ("", "alle"):
continue
root = _thread_root_msg(m, by_id)
if not _root_is_broadcast_inbox(root, pid):
continue
tid = _tid(m)
if _thread_requires_broadcast_exclusion(messages, pid, tid):
continue
out.append(m)
out.sort(key=_msg_chrono_sort_key)
return out
# --- Gruppen-Chat ---
if aud_lower.startswith("group|"):
target_key = aud_lower[len("group|"):].strip()
participants = {_norm_name(p) for p in target_key.split("|") if p.strip()}
thread_ids: set[str] = set()
for m in messages:
gk = _normalized_group_key_from_message(m)
if gk == target_key:
thread_ids.add(_tid(m))
for m in messages:
if _tid(m) not in thread_ids:
continue
gk2 = _normalized_group_key_from_message(m)
if gk2 == target_key:
out.append(m)
continue
if not gk2:
sn = _norm_name(_sender_core(m.get("absender", "")))
if sn and sn in participants:
out.append(m)
out.sort(key=_msg_chrono_sort_key)
return out
# --- 1:1 Direktverlauf ---
mu = (me_user_id or "").strip() or ""
pu = (peer_user_id or "").strip() or _resolve_user_uid_in_practice(pid, aud_raw)
dm_list = _conversation_dm_by_key_or_names(
messages,
pid,
mu,
pu,
me_display,
aud_raw,
)
out.extend(dm_list)
out.sort(key=_msg_chrono_sort_key)
return out
@router.get("/conversation")
async def empfang_conversation(
request: Request,
audience: str = Query(""),
me: str = Query(""),
me_user_id: str = Query(""),
peer_user_id: str = Query(""),
practice_id: Optional[str] = Query(None),
):
"""Liefert den vollstaendigen, serverseitig gefilterten Verlauf.
Eine Quelle fuer Browser, Hülle und Desktop-Dialog "An Empfang senden".
Optional: me_user_id + peer_user_id fuer stabilen Direktchat (gleiche Logik ueberall).
"""
pid = (practice_id or "").strip() or _resolve_practice_id(request)
if not pid:
return JSONResponse(
content={"success": True, "messages": [], "tick": 0},
headers={"Cache-Control": "no-store, no-cache, must-revalidate"},
)
s = _session_from_request(request)
me_eff = (me or "").strip() or (s.get("display_name") if s else "")
me_uid_eff = (me_user_id or "").strip() or (str(s.get("user_id") or "").strip() if s else "")
if not me_uid_eff and me_eff:
# Fallback: aus dem Anzeigename in der Praxis aufloesen, damit
# Desktop ohne Browser-Session denselben direct_conv_key trifft.
me_uid_eff = _resolve_user_uid_in_practice(pid, me_eff)
peer_uid_eff = (peer_user_id or "").strip()
if not peer_uid_eff:
aud_raw = (audience or "").strip()
aud_lower = aud_raw.lower()
if aud_raw and aud_lower not in ("", "alle", "all", "allgemein", "__noop__", "__multi__") and not aud_lower.startswith("group|"):
peer_uid_eff = _resolve_user_uid_in_practice(pid, aud_raw)
messages = _filter_by_practice(_load_messages(), pid)
conv = _conversation_for_audience(
messages,
pid,
me_eff,
audience,
me_uid_eff,
peer_uid_eff,
)
pulse = _pulse_get(pid)
_log.info(
"empfang_conversation load practice_id=%s me_uid=%s peer_uid=%s "
"audience_key=%s msg_count=%s tick=%s",
pid,
me_uid_eff,
peer_uid_eff,
(audience or "")[:80] if audience else "",
len(conv),
int(pulse.get("tick", 0)),
)
return JSONResponse(
content={
"success": True,
"messages": conv,
"audience": audience or "",
"me": me_eff,
"me_user_id": me_uid_eff,
"peer_user_id_used": peer_uid_eff,
"tick": int(pulse.get("tick", 0)),
"ts": pulse.get("ts", 0.0),
},
headers={
"Cache-Control": "no-store, no-cache, must-revalidate",
"Pragma": "no-cache",
},
)
@router.get("/pulse")
async def empfang_pulse(request: Request, practice_id: Optional[str] = Query(None)):
"""Sehr leichter Endpoint fuer Live-Pulse.
Clients pollen kurz (z. B. 800 ms) und holen die Conversation nur dann
neu, wenn sich 'tick' geaendert hat. Damit erscheint das Signal sofort
und ohne 510 s Verzoegerung der alten Polling-Loop.
"""
pid = (practice_id or "").strip() or _resolve_practice_id(request)
if not pid:
return JSONResponse(
content={"tick": 0, "ts": 0.0},
headers={"Cache-Control": "no-store, no-cache, must-revalidate"},
)
p = _pulse_get(pid)
tick = int(p.get("tick", 0))
content: dict = {
"tick": tick,
"ts": float(p.get("ts", 0.0)),
"last_sender": p.get("last_sender", ""),
}
try:
s = _session_from_request(request)
if s:
me_uid = str(s.get("user_id") or "").strip()
if me_uid:
snap = _pulse_dm_pending_ack_for_tick(pid, me_uid, tick)
content["dm_pending_ack_by_peer"] = snap["by_peer"]
content["dm_pending_ack_total"] = int(snap["total"])
except Exception:
pass
return JSONResponse(
content=content,
headers={
"Cache-Control": "no-store, no-cache, must-revalidate",
"Pragma": "no-cache",
},
)
@router.post("/presence/ping")
async def empfang_presence_ping(request: Request):
"""Setzt fuer die aktuelle Praxis/User-Kombination last_seen (TTL = online).
Cookie-Session: Web / WebView. Optional: Desktop mit X-API-Token +
X-Practice-Id + X-AzA-Empfang-User-Id (gleiche Pruefung wie Shell-Erzeugung).
"""
now = time.time()
ttl = int(EMPFANG_PRESENCE_TTL_SECONDS)
s = _session_from_request(request)
if s:
pid = (s.get("practice_id") or "").strip()
uid = (s.get("user_id") or "").strip()
if pid and uid:
_presence_record_ping(pid, uid, "web")
cnt = _presence_count_for_practice(pid)
return JSONResponse(
content={
"success": True,
"server_time": now,
"ttl_seconds": ttl,
"own_user_id": uid,
"practice_id": pid,
"presence_count_for_practice": cnt,
},
headers={"Cache-Control": "no-store, no-cache, must-revalidate"},
)
api_raw = (request.headers.get("X-API-Token") or "").strip()
if api_raw:
pid, uid = _require_shell_api_identity(request)
_presence_record_ping(pid, uid, "desktop")
cnt = _presence_count_for_practice(pid)
return JSONResponse(
content={
"success": True,
"server_time": now,
"ttl_seconds": ttl,
"own_user_id": uid,
"practice_id": pid,
"presence_count_for_practice": cnt,
},
headers={"Cache-Control": "no-store, no-cache, must-revalidate"},
)
raise HTTPException(status_code=401, detail="Nicht angemeldet")
@router.get("/presence/debug")
async def empfang_presence_debug(request: Request):
"""Diagnose: RAM-Presence + Projektion wie /users (nur Auth, keine Patientendaten)."""
pid = _presence_debug_resolve_practice_auth(request)
ttl = int(EMPFANG_PRESENCE_TTL_SECONDS)
now_ts = time.time()
now_iso = _presence_iso_utc(now_ts)
accounts = _accounts_by_practice(pid)
presence_store: list[dict] = []
for uid_store, rec in _presence_iter_practice(pid):
last = float(rec.get("last_seen", 0))
age_s = max(0, int(now_ts - last))
online = age_s <= ttl
acc = accounts.get(uid_store, {})
presence_store.append({
"user_id": uid_store,
"display_name": str(acc.get("display_name") or ""),
"role": str(acc.get("role") or ""),
"last_seen": _presence_iso_utc(last) if last > 0 else "",
"age_seconds": age_s,
"online": online,
"source": str(rec.get("source") or ""),
})
users = _practice_users(pid)
_attach_devices_to_practice_users(users, pid)
_attach_presence_to_practice_users(users, pid)
users_projection: list[dict] = []
for u in users:
devs = u.get("devices") or []
users_projection.append({
"user_id": str(u.get("user_id") or ""),
"display_name": str(u.get("display_name") or ""),
"role": str(u.get("role") or ""),
"presence_online": bool(u.get("presence_online")),
"presence_age_seconds": u.get("presence_age_seconds"),
"presence_source": str(u.get("presence_source") or ""),
"has_devices": len(devs) > 0,
"device_recent": _presence_debug_any_device_recent(devs),
})
return JSONResponse(
content={
"success": True,
"practice_id": pid,
"now": now_iso,
"ttl_seconds": ttl,
"presence_store": presence_store,
"users_projection": users_projection,
},
headers={"Cache-Control": "no-store, no-cache, must-revalidate"},
)
# =====================================================================
# DM v2 — Direct-Only, Fail-Closed
# Eigene, isolierte API ausschliesslich fuer Personenchats:
# - kein Allgemein/Broadcast-Pfad
# - kein Namens-/Heuristik-Matching
# - Speicherung und Laden nur ueber direct_conv_key
# - Verifizierbar (msg_id + conversation_key in Antwort)
# =====================================================================
class DmSendIn(BaseModel):
practice_id: str = ""
sender_user_id: str = ""
recipient_user_id: str = ""
text: str = ""
attachments: list = Field(default_factory=list)
attachment_ids: list[str] = Field(default_factory=list)
client_msg_id: str = ""
def _dm_v2_load_for_pair(pid: str, uid_a: str, uid_b: str) -> tuple[list[dict], str]:
"""Liefert genau die Nachrichten dieses 1:1-Schluessels, sortiert chronologisch."""
conv_key = _direct_conv_key(pid, uid_a, uid_b)
msgs = _filter_by_practice(_load_messages(), pid)
out: list[dict] = []
for m in msgs:
ex = m.get("extras") or {}
if str(ex.get("direct_conv_key") or "").strip() == conv_key:
out.append(m)
out.sort(key=_msg_chrono_sort_key)
return out, conv_key
def _compute_dm_pending_ack_by_peer(pid: str, me_uid: str) -> dict[str, int]:
"""Eingehende DM an mich ohne chat_ack: Zähler pro Absender-user_id (für Badges / Alarm).
Nutzt dieselben Felder wie /dm/conversation (direct_conv_key, sender/recipient_user_id).
"""
by_peer: dict[str, int] = {}
if not pid or not me_uid:
return by_peer
for m in _filter_by_practice(_load_messages(), pid):
ex = m.get("extras") or {}
if not str(ex.get("direct_conv_key") or "").strip():
continue
su = str(ex.get("sender_user_id") or "").strip()
ru = str(ex.get("recipient_user_id") or "").strip()
if not su or su == me_uid:
continue
if ru != me_uid:
continue
if bool(ex.get("chat_ack")):
continue
by_peer[su] = by_peer.get(su, 0) + 1
return by_peer
_PENDING_ACK_CACHE_MAX = 128
_DM_PENDING_ACK_CACHE: dict[str, dict] = {}
def _pulse_dm_pending_ack_for_tick(pid: str, me_uid: str, tick: int) -> dict:
"""Pro (Praxis, Nutzer, Pulse-Tick) einmal berechnen, dann cachen (Tick bump = neue Daten)."""
key = f"{pid}|{me_uid}|{tick}"
hit = _DM_PENDING_ACK_CACHE.get(key)
if hit is not None:
return hit
by_peer = _compute_dm_pending_ack_by_peer(pid, me_uid)
out = {
"by_peer": by_peer,
"total": int(sum(by_peer.values())),
}
if len(_DM_PENDING_ACK_CACHE) > _PENDING_ACK_CACHE_MAX:
_DM_PENDING_ACK_CACHE.clear()
_DM_PENDING_ACK_CACHE[key] = out
return out
@router.post("/dm/send")
async def empfang_dm_send(payload: DmSendIn, request: Request):
"""Direct-Only Senden. Fail-Closed:
- practice_id Pflicht (Body oder Session)
- sender_user_id Pflicht (Body oder Session)
- recipient_user_id Pflicht
- sender != recipient
- beide Konten muessen zur Praxis gehoeren
- Kein Fallback auf Allgemein. Kein audience=all.
"""
s = _session_from_request(request)
pid = (payload.practice_id or "").strip() or _resolve_practice_id(request)
if not pid:
raise HTTPException(status_code=400, detail="practice_id erforderlich")
sender_uid = (payload.sender_user_id or "").strip()
if not sender_uid and s:
sender_uid = str(s.get("user_id") or "").strip()
recipient_uid = (payload.recipient_user_id or "").strip()
text = (payload.text or "").strip()
att_ids = [str(x or "").strip() for x in (payload.attachment_ids or []) if str(x or "").strip()]
legacy_att = list(payload.attachments or [])
if not sender_uid:
raise HTTPException(status_code=400, detail="sender_user_id erforderlich")
if not recipient_uid:
raise HTTPException(status_code=400, detail="recipient_user_id erforderlich")
if sender_uid == recipient_uid:
raise HTTPException(status_code=400, detail="Selbstchat nicht erlaubt")
acc_map = _accounts_by_practice(pid)
if sender_uid not in acc_map:
raise HTTPException(status_code=400, detail="sender_user_id gehoert nicht zu dieser Praxis")
if recipient_uid not in acc_map:
raise HTTPException(status_code=400, detail="recipient_user_id gehoert nicht zu dieser Praxis")
if len(att_ids) + len(legacy_att) > _ATTACHMENT_MAX_PER_MESSAGE:
raise HTTPException(status_code=400, detail="Zu viele Anhaenge")
conv_key = _direct_conv_key(pid, sender_uid, recipient_uid)
finalized_stored = _finalize_attachment_ids_internal(
att_ids, pid, sender_uid, recipient_uid, conv_key,
)
merged_attachments = legacy_att + finalized_stored
has_attachments = bool(merged_attachments)
if not text and not has_attachments:
raise HTTPException(status_code=400, detail="Leere Nachricht ohne Anhang nicht erlaubt")
sender_dn = (acc_map[sender_uid].get("display_name") or "").strip()
recipient_dn = (acc_map[recipient_uid].get("display_name") or "").strip()
msg_id = uuid.uuid4().hex[:12]
now = _utc_now_iso_z()
extras = {
"audience": "direct",
"rcpt_broadcast": False,
"recipient": recipient_dn,
"recipient_user_id": recipient_uid,
"sender_user_id": sender_uid,
"direct_conv_key": conv_key,
"dm_v2": True,
}
if payload.client_msg_id:
extras["client_msg_id"] = str(payload.client_msg_id)[:64]
if has_attachments:
extras["attachments"] = merged_attachments
entry = {
"id": msg_id,
"thread_id": msg_id,
"practice_id": pid,
"medikamente": "",
"therapieplan": "",
"procedere": "",
"kommentar": text or ("\u200b" if has_attachments else ""),
"patient": "Direkt: " + recipient_dn,
"absender": sender_dn + " (Empfang)",
"zeitstempel": now,
"empfangen": now,
"status": "offen",
"user_id": sender_uid,
"extras": extras,
}
messages = _load_messages()
messages.insert(0, entry)
_save_messages(messages)
try:
_pulse_bump(pid, sender=sender_dn)
except Exception:
pass
_log.info(
"AZA_CHAT_SEND mode=direct practice=%s sender=%s recipient=%s conv=%s msg=%s",
pid, sender_uid, recipient_uid, conv_key, msg_id,
)
return JSONResponse(
content={
"success": True,
"ok": True,
"mode": "direct",
"message_id": msg_id,
"thread_id": msg_id,
"practice_id": pid,
"sender_user_id": sender_uid,
"recipient_user_id": recipient_uid,
"conversation_key": conv_key,
"created_at": now,
},
headers={
"Cache-Control": "no-store, no-cache, must-revalidate",
"Pragma": "no-cache",
},
)
@router.get("/dm/conversation")
async def empfang_dm_conversation(
request: Request,
sender_user_id: str = Query(""),
recipient_user_id: str = Query(""),
practice_id: Optional[str] = Query(None),
):
"""Liefert ausschliesslich Direct-Nachrichten dieses 1:1-Paares (per direct_conv_key).
Keine Heuristik, kein Broadcast-Fallback.
"""
s = _session_from_request(request)
pid = (practice_id or "").strip() or _resolve_practice_id(request)
if not pid:
raise HTTPException(status_code=400, detail="practice_id erforderlich")
me_uid = (sender_user_id or "").strip()
if not me_uid and s:
me_uid = str(s.get("user_id") or "").strip()
peer_uid = (recipient_user_id or "").strip()
if not me_uid:
raise HTTPException(status_code=400, detail="sender_user_id erforderlich")
if not peer_uid:
raise HTTPException(status_code=400, detail="recipient_user_id erforderlich")
if me_uid == peer_uid:
raise HTTPException(status_code=400, detail="Selbstchat nicht erlaubt")
acc_map = _accounts_by_practice(pid)
if me_uid not in acc_map:
raise HTTPException(status_code=400, detail="sender_user_id gehoert nicht zu dieser Praxis")
if peer_uid not in acc_map:
raise HTTPException(status_code=400, detail="recipient_user_id gehoert nicht zu dieser Praxis")
msgs, conv_key = _dm_v2_load_for_pair(pid, me_uid, peer_uid)
pulse = _pulse_get(pid)
_log.info(
"AZA_CHAT_LOAD mode=direct practice=%s me=%s peer=%s conv=%s count=%s",
pid, me_uid, peer_uid, conv_key, len(msgs),
)
return JSONResponse(
content={
"success": True,
"ok": True,
"mode": "direct",
"practice_id": pid,
"sender_user_id": me_uid,
"recipient_user_id": peer_uid,
"conversation_key": conv_key,
"messages": msgs,
"count": len(msgs),
"tick": int(pulse.get("tick", 0)),
"ts": pulse.get("ts", 0.0),
},
headers={
"Cache-Control": "no-store, no-cache, must-revalidate",
"Pragma": "no-cache",
},
)
@router.get("/thread/{thread_id}")
async def empfang_thread(thread_id: str, request: Request,
practice_id: Optional[str] = Query(None)):
pid = (practice_id or "").strip() or _resolve_practice_id(request)
if not pid:
return JSONResponse(content={"success": True, "messages": []})
messages = _load_messages()
thread = [m for m in messages
if m.get("thread_id") == thread_id and _msg_practice(m) == pid]
thread.sort(key=_msg_chrono_sort_key)
return JSONResponse(content={"success": True, "messages": thread})
@router.post("/messages/{msg_id}/done")
async def empfang_done(msg_id: str):
messages = _load_messages()
target = next((m for m in messages if m.get("id") == msg_id), None)
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 and _msg_practice(m) == pid:
m["status"] = "erledigt"
_save_messages(messages)
try:
_pulse_bump(pid, sender="")
except Exception:
pass
return JSONResponse(content={"success": True})
EMPFANG_MSG_TRANSCRIBE_MAX_BYTES = 2 * 1024 * 1024
def _attachment_is_audio_dict(a: object) -> bool:
if not isinstance(a, dict):
return False
if str(a.get("kind") or "").strip().lower() == "audio":
return True
mt = str(a.get("mime") or "").strip().lower()
if mt.startswith("audio/"):
return True
n = str(a.get("name") or "").strip().lower()
return bool(
re.search(r"\.(webm|ogg|opus|wav|mp3|m4a)$", n, re.I)
)
def _audio_suffix_for_attachment(att: dict) -> str:
"""Endung fuer die Tempfile abgeleitet aus dem MIME-Typ.
Wichtig: ``audio/webm`` und ``audio/webm;codecs=opus`` muessen ``.webm`` werden,
weil die Bytes ein WebM-Container sind (Edge/Chrome MediaRecorder). Erst danach
werden echte OGG-/Opus-Faelle wie ``audio/ogg`` oder ``audio/opus`` als ``.ogg``
bzw. ``.opus`` behandelt. Falsche Container-/Endungs-Kombination ist ein
bekannter Ausloeser fuer OpenAI BadRequestError.
"""
mt = str(att.get("mime") or "").lower()
if "webm" in mt:
return ".webm"
if "wav" in mt:
return ".wav"
if "mp4" in mt or "m4a" in mt or "aac" in mt:
return ".m4a"
if "ogg" in mt:
return ".ogg"
if "opus" in mt:
return ".opus"
if "mpeg" in mt or "mp3" in mt:
return ".mp3"
n = str(att.get("name") or "").lower()
if n.endswith(".wav"):
return ".wav"
if n.endswith(".m4a"):
return ".m4a"
if n.endswith(".ogg"):
return ".ogg"
if n.endswith(".opus"):
return ".opus"
if n.endswith(".mp3"):
return ".mp3"
return ".webm"
def _empfang_transcribe_openai_from_bytes(
audio_bytes: bytes,
*,
filename_suffix: str = ".webm",
) -> str:
"""Eine Audiodatei transkribieren (OpenAI wie backend /v1/transcribe). Tempfile wird geloescht."""
import backend_main as bm
tmp_path: Optional[str] = None
try:
client = bm._get_openai()
with tempfile.NamedTemporaryFile(
prefix="aza_ef_tr_",
suffix=filename_suffix,
delete=False,
) as tmp:
tmp.write(audio_bytes)
tmp_path = tmp.name
with open(tmp_path, "rb") as f:
is_gpt = "gpt-" in bm.TRANSCRIBE_MODEL
params: dict = dict(model=bm.TRANSCRIBE_MODEL, file=f, language="de")
dom = "medical"
chosen = (
bm.WHISPER_GENERAL_PROMPT
if dom == "general"
else bm.WHISPER_MEDICAL_PROMPT
)
if is_gpt:
params["prompt"] = bm.GPT_TRANSCRIBE_SHORT_PROMPT
else:
params["prompt"] = chosen
params["temperature"] = 0.0
resp = client.audio.transcriptions.create(**params)
text = getattr(resp, "text", "") or ""
if not text:
try:
if hasattr(resp, "model_dump"):
dd = resp.model_dump()
if isinstance(dd, dict):
text = dd.get("text", "") or ""
except Exception:
pass
t_stripped = text.lstrip()
if t_stripped.startswith(bm.WHISPER_PROMPT_PREFIX):
text = t_stripped[len(bm.WHISPER_PROMPT_PREFIX) :].lstrip(" :\t\r\n-")
text = (text or "").replace("ß", "ss")
text = bm.apply_medical_corrections(text, "")
text = bm.apply_medical_post_corrections(text)
text = bm.apply_medication_fuzzy_corrections(text)
return (text or "").strip()
finally:
if tmp_path:
try:
os.unlink(tmp_path)
except OSError:
pass
@router.post("/messages/{msg_id}/transcribe-audio")
async def empfang_message_transcribe_audio(msg_id: str, request: Request):
"""Transkribiert das erste Audio-Attachment einer Nachricht; speichert ``transcript`` am Attachment."""
_require_session(request)
pid = _require_practice_id(request)
messages = _load_messages()
target = next((m for m in messages if m.get("id") == msg_id), None)
if not target:
raise HTTPException(status_code=404, detail="Nachricht nicht gefunden")
if _msg_practice(target) != pid:
raise HTTPException(status_code=403, detail="Kein Zugriff")
ex = dict(target.get("extras") or {})
attachments = ex.get("attachments")
if not isinstance(attachments, list):
attachments = []
idx: Optional[int] = None
att: Optional[dict] = None
for i, raw_a in enumerate(attachments):
if isinstance(raw_a, dict) and _attachment_is_audio_dict(raw_a):
idx = i
att = raw_a
break
if idx is None or not att:
raise HTTPException(status_code=400, detail="Keine Audiodatei in dieser Nachricht")
existing = str(att.get("transcript") or "").strip()
if existing:
mid_short = (msg_id or "")[:12]
_log.info(
"EMPFANG_TRANSCRIBE_CACHE msg=%s att=%s",
mid_short,
idx,
)
return JSONResponse(
content={
"success": True,
"transcript": existing,
"cached": True,
"attachment_index": idx,
}
)
b64 = str(att.get("data") or "").strip()
if not b64:
raise HTTPException(status_code=400, detail="Audiodaten fehlen")
try:
raw = base64.b64decode(b64, validate=False)
except Exception:
raise HTTPException(status_code=400, detail="Audiodaten ungueltig")
nbytes = len(raw)
if nbytes < 1 or nbytes > EMPFANG_MSG_TRANSCRIBE_MAX_BYTES:
raise HTTPException(status_code=413, detail="Audiodatei zu gross")
suffix = _audio_suffix_for_attachment(att)
try:
transcript = _empfang_transcribe_openai_from_bytes(raw, filename_suffix=suffix)
except HTTPException:
raise
except Exception as exc:
mid_short = (msg_id or "")[:12]
err_type = type(exc).__name__
try:
err_short = str(exc)
except Exception:
err_short = ""
err_short = (err_short or "").strip()
if len(err_short) > 200:
err_short = err_short[:200]
if err_short:
err_short = err_short.replace("\n", " ").replace("\r", " ")
_log.warning(
"EMPFANG_TRANSCRIBE_FAIL msg=%s bytes=%s suffix=%s err=%s short=%s",
mid_short,
nbytes,
suffix,
err_type,
err_short,
)
raise HTTPException(
status_code=503,
detail=f"OpenAI: {err_type}",
) from exc
if not transcript:
mid_short = (msg_id or "")[:12]
_log.info("EMPFANG_TRANSCRIBE_EMPTY msg=%s bytes=%s", mid_short, nbytes)
raise HTTPException(status_code=502, detail="Leeres Transkript")
att_new = dict(att)
att_new["transcript"] = transcript
att_new["transcript_at"] = time.strftime("%Y-%m-%d %H:%M:%S")
attachments = list(attachments)
attachments[idx] = att_new
ex["attachments"] = attachments
target["extras"] = ex
_save_messages(messages)
try:
_pulse_bump(pid, sender="")
except Exception:
pass
mid_short = (msg_id or "")[:12]
_log.info(
"EMPFANG_TRANSCRIBE_OK msg=%s bytes=%s",
mid_short,
nbytes,
)
return JSONResponse(
content={
"success": True,
"transcript": transcript,
"cached": False,
"attachment_index": idx,
}
)
@router.post("/messages/{msg_id}/chat-ack")
async def empfang_message_chat_ack(msg_id: str, request: Request):
"""OK/Kenntnisnahme pro Nachricht (``extras.chat_ack``).
Optionale Bulk-Variante (rueckwaerts-kompatibel): Body
``{"ack": true, "scope": "thread_until_message"}`` quittiert zusaetzlich
*alle* aelteren eingehenden Nachrichten desselben ``direct_conv_key`` an
den aktuellen Empfaenger (Session-User), die noch ohne ``chat_ack`` sind
und chronologisch <= der geklickten Nachricht liegen.
- Eigene Nachrichten werden nie quittiert.
- Nichts ausserhalb der eigenen Praxis.
- Nichts in anderen Conversations / anderen Absendern.
- Bei ``ack=false`` (Toggle aus) wirkt nur die geklickte Nachricht.
"""
s = _require_session(request)
pid = _require_practice_id(request)
try:
body = await request.json()
except Exception:
body = {}
ack = bool(body.get("ack"))
scope = str((body.get("scope") or "")).strip().lower()
me_uid = str((s.get("user_id") if s else "") or "").strip()
messages = _load_messages()
target = next((m for m in messages if m.get("id") == msg_id), None)
if not target:
raise HTTPException(status_code=404, detail="Nachricht nicht gefunden")
if _msg_practice(target) != pid:
raise HTTPException(status_code=403, detail="Kein Zugriff")
bulk_targets: list[dict] = []
if ack and scope == "thread_until_message":
ex_clk = target.get("extras") or {}
clk_conv = str(ex_clk.get("direct_conv_key") or "").strip()
clk_recipient = str(ex_clk.get("recipient_user_id") or "").strip()
clk_sender = str(ex_clk.get("sender_user_id") or "").strip()
clk_chrono = _msg_chrono_sort_key(target)
# Bulk-Ack nur, wenn die geklickte Nachricht eine DM an den Session-User ist.
# Schutz gegen versehentliches Bulk auf eigene gesendete Nachrichten.
if clk_conv and me_uid and clk_recipient == me_uid and clk_sender and clk_sender != me_uid:
for m in messages:
if _msg_practice(m) != pid:
continue
ex_m = m.get("extras") or {}
if str(ex_m.get("direct_conv_key") or "").strip() != clk_conv:
continue
if str(ex_m.get("recipient_user_id") or "").strip() != me_uid:
continue
su_m = str(ex_m.get("sender_user_id") or "").strip()
if not su_m or su_m == me_uid:
continue
if su_m != clk_sender:
continue
if bool(ex_m.get("chat_ack")):
continue
if _msg_chrono_sort_key(m) > clk_chrono:
continue
bulk_targets.append(m)
affected = 0
target_id = str(target.get("id") or "").strip()
if bulk_targets:
for m in bulk_targets:
ex_m = dict(m.get("extras") or {})
ex_m["chat_ack"] = True
# Visuelle Markierung (Pastellgruen + Haekchen) NUR fuer die
# tatsaechlich geklickte Nachricht. Aeltere Bulk-Eintraege werden
# zwar quittiert (Alarm/Badge stoppt), bekommen aber kein gruenes
# Highlight, damit nur die angeklickte Nachricht hervorgehoben ist.
if str(m.get("id") or "").strip() == target_id:
ex_m["chat_ack_visual"] = True
else:
ex_m.pop("chat_ack_visual", None)
m["extras"] = ex_m
affected += 1
# Klick-Ziel ist normalerweise schon enthalten (chrono <= clk_chrono),
# aber sicherheitshalber separat sicherstellen:
ex_t = dict(target.get("extras") or {})
if not ex_t.get("chat_ack"):
ex_t["chat_ack"] = True
ex_t["chat_ack_visual"] = True
target["extras"] = ex_t
affected += 1
elif not ex_t.get("chat_ack_visual"):
ex_t["chat_ack_visual"] = True
target["extras"] = ex_t
else:
ex = dict(target.get("extras") or {})
if ack:
if not ex.get("chat_ack"):
ex["chat_ack"] = True
affected = 1
ex["chat_ack_visual"] = True
else:
if ex.pop("chat_ack", None) is not None:
affected = 1
ex.pop("chat_ack_visual", None)
target["extras"] = ex
_save_messages(messages)
try:
_pulse_bump(pid, sender="")
except Exception:
pass
mid_short = (msg_id or "")[:16]
pid_short = (pid or "")[:16]
_log.info(
"AZA_CHAT_ACK practice=%s msg=%s ack=%s scope=%s affected=%s",
pid_short, mid_short, ack, scope or "single", affected,
)
return JSONResponse(content={
"success": True,
"ack": ack,
"scope": scope or "single",
"affected": affected,
})
# Erlaubte Emoji-Reaktionen (kurze Whitelist; Server speichert nur diese).
_ALLOWED_REACTION_EMOJIS = {
"\U0001F44D", # 👍 thumbs up
"\u2764\uFE0F", # ❤️ red heart (mit VS16)
"\u2764", # ❤ ohne VS16
"\U0001F602", # 😂 laughing
"\U0001F62E", # 😮 surprised
"\U0001F622", # 😢 crying
"\U0001F64F", # 🙏 folded hands
}
@router.post("/messages/{msg_id}/reaction")
async def empfang_message_reaction(msg_id: str, request: Request):
"""Persistente Emoji-Reaktion eines Benutzers zu einer Nachricht.
Body: ``{"emoji": "<emoji>"}`` setzt/ersetzt; ``{"emoji": ""}`` entfernt.
Speicherung in ``extras.user_reactions = {user_id: emoji}``.
Ein Benutzer hat pro Nachricht max. eine Reaktion. Reaktionen sind
sichtbar fuer alle Mitglieder derselben Praxis.
Hinweis: Dieser Endpunkt setzt KEIN ``chat_ack``/OK-Quittierung;
das macht weiterhin /messages/{id}/chat-ack. Client darf bei 👍
beide Endpunkte aufrufen, um sowohl die Reaktion als auch die
Quittierung zu setzen.
"""
s = _require_session(request)
pid = _require_practice_id(request)
uid = str((s.get("user_id") if s else "") or "").strip()
if not uid:
raise HTTPException(status_code=400, detail="Session unvollstaendig")
try:
body = await request.json()
except Exception:
body = {}
raw_emoji = str((body.get("emoji") if isinstance(body, dict) else "") or "")
# Whitelist-Pruefung: leer = entfernen; alles andere muss erlaubt sein.
emoji = raw_emoji.strip()
if emoji and emoji not in _ALLOWED_REACTION_EMOJIS:
# Variant-Selector-Toleranz: ❤️ vs ❤
if emoji.rstrip("\uFE0F") in {e.rstrip("\uFE0F") for e in _ALLOWED_REACTION_EMOJIS}:
emoji = "\u2764\uFE0F" if emoji.startswith("\u2764") else emoji
else:
raise HTTPException(status_code=400, detail="Emoji nicht unterstuetzt")
messages = _load_messages()
target = next((m for m in messages if m.get("id") == msg_id), None)
if not target:
raise HTTPException(status_code=404, detail="Nachricht nicht gefunden")
if _msg_practice(target) != pid:
raise HTTPException(status_code=403, detail="Kein Zugriff")
ex = dict(target.get("extras") or {})
reactions = dict(ex.get("user_reactions") or {})
if emoji:
reactions[uid] = emoji
else:
reactions.pop(uid, None)
if reactions:
ex["user_reactions"] = reactions
else:
ex.pop("user_reactions", None)
target["extras"] = ex
_save_messages(messages)
try:
_pulse_bump(pid, sender="")
except Exception:
pass
_log.info(
"AZA_CHAT_REACTION practice=%s msg=%s set=%s",
(pid or "")[:16], (msg_id or "")[:16], bool(emoji),
)
return JSONResponse(content={
"success": True,
"emoji": emoji,
"user_reactions": reactions,
})
@router.delete("/messages/{msg_id}")
async def empfang_delete(msg_id: str):
messages = _load_messages()
target = next((m for m in messages if m.get("id") == msg_id), None)
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 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)
try:
_pulse_bump(pid, sender="")
except Exception:
pass
return JSONResponse(content={"success": True})
# =====================================================================
# TASKS (practice-scoped, server-side)
# =====================================================================
@router.get("/tasks")
async def empfang_tasks_list(request: Request):
pid = _resolve_practice_id(request)
if not pid:
return JSONResponse(content={"success": True, "tasks": []})
tasks = _load_tasks()
filtered = [t for t in tasks if t.get("practice_id") == pid]
return JSONResponse(content={"success": True, "tasks": filtered})
@router.post("/tasks")
async def empfang_tasks_create(request: Request):
pid = _require_practice_id(request)
s = _session_from_request(request)
try:
body = await request.json()
except Exception:
body = {}
text = (body.get("text") or "").strip()
if not text:
raise HTTPException(status_code=400, detail="Text erforderlich")
title_opt = (body.get("title") or "").strip()
meta_opt = (body.get("source_meta") or "").strip()
peer_opt = (body.get("source_peer") or "").strip()
stid_opt = (body.get("source_thread_id") or "").strip()
raw_kind = str(body.get("item_kind") or body.get("kind") or "task").strip().lower()
item_kind = "letter" if raw_kind == "letter" else "task"
task = {
"task_id": uuid.uuid4().hex[:12],
"practice_id": pid,
"text": text,
"title": title_opt or "",
"source_meta": meta_opt or "",
"source_peer": peer_opt or "",
"source_thread_id": stid_opt or "",
"done": False,
"assignee": (body.get("assignee") or "").strip(),
"created_by": (s or {}).get("display_name", "") if s else "",
"created": time.strftime("%Y-%m-%d %H:%M:%S"),
"source_msg_id": (body.get("source_msg_id") or "").strip(),
"item_kind": item_kind,
}
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 "title" in body:
target["title"] = (body.get("title") or "").strip()
if "assignee" in body:
target["assignee"] = (body.get("assignee") or "").strip()
if "source_meta" in body:
target["source_meta"] = (body.get("source_meta") 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})
# =====================================================================
# CHANNEL ENDPOINTS (Kanaele)
# =====================================================================
@router.get("/channels")
async def channels_list(request: Request):
"""Kanaele anzeigen, gefiltert nach Rolle des Benutzers."""
s = _require_session(request)
pid = s["practice_id"]
role = s.get("role", "mpa")
_ensure_default_channels(pid)
channels = _load_channels()
visible = []
for c in channels:
if c.get("practice_id") != pid:
continue
allowed = c.get("allowed_roles", [])
if not allowed or role in allowed:
visible.append(c)
return JSONResponse(content={"success": True, "channels": visible})
@router.post("/channels")
async def channels_create(request: Request):
"""Neuen Kanal erstellen (nur Admin)."""
s = _require_admin(request)
try:
body = await request.json()
except Exception:
body = {}
name = (body.get("name") or "").strip()
if not name:
raise HTTPException(status_code=400, detail="Kanalname erforderlich")
scope = body.get("scope", "internal")
if scope not in ("internal", "external"):
scope = "internal"
channel_type = body.get("channel_type", "group")
if channel_type not in ("group", "direct", "external"):
channel_type = "group"
allowed_roles = body.get("allowed_roles", [])
if not isinstance(allowed_roles, list):
allowed_roles = []
channel = {
"channel_id": uuid.uuid4().hex[:12],
"practice_id": s["practice_id"],
"name": name,
"scope": scope,
"channel_type": channel_type,
"allowed_roles": allowed_roles,
"connection_id": body.get("connection_id", ""),
"created": time.strftime("%Y-%m-%d %H:%M:%S"),
"created_by": s["user_id"],
}
channels = _load_channels()
channels.append(channel)
_save_channels(channels)
return JSONResponse(content={"success": True, "channel": channel})
@router.post("/channels/{channel_id}/update")
async def channels_update(channel_id: str, request: Request):
"""Kanal aktualisieren (nur Admin)."""
s = _require_admin(request)
try:
body = await request.json()
except Exception:
body = {}
channels = _load_channels()
target = None
for c in channels:
if c.get("channel_id") == channel_id and c.get("practice_id") == s["practice_id"]:
target = c
break
if not target:
raise HTTPException(status_code=404, detail="Kanal nicht gefunden")
if "name" in body:
new_name = (body["name"] or "").strip()
if new_name:
target["name"] = new_name
if "allowed_roles" in body:
ar = body["allowed_roles"]
if isinstance(ar, list):
target["allowed_roles"] = ar
_save_channels(channels)
return JSONResponse(content={"success": True, "channel": target})
@router.delete("/channels/{channel_id}")
async def channels_delete(channel_id: str, request: Request):
"""Kanal loeschen (nur Admin, keine Default-Kanaele)."""
s = _require_admin(request)
channels = _load_channels()
target = None
for c in channels:
if c.get("channel_id") == channel_id and c.get("practice_id") == s["practice_id"]:
target = c
break
if not target:
raise HTTPException(status_code=404, detail="Kanal nicht gefunden")
default_names = {d["name"] for d in _DEFAULT_CHANNEL_DEFS}
if target.get("name") in default_names and target.get("scope") == "internal":
raise HTTPException(status_code=400,
detail="Standard-Kanaele koennen nicht geloescht werden")
channels = [c for c in channels if c.get("channel_id") != channel_id]
_save_channels(channels)
return JSONResponse(content={"success": True, "deleted": channel_id})
# =====================================================================
# FEDERATION ENDPOINTS (Praxis-zu-Praxis-Verbindungen)
# =====================================================================
@router.post("/federation/invite")
async def federation_invite(request: Request):
"""Einladung zur Praxis-Verbindung erstellen."""
s = _require_admin(request)
try:
body = await request.json()
except Exception:
body = {}
pid = s["practice_id"]
practices = _load_practices()
practice_name = practices.get(pid, {}).get("name", "Unbekannte Praxis")
conn = {
"connection_id": uuid.uuid4().hex[:12],
"practice_a_id": pid,
"practice_b_id": "",
"status": "pending",
"invite_token": secrets.token_urlsafe(24),
"created_by": s["user_id"],
"accepted_by": "",
"created_at": time.strftime("%Y-%m-%d %H:%M:%S"),
"accepted_at": "",
"revoked_at": "",
"practice_a_name": practice_name,
"practice_b_name": "",
"message": (body.get("message") or "").strip(),
}
conns = _load_connections()
conns.append(conn)
_save_connections(conns)
return JSONResponse(content={
"success": True,
"connection_id": conn["connection_id"],
"invite_token": conn["invite_token"],
})
@router.post("/federation/accept")
async def federation_accept(request: Request):
"""Verbindungseinladung annehmen."""
s = _require_admin(request)
try:
body = await request.json()
except Exception:
body = {}
invite_token = (body.get("invite_token") or "").strip()
if not invite_token:
raise HTTPException(status_code=400, detail="invite_token erforderlich")
conns = _load_connections()
target = None
for c in conns:
if c.get("invite_token") == invite_token and c.get("status") == "pending":
target = c
break
if not target:
raise HTTPException(status_code=404, detail="Einladung nicht gefunden oder bereits verwendet")
pid_b = s["practice_id"]
if target["practice_a_id"] == pid_b:
raise HTTPException(status_code=400, detail="Kann eigene Einladung nicht annehmen")
practices = _load_practices()
practice_b_name = practices.get(pid_b, {}).get("name", "Unbekannte Praxis")
now = time.strftime("%Y-%m-%d %H:%M:%S")
target["practice_b_id"] = pid_b
target["practice_b_name"] = practice_b_name
target["status"] = "active"
target["accepted_by"] = s["user_id"]
target["accepted_at"] = now
_save_connections(conns)
channel_name = f"{target['practice_a_name']} \u2194 {practice_b_name}"
conn_id = target["connection_id"]
channels = _load_channels()
for practice_id in (target["practice_a_id"], pid_b):
channels.append({
"channel_id": uuid.uuid4().hex[:12],
"practice_id": practice_id,
"name": channel_name,
"scope": "external",
"channel_type": "external",
"allowed_roles": [],
"connection_id": conn_id,
"created": now,
"created_by": s["user_id"],
})
_save_channels(channels)
return JSONResponse(content={
"success": True,
"connection_id": conn_id,
"practice_a": target["practice_a_name"],
"practice_b": practice_b_name,
})
@router.get("/federation/connections")
async def federation_connections(request: Request):
"""Alle Verbindungen der eigenen Praxis anzeigen."""
s = _require_admin(request)
pid = s["practice_id"]
conns = _load_connections()
result = [c for c in conns
if c.get("practice_a_id") == pid or c.get("practice_b_id") == pid]
return JSONResponse(content={"success": True, "connections": result})
@router.post("/federation/connections/{connection_id}/revoke")
async def federation_revoke(connection_id: str, request: Request):
"""Verbindung widerrufen / trennen."""
s = _require_admin(request)
pid = s["practice_id"]
conns = _load_connections()
target = None
for c in conns:
if c.get("connection_id") == connection_id:
if c.get("practice_a_id") == pid or c.get("practice_b_id") == pid:
target = c
break
if not target:
raise HTTPException(status_code=404, detail="Verbindung nicht gefunden")
if target["status"] == "revoked":
raise HTTPException(status_code=400, detail="Verbindung bereits widerrufen")
target["status"] = "revoked"
target["revoked_at"] = time.strftime("%Y-%m-%d %H:%M:%S")
_save_connections(conns)
return JSONResponse(content={"success": True, "connection_id": connection_id, "status": "revoked"})
@router.get("/federation/practices")
async def federation_practices(request: Request):
"""Verbundene Praxen anzeigen (fuer alle authentifizierten Benutzer)."""
s = _require_session(request)
pid = s["practice_id"]
conns = _load_connections()
result = []
for c in conns:
if c.get("status") != "active":
continue
if c.get("practice_a_id") == pid:
result.append({
"practice_id": c.get("practice_b_id"),
"practice_name": c.get("practice_b_name", ""),
"connection_id": c.get("connection_id"),
"status": c.get("status"),
})
elif c.get("practice_b_id") == pid:
result.append({
"practice_id": c.get("practice_a_id"),
"practice_name": c.get("practice_a_name", ""),
"connection_id": c.get("connection_id"),
"status": c.get("status"),
})
return JSONResponse(content={"success": True, "practices": result})
# =====================================================================
# CLEANUP + PRACTICE INFO
# =====================================================================
@router.post("/cleanup")
async def empfang_cleanup(request: Request):
try:
body = await request.json()
except Exception:
body = {}
max_days = int(body.get("max_age_days", 30))
pid = (body.get("practice_id") or "").strip() or _resolve_practice_id(request)
if not pid:
return JSONResponse(content={"success": True, "removed": 0, "remaining": 0})
cutoff_ts = time.time() - max_days * 86400
messages = _load_messages()
before = len(messages)
kept = []
for m in messages:
if _msg_practice(m) != pid:
kept.append(m)
continue
raw_t = (m.get("empfangen") or m.get("zeitstempel") or "").strip()
ts = _parse_msg_instant_utc_ts(raw_t)
if ts <= 0 or ts >= cutoff_ts:
kept.append(m)
removed = before - len(kept)
if removed > 0:
_save_messages(kept)
return JSONResponse(content={
"success": True, "removed": removed, "remaining": len(kept),
})
@router.get("/practice/info")
async def empfang_practice_info(request: Request):
api_token = request.headers.get("X-API-Token", "")
s = _session_from_request(request)
pid = _resolve_practice_id(request)
if not pid:
return JSONResponse(content={"practice_id": "", "practice_name": "",
"user_count": 0, "message_count": 0, "open_count": 0})
_ensure_practice(pid)
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", ""),
"practice_specialty": str(p.get("specialty") or "").strip(),
"practice_timezone": str(p.get("timezone") or "").strip(),
"user_count": len(users),
"message_count": len(messages),
"open_count": open_count,
}
try:
from stripe_routes import lookup_license_email_for_practice
lcm = (lookup_license_email_for_practice(pid) or "").strip()
if lcm:
result["license_customer_email"] = lcm
except Exception:
pass
role_l = str(s.get("role") or "").strip().lower() if s else ""
is_sess_admin = bool(s and _is_admin_session(s))
show_invite = bool(api_token) or role_l in ("admin", "empfang") or is_sess_admin
if show_invite:
result["invite_code"] = p.get("invite_code", "")
if api_token or is_sess_admin:
result["admin_email"] = p.get("admin_email", "")
if api_token:
alerts = list(p.get("pdevice_alerts") or [])
result["pending_new_device_count"] = len(alerts)
if alerts:
by_uid = {u["user_id"]: u["display_name"] for u in _practice_users(pid)}
tail = alerts[-15:]
result["pending_new_devices"] = [
{
"user_id": str(x.get("user_id") or ""),
"display_name": by_uid.get(str(x.get("user_id") or ""), ""),
"device_suffix": str(x.get("device_suffix") or ""),
"ip": str(x.get("ip") or ""),
}
for x in tail
]
return JSONResponse(content=result)
@router.post("/practice/update_public_name")
async def empfang_update_public_practice_name(request: Request):
"""Setzt den oeffentlichen Praxisnamen fuer Chat/Empfang (Desktop-API-Token oder Admin-Session)."""
pid = _require_practice_admin_or_api_token(request)
try:
body = await request.json()
except Exception:
body = {}
pname = " ".join(
((body.get("practice_name") or body.get("name") or "").strip()).split()
)
if not pname or len(pname) > 240:
raise HTTPException(
status_code=400,
detail="practice_name erforderlich (1240 Zeichen)",
)
spec_in = (
body.get("specialty") or body.get("practice_specialty") or ""
)
spec = " ".join(str(spec_in or "").strip().split()) if spec_in else ""
if spec and len(spec) > 160:
raise HTTPException(
status_code=400,
detail="specialty max. 160 Zeichen",
)
_ensure_practice(pid)
practices = _load_practices()
entry = practices.get(pid) or {}
entry["practice_id"] = pid
entry["name"] = pname
if spec:
entry["specialty"] = spec
practices[pid] = entry
_save_practices(practices)
return JSONResponse(content={
"success": True, "practice_id": pid, "practice_name": pname,
"practice_specialty": str((practices.get(pid) or {}).get("specialty") or "").strip(),
})
@router.get("/practice/profile")
async def empfang_practice_profile_get(request: Request):
"""Aggregiert Praxis- und (optional) Benutzerprofil fuer Desktop/Session. Nur Metadaten."""
pid = _presence_debug_resolve_practice_auth(request)
_ensure_practice(pid)
practices = _load_practices()
p = dict(practices.get(pid) or {})
uid = (request.headers.get("X-AzA-Empfang-User-Id") or "").strip()
s = _session_from_request(request)
if s and not uid:
uid = str(s.get("user_id") or "").strip()
user_pub = None
if uid:
accounts0 = _load_accounts()
acc0 = accounts0.get(uid)
if acc0 and (acc0.get("practice_id") or "").strip() == pid:
user_pub = _user_profile_public_shape(acc0)
else:
uid = ""
user_pub = None
license_customer_email = ""
try:
from stripe_routes import lookup_license_email_for_practice
license_customer_email = (
lookup_license_email_for_practice(pid) or ""
).strip()
except Exception:
pass
practice_pub = _practice_profile_public_shape(p, pid)
warns = _practice_profile_warnings_payload(
practice_pub, user_pub, license_customer_email,
)
return JSONResponse(content={
"success": True,
"practice_id": pid,
"practice": practice_pub,
"license_customer_email": license_customer_email,
"user": user_pub,
"warnings": warns,
"read_only": True,
})
@router.patch("/practice/profile")
async def empfang_practice_profile_patch(request: Request):
"""Whitelist-PATCH fuer Praxis- und eigenes Benutzerprofil (API-Token/Admin)."""
pid = _require_practice_admin_or_api_token(request)
_ensure_practice(pid)
try:
body = await request.json()
except Exception:
body = {}
practice_in = body.get("practice") if isinstance(body.get("practice"), dict) else {}
user_in = body.get("user") if isinstance(body.get("user"), dict) else {}
actor_uid = (request.headers.get("X-AzA-Empfang-User-Id") or "").strip()
sess = _session_from_request(request)
if sess and not actor_uid:
actor_uid = str(sess.get("user_id") or "").strip()
patched_user_id = ""
if practice_in:
practices = _load_practices()
entry = dict(practices.get(pid) or {})
entry["practice_id"] = pid
if "name" in practice_in:
nm = " ".join(str(practice_in.get("name") or "").strip().split())
if nm and len(nm) <= 240:
entry["name"] = nm
if "specialty" in practice_in:
sp = " ".join(str(practice_in.get("specialty") or "").strip().split())
if sp and len(sp) > 160:
raise HTTPException(status_code=400, detail="specialty max. 160")
if sp:
entry["specialty"] = sp
for fld, maxlen in (
("phone", 120),
("address", 500),
("website", 500),
("contact_email", 240),
):
if fld in practice_in:
v = " ".join(str(practice_in.get(fld) or "").strip().split())
if len(v) > maxlen:
raise HTTPException(status_code=400, detail=f"{fld} zu lang")
entry[fld] = v
entry["profile_updated_at"] = datetime.now(timezone.utc).strftime(
"%Y-%m-%dT%H:%M:%SZ",
)
if actor_uid:
entry["profile_updated_by_user_id"] = actor_uid[:32]
practices[pid] = entry
_save_practices(practices)
if user_in:
uid_tgt = str(user_in.get("user_id") or "").strip() or actor_uid
if not uid_tgt:
raise HTTPException(
status_code=400,
detail="user.user_id oder X-AzA-Empfang-User-Id erforderlich",
)
api_raw = (request.headers.get("X-API-Token") or "").strip()
hdr_uid = (request.headers.get("X-AzA-Empfang-User-Id") or "").strip()
if api_raw and uid_tgt != hdr_uid:
raise HTTPException(
status_code=403,
detail="Desktop-API: nur das eigene Benutzerprofil (user_id=Header)",
)
if sess and not api_raw:
su = str(sess.get("user_id") or "").strip()
if su != uid_tgt and not _is_admin_session(sess):
raise HTTPException(
status_code=403,
detail="Nur eigenes Profil oder Admin-Sitzung",
)
accounts = _load_accounts()
tgt = accounts.get(uid_tgt)
if not tgt or (tgt.get("practice_id") or "").strip() != pid:
raise HTTPException(status_code=404, detail="Benutzer nicht gefunden")
if "display_name" in user_in:
dn = " ".join(str(user_in.get("display_name") or "").strip().split())
if not dn:
raise HTTPException(status_code=400, detail="display_name erforderlich")
if len(dn) > 200:
raise HTTPException(status_code=400, detail="display_name zu lang")
tgt["display_name"] = dn
if "title" in user_in:
ti = " ".join(str(user_in.get("title") or "").strip().split())
if len(ti) > 80:
raise HTTPException(status_code=400, detail="title zu lang")
tgt["title"] = ti
sp_key = None
if "specialty_user" in user_in:
sp_key = user_in.get("specialty_user")
elif "specialty" in user_in:
sp_key = user_in.get("specialty")
if sp_key is not None:
sp = " ".join(str(sp_key or "").strip().split())
if len(sp) > 160:
raise HTTPException(status_code=400, detail="specialty max. 160")
tgt["specialty"] = sp
if "email" in user_in:
raw_em = user_in.get("email")
tgt["email"] = (raw_em or "").strip() if isinstance(raw_em, str) else ""
if "job_function" in user_in:
jf = " ".join(str(user_in.get("job_function") or "").strip().split())
if len(jf) > 160:
raise HTTPException(status_code=400, detail="job_function zu lang")
tgt["job_function"] = jf
tgt["profile_updated_at"] = datetime.now(timezone.utc).strftime(
"%Y-%m-%dT%H:%M:%SZ",
)
accounts[uid_tgt] = tgt
_save_accounts(accounts)
patched_user_id = uid_tgt
practices_out = _load_practices()
p_out = dict(practices_out.get(pid) or {})
practice_pub = _practice_profile_public_shape(p_out, pid)
license_customer_email = ""
try:
from stripe_routes import lookup_license_email_for_practice
license_customer_email = (
lookup_license_email_for_practice(pid) or ""
).strip()
except Exception:
pass
user_pub = None
show_uid = patched_user_id or actor_uid
if show_uid:
au = _load_accounts().get(show_uid)
if au and (au.get("practice_id") or "").strip() == pid:
user_pub = _user_profile_public_shape(au)
return JSONResponse(content={
"success": True,
"practice_id": pid,
"practice": practice_pub,
"license_customer_email": license_customer_email,
"user": user_pub,
"warnings": _practice_profile_warnings_payload(
practice_pub, user_pub, license_customer_email,
),
})
@router.post("/practice/clear_device_alerts")
async def empfang_clear_device_alerts(request: Request):
"""Loescht neue-Geraet-Hinweise fuer die mandantenrichtige Praxis (Desktop/API-Token)."""
pid = _presence_debug_resolve_practice_auth(request)
practices = _load_practices()
pdata = practices.get(pid)
if isinstance(pdata, dict):
pdata["pdevice_alerts"] = []
practices[pid] = pdata
_save_practices(practices)
return JSONResponse(content={"success": True})
# Shell session (kurzlebiger Desktop-Web-Huelle-Bootstrap, API-validiert)
# =====================================================================
#
# Nur POST /shell/session darf mittels gueltigem MEDWORK-X-API-Token + Practice
# einen shell_token ausstellen (kein Browser-Cookie als Identitaetsgrundlage).
#
# POST /shell/consume tauscht bearer shell_token gegen normale HttpOnly-Session —
# ohne API-Token, damit spaetere Web-Huelle den Token ohne Desktop-Shared-Secret
# einloesen kann.
_SHELL_TTL_DEFAULT = 300
_SHELL_PURPOSE = "send_to_reception_shell"
_shell_store: dict[str, dict] = {}
# Kurzlebiger Desktop-Kontext fuer WebView (Therapie/Procedere, RAM, kein Persist).
_DESKTOP_SHELL_CONTEXT_TTL_SECONDS = 900.0
_desktop_shell_latest_context_by_user: dict[tuple[str, str], dict] = {}
def _shell_cleanup_expired() -> None:
now = time.time()
stale = [
tok for tok, rec in _shell_store.items()
if isinstance(rec, dict) and float(rec.get("expires_at", 0)) < now
]
for tok in stale:
try:
del _shell_store[tok]
except KeyError:
pass
def _desktop_shell_context_cleanup() -> None:
now = time.time()
stale = [
k for k, rec in _desktop_shell_latest_context_by_user.items()
if isinstance(rec, dict) and float(rec.get("expires_at", 0)) < now
]
for k in stale:
try:
del _desktop_shell_latest_context_by_user[k]
except KeyError:
pass
def _require_shell_api_identity(request: Request) -> Tuple[str, str]:
"""Validiert X-API-Token wie aza_security; liefert (practice_id, desktop_user_id)."""
api_raw = (request.headers.get("X-API-Token") or "").strip()
if not api_raw:
raise HTTPException(status_code=401, detail="API-Token erforderlich")
try:
from aza_security import get_required_api_tokens
allowed = get_required_api_tokens()
except RuntimeError:
raise HTTPException(status_code=503, detail="API token nicht konfiguriert")
if not any(hmac.compare_digest(api_raw, t) for t in allowed):
raise HTTPException(status_code=401, detail="Unauthorized")
pid = request.headers.get("X-Practice-Id", "").strip()
if not pid:
raise HTTPException(
status_code=400,
detail="X-Practice-Id erforderlich",
)
claimed_uid = (request.headers.get("X-AzA-Empfang-User-Id") or "").strip()
if not claimed_uid:
raise HTTPException(
status_code=403,
detail=(
"X-AzA-Empfang-User-Id fehlt: Desktop muss vom Server ermittelte "
"user_id aus dem Provisionierungs-/Users-Endpunkt mitschicken."
),
)
accounts = _load_accounts()
acc = accounts.get(claimed_uid)
if not acc or (acc.get("practice_id") or "").strip() != pid:
raise HTTPException(
status_code=403,
detail="Benutzer gehoert nicht zu dieser Praxis oder user_id ungueltig",
)
if (acc.get("status") or "active") != "active":
raise HTTPException(status_code=403, detail="Benutzerkonto nicht aktiv")
return pid, claimed_uid
def _session_or_shell_identity(request: Request) -> dict:
"""Cookie-Session (Browser) oder Desktop mit X-API-Token + X-Practice-Id + User-Id."""
s = _session_from_request(request)
if s:
pid = (s.get("practice_id") or "").strip()
uid = (s.get("user_id") or "").strip()
if pid and uid:
return s
api_raw = (request.headers.get("X-API-Token") or "").strip()
if api_raw:
pid, uid = _require_shell_api_identity(request)
return {"practice_id": pid, "user_id": uid}
raise HTTPException(status_code=401, detail="Nicht angemeldet")
def _consume_shell_token_core(request: Request, shell_raw: str) -> Tuple[str, dict]:
"""POP shell_token, erstelle Serversession. Liefert (aza_session-Wert, Kennzeichen ohne Geheimnis)."""
_shell_cleanup_expired()
stok = (shell_raw or "").strip()
if not stok:
raise HTTPException(
status_code=400,
detail="shell_token fehlt",
)
rec = _shell_store.get(stok)
if not rec:
raise HTTPException(
status_code=401,
detail="Shell-Token ungueltig, verbraucht oder abgelaufen",
)
if time.time() > float(rec.get("expires_at", 0)):
try:
del _shell_store[stok]
except KeyError:
pass
raise HTTPException(status_code=401, detail="Shell-Token abgelaufen")
try:
del _shell_store[stok]
except KeyError:
raise HTTPException(
status_code=401,
detail="Shell-Token ungueltig, verbraucht oder abgelaufen",
)
uid = (rec.get("user_id") or "").strip()
pid = (rec.get("practice_id") or "").strip()
dn = (rec.get("display_name") or "").strip()
role = (rec.get("role") or "mpa").strip()
ua = request.headers.get("User-Agent") or "AzA-Shell-Client"
ip = _extract_client_ip(request)
sess_token = _create_session(
uid,
pid,
dn,
role,
device_id="",
user_agent=ua,
ip_addr=ip,
)
_log.info(
"AZA_SHELL_SESSION_CONSUMED practice=%s user=%s purpose=%s",
pid,
uid,
_SHELL_PURPOSE,
)
public = {
"user_id": uid,
"practice_id": pid,
"display_name": dn,
"role": role,
"purpose": _SHELL_PURPOSE,
}
return sess_token, public
@router.post("/shell/session")
async def empfang_shell_session_create(request: Request):
"""Erzeugt kurzlebigen shell_token (nur mit Desktop-API-Token + validierter user_id)."""
_shell_cleanup_expired()
pid, uid = _require_shell_api_identity(request)
accounts = _load_accounts()
acc = accounts.get(uid) or {}
display_name = (acc.get("display_name") or "").strip() or "Benutzer"
role = (acc.get("role") or "mpa").strip()
now = time.time()
expires_at = now + float(_SHELL_TTL_DEFAULT)
shell_token = secrets.token_urlsafe(32)
_shell_store[shell_token] = {
"practice_id": pid,
"user_id": uid,
"display_name": display_name,
"role": role,
"purpose": _SHELL_PURPOSE,
"expires_at": expires_at,
"created_at": now,
}
expires_iso = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(expires_at))
_log.info(
"AZA_SHELL_SESSION_CREATED practice=%s user=%s purpose=%s ttl=%s",
pid,
uid,
_SHELL_PURPOSE,
int(_SHELL_TTL_DEFAULT),
)
return JSONResponse(content={
"shell_token": shell_token,
"expires_at": expires_iso,
"expires_at_unix": int(expires_at),
"ttl_seconds": int(_SHELL_TTL_DEFAULT),
"practice_id": pid,
"user_id": uid,
"display_name": display_name,
"role": role,
"purpose": _SHELL_PURPOSE,
})
@router.post("/shell/context")
async def empfang_shell_context_upload(request: Request):
"""Desktop: Therapie/Procedere-Vorschau fuer WebView-Huelle (RAM, TTL, keine URL).
Keine medizinischen Inhalte loggen."""
_desktop_shell_context_cleanup()
pid, uid = _require_shell_api_identity(request)
body: dict
try:
body_raw = await request.json()
body = body_raw if isinstance(body_raw, dict) else {}
except Exception:
body = {}
therapy_raw = body.get("therapy_text")
proc_raw = body.get("procedure_text")
therapy_text = (therapy_raw if isinstance(therapy_raw, str) else str(therapy_raw or ""))[:120000]
procedure_text = (proc_raw if isinstance(proc_raw, str) else str(proc_raw or ""))[:120000]
therapy_autocopy = body.get("therapy_autocopy") in (True, "true", "1", 1)
procedure_autocopy = body.get("procedure_autocopy") in (True, "true", "1", 1)
dm_uid_raw = body.get("dm_open_peer_user_id")
dm_dn_raw = body.get("dm_open_display_name")
dm_open_peer_user_id = (
str(dm_uid_raw if dm_uid_raw is not None else "").strip()[:64]
)
dm_open_display_name = (
str(dm_dn_raw if dm_dn_raw is not None else "").strip()[:200]
)
now = time.time()
expires_at = now + float(_DESKTOP_SHELL_CONTEXT_TTL_SECONDS)
ctx_id = "ctx_" + secrets.token_urlsafe(24)
record = {
"context_id": ctx_id,
"practice_id": pid,
"user_id": uid,
"therapy_text": therapy_text,
"procedure_text": procedure_text,
"therapy_autocopy": therapy_autocopy,
"procedure_autocopy": procedure_autocopy,
"dm_open_peer_user_id": dm_open_peer_user_id,
"dm_open_display_name": dm_open_display_name,
"expires_at": expires_at,
"updated_at": now,
}
_desktop_shell_latest_context_by_user[(pid, uid)] = record
_log.info(
"DESKTOP_SHELL_CONTEXT_PUT practice=%s user=%s len_ctx_id=%s nonempty=%s/%s",
pid,
uid,
len(ctx_id),
int(bool(therapy_text.strip())),
int(bool(procedure_text.strip())),
)
return JSONResponse(
content={
"success": True,
"context_id": ctx_id,
"ttl_seconds": int(_DESKTOP_SHELL_CONTEXT_TTL_SECONDS),
},
headers={"Cache-Control": "no-store, no-cache, must-revalidate"},
)
@router.get("/shell/context/me")
async def empfang_shell_context_me(request: Request):
"""Shell-WebView: eigener Kontext fuer angemeldete Session (Practice/User-Match).
Nur Metadaten im Server-Log."""
_desktop_shell_context_cleanup()
s = _require_session(request)
pid = (s.get("practice_id") or "").strip()
uid = (s.get("user_id") or "").strip()
rec = _desktop_shell_latest_context_by_user.get((pid, uid))
now = time.time()
if not rec or float(rec.get("expires_at", 0)) < now:
return JSONResponse(
content={
"success": True,
"context_id": "",
"therapy_text": "",
"procedure_text": "",
"therapy_autocopy": False,
"procedure_autocopy": False,
"dm_open_peer_user_id": "",
"dm_open_display_name": "",
},
headers={"Cache-Control": "no-store, no-cache, must-revalidate"},
)
cid = str(rec.get("context_id") or "")
_log.info(
"DESKTOP_SHELL_CONTEXT_GET practice=%s user=%s len_ctx_id=%s",
pid,
uid,
len(cid),
)
dm_uid_out = str(rec.get("dm_open_peer_user_id") or "")
dm_dn_out = str(rec.get("dm_open_display_name") or "")
if dm_uid_out:
rec["dm_open_peer_user_id"] = ""
rec["dm_open_display_name"] = ""
_desktop_shell_latest_context_by_user[(pid, uid)] = rec
return JSONResponse(
content={
"success": True,
"context_id": cid,
"therapy_text": str(rec.get("therapy_text") or ""),
"procedure_text": str(rec.get("procedure_text") or ""),
"therapy_autocopy": bool(rec.get("therapy_autocopy")),
"procedure_autocopy": bool(rec.get("procedure_autocopy")),
"dm_open_peer_user_id": dm_uid_out,
"dm_open_display_name": dm_dn_out,
},
headers={"Cache-Control": "no-store, no-cache, must-revalidate"},
)
@router.post("/shell/consume")
async def empfang_shell_consume(request: Request):
"""Tauscht shell_token gegen aza_session (HttpOnly Cookie). Einmal-Verbrauch."""
try:
body = await request.json()
except Exception:
body = {}
shell_raw = ""
if isinstance(body, dict):
shell_raw = (body.get("shell_token") or "").strip()
if not shell_raw:
raise HTTPException(
status_code=400,
detail="shell_token im JSON Body erforderlich",
)
sess_token, pub = _consume_shell_token_core(request, shell_raw)
resp = JSONResponse(content={"success": True, **pub})
resp.set_cookie(
"aza_session",
sess_token,
httponly=True,
samesite="lax",
max_age=SESSION_MAX_AGE,
)
return resp
@router.get("/shell/launch")
async def empfang_shell_launch(
request: Request,
token: str = Query("", description="kurzlebiger Shell-Token (einmaliger Verbrauch)"),
target: str = Query("", description="optional: 'empfang_chat_shell' fuer Empfang-Huelle"),
):
"""Web-Huelle: Token per GET einloesen — setzt Cookie, Redirect ohne Token in URL.
Einmal-Verbrauch. Kein MEDWORK_API-Token hier (Browser/WebView ohne Desktop-Secret).
target=empfang_chat_shell markiert die separate Empfang-Chat-Huelle (kein Arzt-Desktop).
"""
sess_token, _pub = _consume_shell_token_core(request, token)
t = (target or "").strip().lower()
if t == "empfang_chat_shell":
loc = "/empfang/?empfang_chat_shell=1&shell_source=empfang_chat_shell"
else:
loc = "/empfang/?desktop_shell=1&shell_source=aza_desktop"
resp = RedirectResponse(url=loc, status_code=302)
resp.set_cookie(
"aza_session",
sess_token,
httponly=True,
samesite="lax",
max_age=SESSION_MAX_AGE,
)
return resp
# =====================================================================
# Empfang-Chat-Huelle: Browser->native-Huelle Handoff
# =====================================================================
#
# Ziel: Eingeloggter Browser-Empfang kann die separat installierte
# native Empfang-Chat-Huelle (AZA_EmpfangShell.exe) mit derselben Praxis-/
# Chat-Session verbinden, OHNE Arztlizenz / X-API-Token.
#
# Sicherheitsmodell:
# - /empfang/handoff/create erfordert eingeloggte Empfang-Session (Cookie).
# - Erzeugt einen kurzlebigen shell_token (gleiches _shell_store wie Desktop-
# Launch) plus einen kurzen, lesbaren Verbindungscode XXXX-XXXX, der nur
# auf den shell_token verweist (eigener Store, einmalig konsumiert).
# - /empfang/handoff/lookup loest den Verbindungscode in den shell_token auf.
# Kein Login noetig - der Code IST das Geheimnis. Einmal-Verbrauch.
# - Der shell_token selbst wird wie gewohnt durch /empfang/shell/launch
# einmal verbraucht (HttpOnly Cookie in der nativen Huelle gesetzt).
# - Keine Chatdaten werden im Handoff transportiert.
_HANDOFF_TTL_DEFAULT = 300 # 5 Minuten
_HANDOFF_PURPOSE = "empfang_chat_shell_handoff"
_handoff_short_codes: dict[str, dict] = {}
def _handoff_short_codes_cleanup() -> None:
now = time.time()
stale = [
c for c, rec in _handoff_short_codes.items()
if isinstance(rec, dict) and float(rec.get("expires_at", 0)) < now
]
for c in stale:
try:
del _handoff_short_codes[c]
except KeyError:
pass
def _normalize_short_code(raw: str) -> str:
"""Vergleichbar machen: Grossbuchstaben, Bindestrich-Varianten -> '-', Leerzeichen weg."""
s = (raw or "").strip().upper().replace(" ", "")
for ch in ("\u2011", "\u2013", "\u2014", "\u2212", "_"):
s = s.replace(ch, "-")
return s
def _generate_short_handoff_code() -> str:
"""8 Zeichen lesbar, ohne 0/O/1/I-Verwechslungen, im Format XXXX-XXXX."""
import random
chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
a = "".join(random.choices(chars, k=4))
b = "".join(random.choices(chars, k=4))
return f"{a}-{b}"
@router.post("/handoff/create")
async def empfang_handoff_create(request: Request):
"""Browser-Empfang -> Empfang-Chat-Huelle: kurzlebiger Handoff-Token + Verbindungscode.
Erfordert eine bestehende Empfang-Session (HttpOnly-Cookie).
Keine Arzt-Lizenz / kein X-API-Token. Kein Chat-Inhalt im Token.
"""
_shell_cleanup_expired()
_handoff_short_codes_cleanup()
s = _require_session(request)
pid = (s.get("practice_id") or "").strip()
uid = (s.get("user_id") or "").strip()
if not pid or not uid:
raise HTTPException(status_code=400, detail="Session unvollstaendig")
accounts = _load_accounts()
acc = accounts.get(uid) or {}
display_name = (acc.get("display_name")
or s.get("display_name") or "").strip() or "Benutzer"
role = (acc.get("role") or s.get("role") or "mpa").strip()
if (acc.get("status") or "active") != "active":
raise HTTPException(status_code=403, detail="Benutzerkonto nicht aktiv")
now = time.time()
expires_at = now + float(_HANDOFF_TTL_DEFAULT)
shell_token = secrets.token_urlsafe(32)
# Eindeutigen kurzen Verbindungscode erzeugen
short_code = _generate_short_handoff_code()
tries = 0
while short_code in _handoff_short_codes and tries < 8:
short_code = _generate_short_handoff_code()
tries += 1
_shell_store[shell_token] = {
"practice_id": pid,
"user_id": uid,
"display_name": display_name,
"role": role,
"purpose": _HANDOFF_PURPOSE,
"expires_at": expires_at,
"created_at": now,
}
_handoff_short_codes[short_code] = {
"shell_token": shell_token,
"expires_at": expires_at,
"practice_id": pid,
"user_id": uid,
}
expires_iso = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(expires_at))
launch_path = (
f"/empfang/shell/launch?token={shell_token}&target=empfang_chat_shell"
)
_log.info(
"AZA_EMPFANG_HANDOFF_CREATED practice=%s user=%s purpose=%s ttl=%s",
pid, uid, _HANDOFF_PURPOSE, int(_HANDOFF_TTL_DEFAULT),
)
return JSONResponse(content={
"success": True,
"short_code": short_code,
"launch_path": launch_path,
"expires_at": expires_iso,
"expires_at_unix": int(expires_at),
"ttl_seconds": int(_HANDOFF_TTL_DEFAULT),
"purpose": _HANDOFF_PURPOSE,
})
@router.get("/handoff/lookup")
async def empfang_handoff_lookup(
request: Request,
code: str = Query("", description="kurzer Verbindungscode XXXX-XXXX"),
):
"""Empfang-Chat-Huelle: Verbindungscode in shell_token aufloesen.
Kein Cookie/Session noetig — der Code IST das Geheimnis. Einmal-Verbrauch
der Code->Token-Zuordnung. Der shell_token wird erst spaeter durch
/empfang/shell/launch eingeloest.
"""
_shell_cleanup_expired()
_handoff_short_codes_cleanup()
c = _normalize_short_code(code)
if not c:
raise HTTPException(status_code=400, detail="code fehlt")
rec = _handoff_short_codes.get(c)
if not rec:
raise HTTPException(
status_code=404,
detail="Verbindungscode ungueltig oder bereits eingeloest",
)
if time.time() > float(rec.get("expires_at", 0)):
try:
del _handoff_short_codes[c]
except KeyError:
pass
raise HTTPException(status_code=410, detail="Verbindungscode abgelaufen")
shell_token = (rec.get("shell_token") or "").strip()
try:
del _handoff_short_codes[c]
except KeyError:
pass
if not shell_token or shell_token not in _shell_store:
raise HTTPException(
status_code=410,
detail="Verbindungscode war gueltig, aber zugehoerige Session abgelaufen",
)
launch_path = (
f"/empfang/shell/launch?token={shell_token}&target=empfang_chat_shell"
)
_log.info("AZA_EMPFANG_HANDOFF_REDEEMED purpose=%s", _HANDOFF_PURPOSE)
return JSONResponse(content={
"success": True,
"launch_path": launch_path,
"purpose": _HANDOFF_PURPOSE,
})
# =====================================================================
# Textbloecke (pro practice_id + user_id, serverseitig)
# =====================================================================
#
# Persistente Textbloecke werden in einer JSON-Datei pro Praxis und
# Benutzer gespeichert: {practice_id: {user_id: [ {id,title,body,
# sort_order,created_at,updated_at}, ... ]}}.
#
# Sicherheitsmodell:
# - Alle Routen erfordern eine bestehende Empfang-Session (Cookie).
# - Ein Benutzer sieht ausschliesslich seine eigenen Textbloecke.
# - Inhalte werden im aza_audit_log NICHT geloggt (keine Patientendaten).
_TEXTBLOCKS_FILE = _DATA_DIR / "empfang_textblocks.json"
_TEXTBLOCK_MAX_TITLE = 200
_TEXTBLOCK_MAX_BODY = 8000
_TEXTBLOCK_MAX_PER_USER = 200
def _load_textblocks_store() -> dict:
"""{practice_id: {user_id: [textblock dict, ...]}}."""
return _load_json(_TEXTBLOCKS_FILE, {})
def _save_textblocks_store(data: dict) -> None:
_save_json(_TEXTBLOCKS_FILE, data)
def _user_textblocks(store: dict, pid: str, uid: str) -> list:
pdat = store.get(pid)
if not isinstance(pdat, dict):
return []
udat = pdat.get(uid)
if not isinstance(udat, list):
return []
return [tb for tb in udat if isinstance(tb, dict) and tb.get("id")]
def _generate_textblock_id() -> str:
return f"tb_{int(time.time() * 1000)}_{secrets.token_hex(4)}"
def _clip_text(s: str, max_len: int) -> str:
s = "" if s is None else str(s)
if len(s) > max_len:
return s[:max_len]
return s
def _public_textblock(tb: dict) -> dict:
"""Felder, die der Client erhaelt."""
return {
"id": str(tb.get("id") or ""),
"title": str(tb.get("title") or ""),
"body": str(tb.get("body") or ""),
"sort_order": int(tb.get("sort_order") or 0),
"created_at": str(tb.get("created_at") or ""),
"updated_at": str(tb.get("updated_at") or ""),
}
@router.get("/textblocks")
async def empfang_textblocks_list(request: Request):
"""Liste der Textbloecke des angemeldeten Benutzers in der aktuellen Praxis."""
s = _require_session(request)
pid = (s.get("practice_id") or "").strip()
uid = (s.get("user_id") or "").strip()
if not pid or not uid:
raise HTTPException(status_code=400, detail="Session unvollstaendig")
store = _load_textblocks_store()
blocks = _user_textblocks(store, pid, uid)
blocks_sorted = sorted(
blocks,
key=lambda x: (int(x.get("sort_order") or 0), str(x.get("created_at") or "")),
)
return JSONResponse(content={
"success": True,
"textblocks": [_public_textblock(tb) for tb in blocks_sorted],
})
@router.post("/textblocks")
async def empfang_textblocks_create(request: Request):
"""Erzeugt einen neuen Textblock. Body: {title, body, sort_order?, client_id?}."""
s = _require_session(request)
pid = (s.get("practice_id") or "").strip()
uid = (s.get("user_id") or "").strip()
if not pid or not uid:
raise HTTPException(status_code=400, detail="Session unvollstaendig")
try:
body = await request.json()
except Exception:
body = {}
if not isinstance(body, dict):
body = {}
title = _clip_text(body.get("title", ""), _TEXTBLOCK_MAX_TITLE).strip()
content = _clip_text(body.get("body", ""), _TEXTBLOCK_MAX_BODY)
if not content.strip():
raise HTTPException(status_code=400, detail="body erforderlich")
if not title:
title = "Textblock"
sort_order = 0
try:
sort_order = int(body.get("sort_order") or 0)
except Exception:
sort_order = 0
# Idempotenz-Hint: ``client_id`` wird wenn vorhanden als id uebernommen,
# solange noch nicht vergeben.
client_id = str(body.get("client_id") or "").strip()
if client_id and not client_id.startswith("tb_"):
client_id = ""
store = _load_textblocks_store()
pdat = store.setdefault(pid, {})
udat = pdat.setdefault(uid, [])
if len(udat) >= _TEXTBLOCK_MAX_PER_USER:
raise HTTPException(
status_code=400,
detail=f"Maximal {_TEXTBLOCK_MAX_PER_USER} Textbloecke pro Benutzer",
)
tb_id = client_id if client_id and not any(
isinstance(x, dict) and x.get("id") == client_id for x in udat
) else _generate_textblock_id()
now = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
tb = {
"id": tb_id,
"title": title,
"body": content,
"sort_order": sort_order,
"created_at": now,
"updated_at": now,
}
udat.append(tb)
_save_textblocks_store(store)
return JSONResponse(content={"success": True, "textblock": _public_textblock(tb)})
@router.put("/textblocks/{tb_id}")
async def empfang_textblocks_update(tb_id: str, request: Request):
"""Aktualisiert title/body/sort_order eines Textblocks. Nur eigene."""
s = _require_session(request)
pid = (s.get("practice_id") or "").strip()
uid = (s.get("user_id") or "").strip()
if not pid or not uid:
raise HTTPException(status_code=400, detail="Session unvollstaendig")
try:
body = await request.json()
except Exception:
body = {}
if not isinstance(body, dict):
body = {}
store = _load_textblocks_store()
udat = store.get(pid, {}).get(uid)
if not isinstance(udat, list):
raise HTTPException(status_code=404, detail="Textblock nicht gefunden")
target = None
for x in udat:
if isinstance(x, dict) and x.get("id") == tb_id:
target = x
break
if target is None:
raise HTTPException(status_code=404, detail="Textblock nicht gefunden")
if "title" in body:
title = _clip_text(body.get("title", ""), _TEXTBLOCK_MAX_TITLE).strip()
if not title:
title = "Textblock"
target["title"] = title
if "body" in body:
content = _clip_text(body.get("body", ""), _TEXTBLOCK_MAX_BODY)
if not content.strip():
raise HTTPException(status_code=400, detail="body darf nicht leer sein")
target["body"] = content
if "sort_order" in body:
try:
target["sort_order"] = int(body.get("sort_order") or 0)
except Exception:
pass
target["updated_at"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
_save_textblocks_store(store)
return JSONResponse(content={"success": True, "textblock": _public_textblock(target)})
@router.delete("/textblocks/{tb_id}")
async def empfang_textblocks_delete(tb_id: str, request: Request):
"""Loescht einen eigenen Textblock dauerhaft."""
s = _require_session(request)
pid = (s.get("practice_id") or "").strip()
uid = (s.get("user_id") or "").strip()
if not pid or not uid:
raise HTTPException(status_code=400, detail="Session unvollstaendig")
store = _load_textblocks_store()
udat = store.get(pid, {}).get(uid)
if not isinstance(udat, list):
return JSONResponse(content={"success": True, "removed": 0})
before = len(udat)
new_list = [x for x in udat if not (isinstance(x, dict) and x.get("id") == tb_id)]
removed = before - len(new_list)
store[pid][uid] = new_list
if removed:
_save_textblocks_store(store)
return JSONResponse(content={"success": True, "removed": removed})
# =====================================================================
# HTML PAGE
# =====================================================================
_HTML_NO_CACHE = {
"Cache-Control": "no-cache, no-store, must-revalidate",
"Pragma": "no-cache",
"Expires": "0",
"Content-Security-Policy": "frame-ancestors *;",
}
_LOGO_CACHE = {
"Cache-Control": "public, max-age=86400",
}
@router.get("/aza_logo.png")
async def empfang_logo_png():
"""Statisches Logo fuer Nav-Leiste (liegt neben empfang.html in web/)."""
p = Path(__file__).resolve().parent / "web" / "aza_logo.png"
if p.is_file():
return FileResponse(
path=p,
media_type="image/png",
headers=_LOGO_CACHE,
)
raise HTTPException(status_code=404, detail="aza_logo.png nicht gefunden")
@router.get("/favicon.ico")
async def empfang_favicon_ico():
"""Favicon / Taskleisten-Browser-Tab; projekt-root logo.ico (Windows-ICO)."""
p = Path(__file__).resolve().parent / "logo.ico"
if p.is_file():
return FileResponse(
path=p,
media_type="image/x-icon",
headers=_LOGO_CACHE,
)
raise HTTPException(status_code=404, detail="favicon.ico nicht gefunden")
# =====================================================================
# Download der installierbaren Empfang-Chat-Huelle (aza_empfang_chat_setup.exe)
# =====================================================================
#
# Public-Endpoint OHNE Auth: Die EXE-Datei selbst gibt keinen Zugriff auf
# Praxisdaten. Schutz entsteht erst durch:
# * gueltige Empfang-Session (Login),
# * Browser-Handoff-Code aus dem Empfang-Browser,
# * Praxis-Einladungscode CHAT-XXXX-XXXX.
# Der UI-Link wird im Browser-Empfang nur in praxis-aktiver Session
# eingeblendet, aber der Endpunkt selbst ist absichtlich nicht
# session-gesperrt, damit Benutzer den Installer auch ueber direkten
# Link aus Anleitungen herunterladen koennen.
#
# Keine Tokens in der URL, keine Praxisdaten im Log. Datei wird aus dem
# Produktions-Release-Ordner ausgeliefert, mit Dev-Fallback.
_DOWNLOAD_CACHE = {
"Cache-Control": "public, max-age=300", # 5 min: schnelle Updates moeglich
}
def _empfang_chat_setup_candidate_paths() -> list[Path]:
"""Liste plausibler Quellpfade fuer aza_empfang_chat_setup.exe.
Reihenfolge:
1. <projektroot>/release/downloads/aza_empfang_chat_setup.exe (Produktion)
2. <projektroot>/dist/installer/aza_empfang_chat_setup.exe (Dev)
"""
project_root = Path(__file__).resolve().parent
return [
project_root / "release" / "downloads" / "aza_empfang_chat_setup.exe",
project_root / "dist" / "installer" / "aza_empfang_chat_setup.exe",
]
@router.get("/downloads/aza_empfang_chat_setup.exe")
async def empfang_chat_setup_download():
"""Liefert die aktuelle Empfang-Chat-Huelle-Installer-EXE (kein Auth).
Datei-Inhalt wird NICHT geloggt. Es wird nur Pfad/Groesse vermerkt.
"""
for p in _empfang_chat_setup_candidate_paths():
try:
if p.is_file():
try:
_log.info(
"AZA_EMPFANG_CHAT_SETUP_DOWNLOAD source=%s size=%d",
p.name, p.stat().st_size,
)
except Exception:
pass
return FileResponse(
path=p,
media_type="application/vnd.microsoft.portable-executable",
filename="aza_empfang_chat_setup.exe",
headers=_DOWNLOAD_CACHE,
)
except OSError:
continue
raise HTTPException(
status_code=404,
detail="aza_empfang_chat_setup.exe nicht verfuegbar - bitte spaeter erneut versuchen",
)
@router.get("/", response_class=HTMLResponse)
async def empfang_page(request: Request):
html_path = Path(__file__).resolve().parent / "web" / "empfang.html"
if html_path.is_file():
return HTMLResponse(
content=html_path.read_text(encoding="utf-8"),
headers=_HTML_NO_CACHE,
)
return HTMLResponse(content="<h1>empfang.html nicht gefunden</h1>", status_code=404)
@router.get("/chatwin.html", response_class=HTMLResponse)
async def empfang_chatwin_page():
"""Kompaktes Chat-Fenster (neues Fenster / Tab), z. B. 1:1 oder Allgemein."""
html_path = Path(__file__).resolve().parent / "web" / "empfang_chat_minimal.html"
if html_path.is_file():
return HTMLResponse(
content=html_path.read_text(encoding="utf-8"),
headers=_HTML_NO_CACHE,
)
return HTMLResponse(content="<h1>chatwin nicht gefunden</h1>", status_code=404)