feat: Notruf-Frontend — Geoposition + Admin-Notfallprofil (Phase 2)

- 🆘-Handler erfasst beim Auslösen die Geoposition (One-Shot, navigator.
  geolocation, enableHighAccuracy) und hängt sie als geo:{lat,lon,accuracy,ts}
  an /api/emergency. Timeout-Race (8 s), damit hängendes GPS den Alarm nie
  blockiert; nur mit Freigabe (location_consent).
- loadMe: Prefs global gehalten; bei location_consent Standort-Erlaubnis vorab
  angefragt (kein Browser-Dialog im Panikmoment).
- Admin-User-Karte: einklappbares „Notfall-Profil" zum Pflegen von Name,
  Adresse, Telefonen, Geburtsdatum, med. Hinweisen, Kontakt-E-Mail/-Telefon
  und der Standort-Freigabe; speichert via PUT /api/admin/users/{id}/prefs.

Nutzt das in Phase 1+3 gebaute Backend; keine Backend-Änderung. app.js per
node --check validiert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-06-25 15:48:51 +02:00
commit 2a013ae9fe

View file

@ -316,6 +316,7 @@ let activeSources = []; // laufende AudioBufferSourceNodes (stoppbar per Barge-
// /api/me abgeleitet; bis dahin null -> ensureSession() wartet auf loadMe().
let sessionId = null;
let mePromise = null;
let myPrefs = {}; // Prefs des aktuellen Nutzers (u. a. location_consent für den Notruf-Standort)
let currentUserId = null;
const sessionKey = (uid) => "va-session-" + uid;
@ -368,6 +369,14 @@ function loadMe() {
}
// Gespeicherte Nutzer-Präferenzen in die Dropdowns laden.
const prefs = me.prefs || {};
myPrefs = prefs;
// Standort-Erlaubnis ggf. vorab anfragen, damit im Notfall kein Browser-Dialog kommt.
if (prefs.location_consent && navigator.geolocation) {
try {
navigator.geolocation.getCurrentPosition(() => {}, () => {},
{ maximumAge: 600000, timeout: 8000 });
} catch (e) { /* ignore */ }
}
applyAllowedLanguages(prefs);
applyPlaybackChoice(prefs.tts_provider);
applyGenderChoice(prefs.voice_gender || 'f');
@ -1073,6 +1082,36 @@ function buildUserCard(u) {
<div class="lang-allow flex flex-wrap gap-1"></div>
</div>
<!-- Notfall-Profil -->
<div class="mt-3 pt-3 border-t border-slate-100 dark:border-slate-700">
<button class="btn-prof text-xs text-slate-500 hover:text-slate-700 dark:hover:text-slate-300 flex items-center gap-1 transition-colors">
<span class="prof-arrow"></span> Notfall-Profil (fließt in Notruf-Meldungen)
</button>
<div class="prof-section hidden mt-2 space-y-1.5">
<input class="prof-f w-full rounded-lg border border-slate-300 dark:border-slate-600 bg-transparent px-3 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-blue-500" data-k="full_name" placeholder="Voller Name" />
<input class="prof-f w-full rounded-lg border border-slate-300 dark:border-slate-600 bg-transparent px-3 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-blue-500" data-k="street" placeholder="Straße + Hausnummer" />
<div class="flex gap-1.5">
<input class="prof-f w-1/3 rounded-lg border border-slate-300 dark:border-slate-600 bg-transparent px-3 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-blue-500" data-k="postal_code" placeholder="PLZ" />
<input class="prof-f w-2/3 rounded-lg border border-slate-300 dark:border-slate-600 bg-transparent px-3 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-blue-500" data-k="city" placeholder="Ort" />
</div>
<div class="flex gap-1.5">
<input class="prof-f w-1/2 rounded-lg border border-slate-300 dark:border-slate-600 bg-transparent px-3 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-blue-500" data-k="phone_mobile" placeholder="Mobil" />
<input class="prof-f w-1/2 rounded-lg border border-slate-300 dark:border-slate-600 bg-transparent px-3 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-blue-500" data-k="phone_landline" placeholder="Festnetz" />
</div>
<input class="prof-f w-full rounded-lg border border-slate-300 dark:border-slate-600 bg-transparent px-3 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-blue-500" data-k="birth_date" placeholder="Geburtsdatum (z. B. 1940-05-01)" />
<textarea class="prof-f w-full rounded-lg border border-slate-300 dark:border-slate-600 bg-transparent px-3 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-blue-500" data-k="medical_notes" rows="2" placeholder="Medizinische Hinweise (Allergien, Medikamente, Vorerkrankungen)"></textarea>
<input class="prof-f w-full rounded-lg border border-slate-300 dark:border-slate-600 bg-transparent px-3 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-blue-500" data-k="emergency_contacts" placeholder="Kontakt-E-Mail(s), Komma-getrennt" />
<input class="prof-f w-full rounded-lg border border-slate-300 dark:border-slate-600 bg-transparent px-3 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-blue-500" data-k="emergency_phones" placeholder="Kontakt-Telefon(e), Komma-getrennt" />
<label class="flex items-center gap-2 text-xs text-slate-600 dark:text-slate-300">
<input type="checkbox" class="prof-consent" /> Standort im Notfall mitsenden
</label>
<div class="flex items-center gap-2">
<button class="prof-save rounded-lg bg-blue-600 hover:bg-blue-700 text-white text-xs px-3 py-1.5 transition-colors">Profil speichern</button>
<span class="prof-status text-xs text-slate-400"></span>
</div>
</div>
</div>
<!-- Erinnerungen -->
<div class="mt-3 pt-3 border-t border-slate-100 dark:border-slate-700">
<button class="btn-mem text-xs text-slate-500 hover:text-slate-700 dark:hover:text-slate-300 flex items-center gap-1 transition-colors">
@ -1111,6 +1150,37 @@ function buildUserCard(u) {
}
renderLangChips();
// Notfall-Profil — Admin pflegt Kontaktdaten + med. Hinweise + Standort-Freigabe.
const PROF_KEYS = ["full_name", "street", "postal_code", "city", "phone_mobile",
"phone_landline", "birth_date", "medical_notes",
"emergency_contacts", "emergency_phones"];
PROF_KEYS.forEach((k) => {
const el = card.querySelector(`.prof-f[data-k="${k}"]`);
if (el) el.value = u[k] || "";
});
const consentEl = card.querySelector(".prof-consent");
if (consentEl) consentEl.checked = !!u.location_consent;
const profBtn = card.querySelector(".btn-prof");
const profSection = card.querySelector(".prof-section");
const profArrow = card.querySelector(".prof-arrow");
profBtn.addEventListener("click", () => {
const open = !profSection.classList.contains("hidden");
profSection.classList.toggle("hidden", open);
profArrow.textContent = open ? "▶" : "▼";
});
card.querySelector(".prof-save").addEventListener("click", async () => {
const body = { location_consent: !!consentEl.checked };
PROF_KEYS.forEach((k) => {
const el = card.querySelector(`.prof-f[data-k="${k}"]`);
body[k] = el ? el.value.trim() : "";
});
const status = card.querySelector(".prof-status");
status.textContent = "speichere …";
const res = await adminFetch(`/api/admin/users/${uid}/prefs`, "PUT", body);
status.textContent = res ? "✓ gespeichert" : "Fehler";
setTimeout(() => { status.textContent = ""; }, 2500);
});
// Admin-Rechte (persistentes Flag) ein-/ausschalten
let isAdmin = !!u.is_admin;
const adminBtn = card.querySelector(".btn-admin");
@ -2020,16 +2090,44 @@ $("#load-settings").addEventListener("click", loadSettings);
// Bewusste Nutzeraktion mit Bestätigung. Eskalation (Benachrichtigung) folgt
// später; vorerst zeigt der Server einen Hinweis in der Nutzersprache.
const emergencyBtn = $("#emergency-btn");
// Standort (One-Shot) für den Notruf erfassen — nur mit Freigabe (location_consent)
// und mit Timeout, damit ein hängendes GPS den Alarm nicht blockiert. null = kein Fix.
function captureGeo(timeoutMs = 8000) {
return new Promise((resolve) => {
if (!myPrefs.location_consent || !navigator.geolocation) return resolve(null);
let done = false;
const finish = (v) => { if (!done) { done = true; resolve(v); } };
const timer = setTimeout(() => finish(null), timeoutMs);
navigator.geolocation.getCurrentPosition(
(pos) => {
clearTimeout(timer);
finish({
lat: pos.coords.latitude,
lon: pos.coords.longitude,
accuracy: pos.coords.accuracy,
ts: new Date(pos.timestamp).toISOString(),
});
},
() => { clearTimeout(timer); finish(null); },
{ enableHighAccuracy: true, timeout: timeoutMs, maximumAge: 60000 }
);
});
}
if (emergencyBtn) {
emergencyBtn.addEventListener("click", async () => {
if (!confirm("Wirklich Hilfe rufen?")) return;
const lang = (langSel && langSel.value) || "de";
emergencyBtn.disabled = true;
try {
const geo = await captureGeo(); // null, wenn keine Freigabe/kein Fix
const body = { language: lang };
if (geo) body.geo = geo;
const res = await fetch("/api/emergency", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ language: lang }),
body: JSON.stringify(body),
});
const data = await res.json().catch(() => ({}));
const notice = (data && data.notice) ||