This commit is contained in:
2026-05-12 01:21:25 +02:00
parent 8261a281c4
commit 96c1029d91
44 changed files with 166486 additions and 199 deletions

View File

@@ -356,6 +356,62 @@ def _practice_label(practices: dict, pid: str) -> str:
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()
@@ -995,7 +1051,8 @@ async def auth_login(request: Request):
)
if len(gmatches) == 1:
target = gmatches[0]
login_recovered_practice = True
if pid_src != "invite":
login_recovered_practice = True
if not target:
raise HTTPException(
@@ -1003,7 +1060,8 @@ async def auth_login(request: Request):
detail="Benutzer nicht gefunden oder falsches Passwort",
)
tpid = (target.get("practice_id") or "").strip()
if pid and tpid != pid and not login_recovered_practice:
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=(
@@ -1012,7 +1070,8 @@ async def auth_login(request: Request):
),
)
if not pid or login_recovered_practice:
pid = tpid
if not invite_will_clone:
pid = tpid
if target.get("status") == "deactivated":
raise HTTPException(
status_code=403,
@@ -1024,8 +1083,15 @@ async def auth_login(request: Request):
detail="Benutzer nicht gefunden oder falsches Passwort",
)
did_invite_clone = False
now = time.strftime("%Y-%m-%d %H:%M:%S")
target["last_login"] = now
if invite_will_clone:
_ensure_practice(pid)
_ensure_default_channels(pid)
target = _invite_join_clone_account(accounts, target, pid, now)
did_invite_clone = True
else:
target["last_login"] = now
_save_accounts(accounts)
dn = (target.get("display_name") or raw).strip()
@@ -1038,15 +1104,19 @@ async def auth_login(request: Request):
)
bind_src = (
"invite_code"
if pid_src == "invite"
"invite_code_join"
if did_invite_clone
else (
"username_recovered_practice"
if login_recovered_practice
"invite_code"
if pid_src == "invite"
else (
"stored_practice_id"
if pid_src in ("body", "header", "query")
else "account"
"username_recovered_practice"
if login_recovered_practice
else (
"stored_practice_id"
if pid_src in ("body", "header", "query")
else "account"
)
)
)
)
@@ -3613,12 +3683,24 @@ async def empfang_pulse(request: Request, practice_id: Optional[str] = Query(Non
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={
"tick": int(p.get("tick", 0)),
"ts": float(p.get("ts", 0.0)),
"last_sender": p.get("last_sender", ""),
},
content=content,
headers={
"Cache-Control": "no-store, no-cache, must-revalidate",
"Pragma": "no-cache",
@@ -3759,6 +3841,51 @@ def _dm_v2_load_for_pair(pid: str, uid_a: str, uid_b: str) -> tuple[list[dict],
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:
@@ -4186,26 +4313,89 @@ async def empfang_message_transcribe_audio(msg_id: str, request: Request):
@router.post("/messages/{msg_id}/chat-ack")
async def empfang_message_chat_ack(msg_id: str, request: Request):
"""OK/Kenntnisnahme pro Nachricht: nur ``extras.chat_ack`` (kein Thread-Status)."""
_require_session(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")
ex = dict(target.get("extras") or {})
if ack:
ex["chat_ack"] = True
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
if bulk_targets:
for m in bulk_targets:
ex_m = dict(m.get("extras") or {})
ex_m["chat_ack"] = True
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
target["extras"] = ex_t
affected += 1
else:
ex.pop("chat_ack", None)
target["extras"] = ex
ex = dict(target.get("extras") or {})
if ack:
if not ex.get("chat_ack"):
ex["chat_ack"] = True
affected = 1
else:
if ex.pop("chat_ack", None) is not None:
affected = 1
target["extras"] = ex
_save_messages(messages)
try:
_pulse_bump(pid, sender="")
@@ -4213,8 +4403,16 @@ async def empfang_message_chat_ack(msg_id: str, request: Request):
pass
mid_short = (msg_id or "")[:16]
pid_short = (pid or "")[:16]
_log.info("AZA_CHAT_ACK practice=%s msg=%s ack=%s", pid_short, mid_short, ack)
return JSONResponse(content={"success": True, "ack": ack})
_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,
})
@router.delete("/messages/{msg_id}")
@@ -4938,6 +5136,15 @@ async def empfang_shell_context_upload(request: Request):
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)
@@ -4950,6 +5157,8 @@ async def empfang_shell_context_upload(request: Request):
"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,
}
@@ -4995,6 +5204,8 @@ async def empfang_shell_context_me(request: Request):
"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"},
)
@@ -5007,6 +5218,13 @@ async def empfang_shell_context_me(request: Request):
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,
@@ -5015,6 +5233,8 @@ async def empfang_shell_context_me(request: Request):
"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"},
)