{"openapi":"3.1.0","info":{"title":"StockPilot HTTP-Schnittstellen","version":"1.0.0","summary":"Beschreibung der Schnittstellen unter /api, mit denen die StockPilot-App arbeitet.","description":"Diese Beschreibung deckt die Schnittstellen ab, die die StockPilot-Web-App für angemeldete Personen nutzt, sowie die öffentlichen Berichts- und Statusendpunkte. Betreiber-, Cron- und interne Routen sind nicht enthalten.\n\n**Authentifizierung.** Es gibt keine API-Schlüssel und keine Partner-Zugänge mit eigenem Verfahren. Alle geschützten Routen erwarten das Sitzungs-Cookie, das Auth.js nach der Anmeldung setzt (E-Mail und Passwort, optional mit zweitem Faktor, oder Google). Die Sitzung ist ein JWT-Cookie und gilt bis zu 30 Tage; sie endet früher bei Sperrung des Kontos oder nach einem Passwortwechsel. Partner- und Betreiberkonten melden sich genauso an, ihre Rolle steht in der Sitzung.\n\n**Ändernde Aufrufe.** `POST`, `PUT`, `PATCH` und `DELETE` prüfen die Herkunft: Aus dem Browser sind nur Aufrufe der eigenen Seite zulässig (sonst 403). Aufrufe ohne Browser-Header, etwa aus Skripten, werden nicht abgewiesen — die Sitzung ist trotzdem nötig.\n\n**Ohne Sitzung** antworten die Routen uneinheitlich: Manche leiten zum Login weiter (307), andere antworten mit 401. Das steht an jeder Route.\n\n**Stabilität.** Die Schnittstellen sind für die eigene App gebaut und ändern sich mit ihr; es gibt keine Versionierung im Pfad und keine Zusage zur Abwärtskompatibilität. Für dauerhafte Integrationen von Partnern ist ein eigener, versionierter Zugang mit Schlüsselverwaltung geplant.\n\n**Fehler.** JSON-Fehler haben die Form `{ \"error\": \"…\" }`. Verlässlich ist der HTTP-Status; Text und Sprache sind uneinheitlich (überwiegend deutsch, teils kurze englische Codes wie `Forbidden` oder `Blocked`, die die App selbst auswertet). Interne Details werden nicht ausgegeben. Jede Route kann bei unerwarteten Fehlern mit 500 antworten.\n\n**Grenzen.** Anfragen sind je IP oder Konto begrenzt (429). Hochgeladene Dateien sind auf 8 bis 12 MB begrenzt.","contact":{"name":"StockPilot","url":"https://eprojekt.cloud"}},"servers":[{"url":"https://eprojekt.cloud","description":"Produktion"}],"tags":[{"name":"Anmeldung","description":"Anmeldung und Sitzung (Auth.js)."},{"name":"Betrieb","description":"Erreichbarkeit, Browser-Berichte und Feedback — öffentlich."},{"name":"Medien","description":"Bilder und Dateien, die nur nach Zugriffsprüfung ausgeliefert werden."},{"name":"Uploads","description":"Bilder hochladen."},{"name":"Vorrat und Einkauf","description":"Export und Live-Aktualisierung."},{"name":"Chat","description":"Unterhaltungen zwischen Personen der Nachbarschaft und im Marktplatz."},{"name":"Nachbarschaft","description":"Sichtbarkeit, Anfragen und aggregierte Karte."},{"name":"Benachrichtigungen","description":"Push-Mitteilungen."},{"name":"Sonstiges","description":"Ortssuche, Anwesenheit, Blockaden."}],"paths":{"/api/auth/google":{"get":{"tags":["Anmeldung"],"summary":"Anmeldung mit Google starten","description":"Leitet zu Google weiter (HTTP 302). Nach der Rückkehr legt Auth.js das Sitzungs-Cookie an. Gedacht für Browser, nicht für Skripte.","parameters":[{"name":"callbackUrl","in":"query","required":false,"description":"Interner Pfad, zu dem nach der Anmeldung weitergeleitet wird. Fremde Adressen werden ersetzt.","schema":{"type":"string","examples":["/app"]}}],"security":[],"responses":{"302":{"description":"Weiterleitung zu Google."}},"operationId":"getAuthGoogle"}},"/api/auth/session":{"get":{"tags":["Anmeldung"],"summary":"Aktuelle Sitzung abfragen","description":"Auth.js-Standardroute. Liefert die Sitzung der Cookie-Inhaberin oder des Inhabers, ohne Sitzung `null`. Die Anmelderoute ist je IP auf 20 Anfragen pro Minute begrenzt.","security":[],"responses":{"200":{"description":"Sitzung oder `null`.","content":{"application/json":{"schema":{"oneOf":[{"type":"null"},{"type":"object","properties":{"user":{"type":"object","properties":{"id":{"type":"string"},"email":{"type":"string"},"name":{"type":["string","null"]},"role":{"enum":["USER","PARTNER","ADMIN","SUPER_ADMIN"]}}},"expires":{"type":"string","format":"date-time"}}}]}}}},"429":{"$ref":"#/components/responses/ZuViele"}},"operationId":"getAuthSession"}},"/api/auth/csrf":{"get":{"tags":["Anmeldung"],"summary":"CSRF-Token für die Anmeldung holen","description":"Auth.js-Standardroute. Das Token wird für `POST /api/auth/callback/credentials` und `POST /api/auth/signout` gebraucht und muss zusammen mit dem gesetzten Cookie zurückgesendet werden.","security":[],"responses":{"200":{"description":"Token.","content":{"application/json":{"schema":{"type":"object","required":["csrfToken"],"properties":{"csrfToken":{"type":"string"}}}}}},"429":{"$ref":"#/components/responses/ZuViele"}},"operationId":"getAuthCsrf"}},"/api/auth/callback/credentials":{"post":{"tags":["Anmeldung"],"summary":"Mit E-Mail und Passwort anmelden","description":"Auth.js-Standardroute. Bei Erfolg setzt die Antwort das Sitzungs-Cookie. Die Anmeldeversuche sind je IP begrenzt. Ist ein zweiter Faktor aktiv, muss zusätzlich `code` mitgeschickt werden. Das Formular unter `/login` ist der unterstützte Weg; diese Route ist nicht für dauerhafte Integrationen gedacht.","requestBody":{"required":true,"content":{"application/x-www-form-urlencoded":{"schema":{"type":"object","required":["csrfToken","email","password"],"properties":{"csrfToken":{"type":"string"},"email":{"type":"string","format":"email"},"password":{"type":"string","format":"password"},"code":{"type":"string","maxLength":40,"description":"App-Code oder Wiederherstellungscode, nur bei aktivierter Zwei-Faktor-Anmeldung."}}}}}},"security":[],"responses":{"302":{"description":"Weiterleitung; bei Erfolg mit gesetztem Sitzungs-Cookie, sonst zurück zur Anmeldung mit Fehlerhinweis."},"429":{"$ref":"#/components/responses/ZuViele"}},"operationId":"postAuthCallbackCredentials"}},"/api/auth/signout":{"post":{"tags":["Anmeldung"],"summary":"Abmelden","description":"Auth.js-Standardroute. Beendet die Sitzung und löscht das Cookie.","requestBody":{"required":true,"content":{"application/x-www-form-urlencoded":{"schema":{"type":"object","required":["csrfToken"],"properties":{"csrfToken":{"type":"string"}}}}}},"security":[],"responses":{"302":{"description":"Weiterleitung nach dem Abmelden."},"429":{"$ref":"#/components/responses/ZuViele"}},"operationId":"postAuthSignout"}},"/api/health":{"get":{"tags":["Betrieb"],"summary":"Erreichbarkeit prüfen","description":"Für Überwachung von außen. Prüft Datenbank und Zwischenspeicher mit je drei Sekunden Zeitlimit. Öffentlich und ohne Details — keine Versionen, Hosts oder Fehlermeldungen. Je IP höchstens 60 Anfragen pro Minute.","security":[],"responses":{"200":{"description":"Datenbank erreichbar. `status` ist `ok` oder `eingeschraenkt` (Zwischenspeicher gestört, App läuft weiter).","content":{"application/json":{"schema":{"type":"object","required":["status","datenbank","cache"],"properties":{"status":{"enum":["ok","eingeschraenkt"]},"datenbank":{"enum":["ok","gestoert"]},"cache":{"enum":["ok","gestoert"]}}}}}},"429":{"description":"Zu viele Anfragen.","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","const":"zu-viele-anfragen"}}}}}},"503":{"description":"Datenbank nicht erreichbar (`status: gestoert`).","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"const":"gestoert"},"datenbank":{"const":"gestoert"},"cache":{"enum":["ok","gestoert"]}}}}}}},"operationId":"getHealth"}},"/api/csp-bericht":{"post":{"tags":["Betrieb"],"summary":"Verstoßbericht der Content Security Policy entgegennehmen","description":"Empfänger für `report-uri` der Browser. Öffentlich und ohne Anmeldung. Je IP (IPv6 je /64) höchstens 30 Berichte pro Stunde; Body höchstens 20 KB. Protokolliert wird nur das Nötigste (verletzte Richtlinie, Ziel-Origin, Seitenpfad ohne Query) — keine IP, keine ganzen Adressen. Die Antwort ist immer 204, auch bei Limit oder ungültigem Inhalt.","requestBody":{"required":true,"content":{"application/csp-report":{"schema":{"type":"object","additionalProperties":true}},"application/reports+json":{"schema":{"type":"array","items":{"type":"object","additionalProperties":true}}},"application/json":{"schema":{"type":"object","additionalProperties":true}}}},"security":[],"responses":{"204":{"description":"Entgegengenommen (oder verworfen — das lässt sich von außen nicht unterscheiden)."}},"operationId":"postCspBericht"}},"/api/fehlerbericht":{"post":{"tags":["Betrieb"],"summary":"Fehler aus dem Browser melden","description":"Die Fehlerseiten der App melden hierher. Nur von der eigenen Seite (`Sec-Fetch-Site: same-origin`); andere Absender werden still verworfen. Je IP höchstens 20 Berichte pro Stunde; Body höchstens 8 KB. Es werden Nachricht, Pfad, Digest, App-Version und die Personengruppe (Betreiber / angemeldet / nicht angemeldet, aus dem Sitzungs-Cookie) protokolliert — keine Nutzerkennung, keine IP. Die Antwort ist immer 204.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["nachricht"],"properties":{"nachricht":{"type":"string"},"pfad":{"type":"string"},"digest":{"type":"string","maxLength":40},"stapel":{"type":"string","maxLength":4000},"version":{"type":"string","maxLength":20,"examples":["1.2.3"]}}}}}},"security":[],"responses":{"204":{"description":"Entgegengenommen oder verworfen."}},"operationId":"postFehlerbericht"}},"/api/feedback":{"post":{"tags":["Betrieb"],"summary":"Feedback, Fehler- oder Funktionswunsch senden","description":"Anmeldung ist optional: Mit Sitzung werden Nutzerkennung, aktiver Haushalt und Tarif serverseitig ergänzt — Werte dazu im Body werden ignoriert. Ohne Sitzung ist die Meldung anonym. Begrenzt auf 30 Meldungen je IP (IPv6 je /64) und Stunde; Body höchstens 16 KB.\n\nNur aus der eigenen Seite aufrufbar: Browser-Anfragen von fremden Seiten beantwortet die App mit 403.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["message"],"properties":{"type":{"enum":["BUG","FEATURE","FEEDBACK","SUPPORT"],"default":"FEEDBACK"},"message":{"type":"string","minLength":1,"maxLength":2000},"url":{"type":["string","null"],"maxLength":500,"description":"Seitenpfad ohne Kennungen."},"userAgent":{"type":["string","null"],"maxLength":500},"screenWidth":{"type":["integer","null"]},"screenHeight":{"type":["integer","null"]},"appVersion":{"type":["string","null"],"maxLength":30},"fehlercode":{"type":["string","null"],"description":"Fehlercode der Fehlerseite (Muster `[A-Za-z0-9_-]{1,40}`); andere Werte werden ignoriert."},"metadata":{"type":["object","null"],"description":"Flache Gerätedaten: höchstens 20 Einträge, Schlüssel bis 40 und Texte bis 200 Zeichen. Verschachteltes fällt still weg.","additionalProperties":true}}}}}},"security":[{},{"sitzungsCookie":[]}],"responses":{"201":{"description":"Gespeichert.","content":{"application/json":{"schema":{"type":"object","required":["success","id"],"properties":{"success":{"const":true},"id":{"type":"string"}}}}}},"400":{"description":"Kein gültiges JSON.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Fehler"}}}},"403":{"$ref":"#/components/responses/FremdeHerkunft"},"413":{"description":"Die Meldung ist zu lang.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Fehler"}}}},"422":{"description":"Ungültige Eingabe (Text leer oder zu lang).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Fehler"}}}},"429":{"description":"Zu viele Meldungen in kurzer Zeit.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Fehler"}}}}},"operationId":"postFeedback"}},"/api/bild/{art}/{id}":{"get":{"tags":["Medien"],"summary":"Profil- oder Titelbild abrufen","description":"Liefert ein in der Datenbank gespeichertes Bild als Datei. Profilbilder (`avatar`) nur mit Sitzung und nicht zwischen Personen mit Blockade; Titelbilder (`cover`) nur für Mitglieder des Haushalts. Der Parameter `v` muss zum aktuellen Bild passen — die Adresse lässt sich nicht aus einer Kennung allein bilden. Cache: `private, no-cache` mit ETag (Rückfrage mit `If-None-Match` ergibt 304).","parameters":[{"name":"art","in":"path","required":true,"description":"`avatar` oder `cover`.","schema":{"type":"string"}},{"name":"id","in":"path","required":true,"description":"Nutzerkennung (`avatar`) oder Haushaltskennung (`cover`).","schema":{"type":"string"}},{"name":"v","in":"query","required":true,"description":"Versionskennung aus der Bildadresse, die die App ausliefert.","schema":{"type":"string"}}],"security":[{"sitzungsCookie":[]}],"responses":{"200":{"description":"Das Bild.","content":{"image/jpeg":{"schema":{"type":"string","format":"binary"}},"image/png":{"schema":{"type":"string","format":"binary"}},"image/webp":{"schema":{"type":"string","format":"binary"}},"image/gif":{"schema":{"type":"string","format":"binary"}}}},"304":{"description":"Unverändert (ETag passt)."},"401":{"$ref":"#/components/responses/NichtAngemeldet"},"404":{"description":"Kein Bild, falsche Version, kein Zugriff (Text `Not Found`).","content":{"text/plain":{"schema":{"type":"string"}}}}},"operationId":"getBildArtId"}},"/api/receipts/{id}/image":{"get":{"tags":["Medien"],"summary":"Kassenbon-Bild abrufen","description":"Liefert das gespeicherte Bild eines Kassenbons — nur an die Eigentümerin oder den Eigentümer und an berechtigte Betreiber. Antwort mit `Cache-Control: private, no-store`.","parameters":[{"name":"id","in":"path","required":true,"description":"Kennung des Kassenbons.","schema":{"type":"string"}}],"security":[{"sitzungsCookie":[]}],"responses":{"200":{"description":"Das Bild.","content":{"image/jpeg":{"schema":{"type":"string","format":"binary"}},"image/png":{"schema":{"type":"string","format":"binary"}},"image/webp":{"schema":{"type":"string","format":"binary"}}}},"401":{"$ref":"#/components/responses/NichtAngemeldet"},"403":{"$ref":"#/components/responses/Verboten"},"404":{"$ref":"#/components/responses/NichtGefunden"}},"operationId":"getReceiptsIdImage"}},"/api/receipts/detect":{"post":{"tags":["Medien"],"summary":"Papierbereich eines Kassenbon-Fotos erkennen","description":"Findet die Ecken des Bons im Foto, damit die App es zuschneiden kann. Das Bild wird nur zur Berechnung gelesen und nicht gespeichert. Tageslimit: 100 Erkennungen je Konto. Datei höchstens 10 MB.\n\nNur aus der eigenen Seite aufrufbar: Browser-Anfragen von fremden Seiten beantwortet die App mit 403.","requestBody":{"required":true,"content":{"multipart/form-data":{"schema":{"type":"object","required":["file"],"properties":{"file":{"type":"string","format":"binary","description":"Foto des Bons, höchstens 10 MB."}}}}}},"security":[{"sitzungsCookie":[]}],"responses":{"200":{"description":"Ecken im Uhrzeigersinn ab links oben, in Pixeln des (ausgerichteten) Bilds. Wird kein Papier gefunden, ist es das ganze Bild. `bonAnzahl` ab 2: Mehrere Bons liegen nebeneinander (höchstens 4), die Ecken umfassen dann nur den breitesten. Bons untereinander zählen hier nicht mit; sie trennt erst das Auslesen, wenn bei jedem die Summe aufgeht.","content":{"application/json":{"schema":{"type":"object","required":["corners","imageWidth","imageHeight","bonAnzahl"],"properties":{"corners":{"type":"array","minItems":4,"maxItems":4,"items":{"type":"object","required":["x","y"],"properties":{"x":{"type":"number"},"y":{"type":"number"}}}},"imageWidth":{"type":"integer"},"imageHeight":{"type":"integer"},"bonAnzahl":{"type":"integer","minimum":1,"maximum":4}}}}}},"400":{"description":"Keine Datei.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Fehler"}}}},"401":{"$ref":"#/components/responses/NichtAngemeldet"},"403":{"$ref":"#/components/responses/FremdeHerkunft"},"413":{"description":"Datei zu groß.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Fehler"}}}},"429":{"$ref":"#/components/responses/Tageslimit"},"500":{"description":"Erkennung fehlgeschlagen.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Fehler"}}}}},"operationId":"postReceiptsDetect"}},"/api/uploads/community":{"post":{"tags":["Uploads"],"summary":"Bild für einen Community-Beitrag hochladen","description":"Speichert das Bild neu kodiert und liefert den Dateinamen, der beim Erstellen oder Bearbeiten eines Beitrags oder Kommentars als `imageFilename` mitgeschickt wird. Voraussetzung: bestätigte E-Mail-Adresse und ein angelegter Haushalt. Tageslimit: 30 Bilder je Konto.\n\nNur aus der eigenen Seite aufrufbar: Browser-Anfragen von fremden Seiten beantwortet die App mit 403.","requestBody":{"required":true,"content":{"multipart/form-data":{"schema":{"type":"object","required":["file"],"properties":{"file":{"type":"string","format":"binary","description":"JPEG, PNG oder WebP, höchstens 12 MB. Der Inhalt wird geprüft (nicht nur der gemeldete Typ), neu kodiert und verliert dabei alle Metadaten wie Aufnahmeort und Kamera."}}}}}},"security":[{"sitzungsCookie":[]}],"responses":{"200":{"description":"Gespeichert.","content":{"application/json":{"schema":{"type":"object","required":["filename","url"],"properties":{"filename":{"type":"string"},"url":{"type":"string","examples":["/uploads/community/0b9f….webp"]}}}}}},"400":{"description":"Datei fehlt, falscher Typ (nur JPEG, PNG, WebP) oder Pflichtfeld fehlt.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Fehler"}}}},"401":{"$ref":"#/components/responses/NichtAngemeldet"},"403":{"description":"E-Mail nicht bestätigt oder kein Haushalt angelegt.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Fehler"}}}},"413":{"$ref":"#/components/responses/ZuGross"},"429":{"$ref":"#/components/responses/Tageslimit"}},"operationId":"postUploadsCommunity"}},"/api/uploads/marketplace":{"post":{"tags":["Uploads"],"summary":"Bild zu einem eigenen Marktplatz-Angebot hochladen","description":"Höchstens 5 Bilder je Angebot. Neue Bilder können zur Prüfung zurückgehalten werden (`zurPruefung`). Tageslimit: 60 Bilder je Konto.\n\nNur aus der eigenen Seite aufrufbar: Browser-Anfragen von fremden Seiten beantwortet die App mit 403.","requestBody":{"required":true,"content":{"multipart/form-data":{"schema":{"type":"object","required":["file","listingId"],"properties":{"file":{"type":"string","format":"binary","description":"JPEG, PNG oder WebP, höchstens 12 MB. Der Inhalt wird geprüft (nicht nur der gemeldete Typ), neu kodiert und verliert dabei alle Metadaten wie Aufnahmeort und Kamera."},"listingId":{"type":"string","description":"Kennung des eigenen Angebots."}}}}}},"security":[{"sitzungsCookie":[]}],"responses":{"200":{"description":"Gespeichert.","content":{"application/json":{"schema":{"type":"object","required":["id","url","zurPruefung"],"properties":{"id":{"type":"string"},"url":{"type":"string"},"zurPruefung":{"type":"boolean"}}}}}},"400":{"description":"Datei fehlt, falscher Typ (nur JPEG, PNG, WebP) oder Pflichtfeld fehlt.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Fehler"}}}},"401":{"$ref":"#/components/responses/NichtAngemeldet"},"403":{"$ref":"#/components/responses/FremdeHerkunft"},"404":{"description":"Angebot nicht gefunden oder nicht das eigene.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Fehler"}}}},"413":{"$ref":"#/components/responses/ZuGross"},"429":{"$ref":"#/components/responses/Tageslimit"},"500":{"description":"Hochladen fehlgeschlagen.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Fehler"}}}}},"operationId":"postUploadsMarketplace"},"delete":{"tags":["Uploads"],"summary":"Bild eines eigenen Marktplatz-Angebots entfernen","description":"Nur aus der eigenen Seite aufrufbar: Browser-Anfragen von fremden Seiten beantwortet die App mit 403.","parameters":[{"name":"imageId","in":"query","required":true,"description":"Kennung des Bilds.","schema":{"type":"string"}}],"security":[{"sitzungsCookie":[]}],"responses":{"200":{"description":"Entfernt.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Ok"}}}},"400":{"description":"`imageId` fehlt.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Fehler"}}}},"401":{"$ref":"#/components/responses/NichtAngemeldet"},"403":{"$ref":"#/components/responses/FremdeHerkunft"},"404":{"description":"Bild nicht gefunden oder nicht das eigene.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Fehler"}}}},"500":{"description":"Entfernen fehlgeschlagen.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Fehler"}}}}},"operationId":"deleteUploadsMarketplace"}},"/api/uploads/product-corrections":{"post":{"tags":["Uploads"],"summary":"Bild für einen Produktkorrekturvorschlag hochladen","description":"Nutzerinnen und Nutzer schlagen Korrekturen vor, Betreiber entscheiden darüber. Nicht verwendete Bilder werden nach sieben Tagen gelöscht. Tageslimit: 30 Bilder je Konto.\n\nVoraussetzung: bestätigte E-Mail-Adresse (sonst 403 „E-Mail nicht bestätigt“).\n\nNur aus der eigenen Seite aufrufbar: Browser-Anfragen von fremden Seiten beantwortet die App mit 403.","requestBody":{"required":true,"content":{"multipart/form-data":{"schema":{"type":"object","required":["file","barcode"],"properties":{"file":{"type":"string","format":"binary","description":"JPEG, PNG oder WebP, höchstens 12 MB. Der Inhalt wird geprüft (nicht nur der gemeldete Typ), neu kodiert und verliert dabei alle Metadaten wie Aufnahmeort und Kamera."},"barcode":{"type":"string","pattern":"^[A-Za-z0-9_-]{1,64}$","description":"Barcode des Produkts."}}}}}},"security":[{"sitzungsCookie":[]}],"responses":{"200":{"description":"Gespeichert.","content":{"application/json":{"schema":{"type":"object","required":["url"],"properties":{"url":{"type":"string","format":"uri"}}}}}},"307":{"$ref":"#/components/responses/NichtAngemeldetWeiterleitung"},"400":{"description":"Datei fehlt, falscher Typ (nur JPEG, PNG, WebP) oder Pflichtfeld fehlt.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Fehler"}}}},"403":{"description":"E-Mail-Adresse nicht bestätigt (`{ \"error\": \"E-Mail nicht bestätigt\" }`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Fehler"}}}},"413":{"$ref":"#/components/responses/ZuGross"},"429":{"$ref":"#/components/responses/Tageslimit"}},"operationId":"postUploadsProductCorrections"}},"/api/uploads/recipes":{"post":{"tags":["Uploads"],"summary":"Foto zu einem Rezept hochladen","description":"`recipeType` bestimmt, wohin das Foto gehört: `user` = eigenes Rezept (Standard), `community-user` = freigegebenes Rezept einer anderen Person, `system` = Systemrezept. Höchstens 3 Fotos je Rezept. Tageslimit: 30 Fotos je Konto.\n\nNur aus der eigenen Seite aufrufbar: Browser-Anfragen von fremden Seiten beantwortet die App mit 403.","requestBody":{"required":true,"content":{"multipart/form-data":{"schema":{"type":"object","required":["file","recipeId"],"properties":{"file":{"type":"string","format":"binary","description":"JPEG, PNG oder WebP, höchstens 12 MB. Der Inhalt wird geprüft (nicht nur der gemeldete Typ), neu kodiert und verliert dabei alle Metadaten wie Aufnahmeort und Kamera."},"recipeId":{"type":"string","maxLength":200,"description":"Kennung des Rezepts (bei Systemrezepten der Slug)."},"recipeType":{"enum":["user","community-user","system"],"default":"user"},"altText":{"type":"string","maxLength":300,"description":"Alternativtext für das Foto."}}}}}},"security":[{"sitzungsCookie":[]}],"responses":{"200":{"description":"Gespeichert.","content":{"application/json":{"schema":{"type":"object","required":["id","url"],"properties":{"id":{"type":"string"},"url":{"type":"string"}}}}}},"400":{"description":"Datei oder `recipeId` fehlt, ungültiger `recipeType`, falscher Dateityp oder schon 3 Fotos am Rezept.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Fehler"}}}},"401":{"$ref":"#/components/responses/NichtAngemeldet"},"403":{"description":"Rezept nicht für die Community freigegeben.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Fehler"}}}},"404":{"description":"Rezept nicht gefunden.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Fehler"}}}},"413":{"$ref":"#/components/responses/ZuGross"},"429":{"$ref":"#/components/responses/Tageslimit"}},"operationId":"postUploadsRecipes"}},"/api/inventory/export":{"get":{"tags":["Vorrat und Einkauf"],"summary":"Vorrat des aktiven Haushalts als CSV exportieren","description":"Alle Artikel, die nicht verbraucht sind. UTF-8 mit Byte-Order-Mark, Zeilenende CRLF, jede Zelle in Anführungszeichen; Zellen, die wie Formeln beginnen, werden entschärft. Spalten: Name, Marke, Kategorie, Menge, Einheit, Lagerort, MHD (Tage), MHD-Datum. Antwort mit `Cache-Control: private, no-store`.","security":[{"sitzungsCookie":[]}],"responses":{"200":{"description":"CSV-Datei als Download (`stockpilot-vorrat-JJJJ-MM-TT.csv`).","headers":{"Content-Disposition":{"schema":{"type":"string"}}},"content":{"text/csv":{"schema":{"type":"string"}}}},"307":{"$ref":"#/components/responses/NichtAngemeldetWeiterleitung"},"400":{"description":"Kein aktiver Haushalt (Text `Kein aktiver Haushalt`).","content":{"text/plain":{"schema":{"type":"string"}}}},"500":{"description":"Export fehlgeschlagen (Text `Fehler beim Export`).","content":{"text/plain":{"schema":{"type":"string"}}}}},"operationId":"getInventoryExport"}},"/api/shopping-list/stream":{"get":{"tags":["Vorrat und Einkauf"],"summary":"Änderungen der Einkaufsliste live empfangen (Server-Sent Events)","description":"Offener Ereignisstrom für die Einkaufsliste eines Haushalts. Das erste Ereignis `connected` trägt `{ \"householdId\": … }`; danach kommt bei jeder Änderung ein Ereignis `update` mit einem Zeitstempel als Daten. Alle 25 Sekunden folgt ein Herzschlag. Höchstens 8 gleichzeitige Ströme je Person — der älteste wird geschlossen. Die Mitgliedschaft wird bei jedem Herzschlag erneut geprüft. Steht der Ereigniskanal nicht zur Verfügung, kommen keine Ereignisse; Clients sollten dann selbst nachladen. Fehlertexte sind reiner Text.","parameters":[{"name":"householdId","in":"query","required":true,"description":"Kennung des Haushalts, dem die Person angehören muss (höchstens 64 Zeichen).","schema":{"type":"string","maxLength":64}}],"security":[{"sitzungsCookie":[]}],"responses":{"200":{"description":"Ereignisstrom.","content":{"text/event-stream":{"schema":{"type":"string"}}}},"307":{"$ref":"#/components/responses/NichtAngemeldetWeiterleitung"},"400":{"description":"`householdId` fehlt (Text `Missing householdId`).","content":{"text/plain":{"schema":{"type":"string"}}}},"403":{"description":"Kein Mitglied des Haushalts (Text `Forbidden`).","content":{"text/plain":{"schema":{"type":"string"}}}},"503":{"description":"Datenbank nicht erreichbar (Text `Service unavailable`).","content":{"text/plain":{"schema":{"type":"string"}}}}},"operationId":"getShoppingListStream"}},"/api/messages/conversations":{"get":{"tags":["Chat"],"summary":"Unterhaltungen und offene Nachbarschaftsanfragen auflisten","security":[{"sitzungsCookie":[]}],"responses":{"200":{"description":"Liste.","content":{"application/json":{"schema":{"type":"object","required":["conversations","incomingRequests"],"properties":{"conversations":{"type":"array","items":{"$ref":"#/components/schemas/Unterhaltung"}},"incomingRequests":{"type":"array","items":{"$ref":"#/components/schemas/NachbarAnfrage"}}}}}}},"307":{"$ref":"#/components/responses/NichtAngemeldetWeiterleitung"}},"operationId":"getMessagesConversations"}},"/api/messages/unread":{"get":{"tags":["Chat"],"summary":"Anzahl ungelesener Nachrichten","description":"Ohne Sitzung (und bei Fehlern) antwortet die Route mit `{ \"count\": 0 }` statt mit einer Weiterleitung.","security":[{},{"sitzungsCookie":[]}],"responses":{"200":{"description":"Anzahl.","content":{"application/json":{"schema":{"type":"object","required":["count"],"properties":{"count":{"type":"integer","minimum":0}}}}}}},"operationId":"getMessagesUnread"}},"/api/messages/{conversationId}":{"get":{"tags":["Chat"],"summary":"Nachrichten einer Unterhaltung laden","description":"Seitenweise, immer aufsteigend nach Zeit. Ohne Parameter die jüngsten 50. `vor` lädt die 50 davor, `nach` neuere ab dieser Nachricht (mit kleiner Überlappung), `ids` frischt einzelne Nachrichten auf (höchstens 100; kombinierbar mit `nach`). `vor` ist nicht mit `nach`/`ids` kombinierbar. Das Laden markiert eingehende Nachrichten als gelesen (Sprachnachrichten erst nach dem Anhören).","parameters":[{"name":"conversationId","in":"path","required":true,"description":"Kennung der Unterhaltung. Nur Teilnehmende haben Zugriff.","schema":{"type":"string"}},{"name":"vor","in":"query","required":false,"description":"Kennung einer Nachricht; liefert die davor.","schema":{"type":"string"}},{"name":"nach","in":"query","required":false,"description":"Kennung einer Nachricht; liefert neuere.","schema":{"type":"string"}},{"name":"ids","in":"query","required":false,"description":"Kommagetrennte Nachrichtenkennungen zum Auffrischen.","schema":{"type":"string"}}],"security":[{"sitzungsCookie":[]}],"responses":{"200":{"description":"Seite.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Nachrichtenseite"}}}},"307":{"$ref":"#/components/responses/NichtAngemeldetWeiterleitung"},"400":{"description":"`vor` mit `nach` oder `ids` kombiniert.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Fehler"}}}},"403":{"description":"Keine Teilnahme an der Unterhaltung.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Fehler"}}}},"404":{"description":"Die Bezugsnachricht ist nicht (mehr) sichtbar.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Fehler"}}}}},"operationId":"getMessagesConversationId"},"post":{"tags":["Chat"],"summary":"Textnachricht senden","description":"Mit `clientId` ist das Senden wiederholbar: Kam die Antwort nicht an und der Client sendet erneut, liefert der zweite Aufruf dieselbe Nachricht statt einer zweiten. Läuft der erste Aufruf noch, antwortet die Route 409. Tageslimit: 1000 Nachrichten je Konto (gilt gemeinsam für Text, Sticker und Standorte).\n\nVoraussetzung: bestätigte E-Mail-Adresse (sonst 403 „E-Mail nicht bestätigt“).\n\nNur aus der eigenen Seite aufrufbar: Browser-Anfragen von fremden Seiten beantwortet die App mit 403.","parameters":[{"name":"conversationId","in":"path","required":true,"description":"Kennung der Unterhaltung. Nur Teilnehmende haben Zugriff.","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["content"],"properties":{"content":{"type":"string","minLength":1,"maxLength":500},"clientId":{"type":"string","description":"Vom Client je Nachricht erzeugte Kennung für sichere Wiederholung."}}}}}},"security":[{"sitzungsCookie":[]}],"responses":{"200":{"description":"Gesendet.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/NachrichtAngelegt"}}}},"307":{"$ref":"#/components/responses/NichtAngemeldetWeiterleitung"},"400":{"description":"Ungültige Eingabe (Inhalt, Datei, Dauer, Koordinaten) oder Gespräch durch Blockade gesperrt.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Fehler"}}}},"403":{"description":"Keine Teilnahme an der Unterhaltung — oder E-Mail-Adresse nicht bestätigt.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Fehler"}}}},"409":{"description":"Dieselbe `clientId` wird gerade noch gespeichert.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Fehler"}}}},"429":{"$ref":"#/components/responses/Tageslimit"}},"operationId":"postMessagesConversationId"}},"/api/messages/{conversationId}/{messageId}":{"patch":{"tags":["Chat"],"summary":"Eigene Nachricht bearbeiten","description":"Nur eigene Nachrichten, außer Sprach- und Standortnachrichten, und nur in den ersten 15 Minuten nach dem Senden (danach 403).\n\nVoraussetzung: bestätigte E-Mail-Adresse (sonst 403 „E-Mail nicht bestätigt“).\n\nNur aus der eigenen Seite aufrufbar: Browser-Anfragen von fremden Seiten beantwortet die App mit 403.","parameters":[{"name":"conversationId","in":"path","required":true,"description":"Kennung der Unterhaltung. Nur Teilnehmende haben Zugriff.","schema":{"type":"string"}},{"name":"messageId","in":"path","required":true,"description":"Kennung der Nachricht innerhalb der Unterhaltung.","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["content"],"properties":{"content":{"type":"string","minLength":1,"maxLength":500}}}}}},"security":[{"sitzungsCookie":[]}],"responses":{"200":{"description":"Bearbeitet.","content":{"application/json":{"schema":{"type":"object","required":["message"],"properties":{"message":{"type":"object","properties":{"id":{"type":"string"},"editedAt":{"type":"string","format":"date-time"}}}}}}}},"307":{"$ref":"#/components/responses/NichtAngemeldetWeiterleitung"},"400":{"description":"Ungültiger Inhalt, nicht bearbeitbar (Sprache, Standort) oder schon gelöscht.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Fehler"}}}},"403":{"description":"Keine Teilnahme, Blockade, nicht die eigene Nachricht, 15-Minuten-Fenster abgelaufen oder E-Mail nicht bestätigt.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Fehler"}}}},"404":{"$ref":"#/components/responses/NichtGefunden"}},"operationId":"patchMessagesConversationIdMessageId"},"delete":{"tags":["Chat"],"summary":"Eigene Nachricht löschen","description":"Nur die eigene Nachricht und nur in den ersten 15 Minuten. Sie bleibt für alle als gelöscht markiert (`isDeleted`) sichtbar; Text, Bild, Audio, Standort und Sticker werden entfernt. Wiederholtes Löschen ist unschädlich.\n\nVoraussetzung: bestätigte E-Mail-Adresse (sonst 403 „E-Mail nicht bestätigt“).\n\nNur aus der eigenen Seite aufrufbar: Browser-Anfragen von fremden Seiten beantwortet die App mit 403.","parameters":[{"name":"conversationId","in":"path","required":true,"description":"Kennung der Unterhaltung. Nur Teilnehmende haben Zugriff.","schema":{"type":"string"}},{"name":"messageId","in":"path","required":true,"description":"Kennung der Nachricht innerhalb der Unterhaltung.","schema":{"type":"string"}}],"security":[{"sitzungsCookie":[]}],"responses":{"200":{"description":"Gelöscht.","content":{"application/json":{"schema":{"type":"object","required":["message"],"properties":{"message":{"type":"object","properties":{"id":{"type":"string"}}}}}}}},"307":{"$ref":"#/components/responses/NichtAngemeldetWeiterleitung"},"403":{"description":"Keine Teilnahme, Blockade, nicht die eigene Nachricht, 15-Minuten-Fenster abgelaufen oder E-Mail nicht bestätigt.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Fehler"}}}},"404":{"$ref":"#/components/responses/NichtGefunden"}},"operationId":"deleteMessagesConversationIdMessageId"}},"/api/messages/{conversationId}/{messageId}/listen":{"post":{"tags":["Chat"],"summary":"Sprachnachricht als angehört melden","description":"Setzt die Lesebestätigung einer erhaltenen Sprachnachricht (idempotent).\n\nNur aus der eigenen Seite aufrufbar: Browser-Anfragen von fremden Seiten beantwortet die App mit 403.","parameters":[{"name":"conversationId","in":"path","required":true,"description":"Kennung der Unterhaltung. Nur Teilnehmende haben Zugriff.","schema":{"type":"string"}},{"name":"messageId","in":"path","required":true,"description":"Kennung der Nachricht innerhalb der Unterhaltung.","schema":{"type":"string"}}],"security":[{"sitzungsCookie":[]}],"responses":{"200":{"description":"Gemeldet.","content":{"application/json":{"schema":{"type":"object","required":["message"],"properties":{"message":{"type":"object","properties":{"messageId":{"type":"string"}}}}}}}},"307":{"$ref":"#/components/responses/NichtAngemeldetWeiterleitung"},"400":{"description":"Keine Sprachnachricht.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Fehler"}}}},"403":{"description":"Keine Teilnahme oder Blockade.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Fehler"}}}},"404":{"$ref":"#/components/responses/NichtGefunden"}},"operationId":"postMessagesConversationIdMessageIdListen"}},"/api/messages/{conversationId}/{messageId}/reaction":{"put":{"tags":["Chat"],"summary":"Mit Emoji auf eine Nachricht reagieren","description":"Setzt oder ersetzt die eigene Reaktion (eine je Person und Nachricht).\n\nVoraussetzung: bestätigte E-Mail-Adresse (sonst 403 „E-Mail nicht bestätigt“).\n\nNur aus der eigenen Seite aufrufbar: Browser-Anfragen von fremden Seiten beantwortet die App mit 403.","parameters":[{"name":"conversationId","in":"path","required":true,"description":"Kennung der Unterhaltung. Nur Teilnehmende haben Zugriff.","schema":{"type":"string"}},{"name":"messageId","in":"path","required":true,"description":"Kennung der Nachricht innerhalb der Unterhaltung.","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["emoji"],"properties":{"emoji":{"type":"string","maxLength":24}}}}}},"security":[{"sitzungsCookie":[]}],"responses":{"200":{"description":"Gesetzt.","content":{"application/json":{"schema":{"type":"object","required":["reaction"],"properties":{"reaction":{"type":"object","properties":{"messageId":{"type":"string"},"emoji":{"type":"string"}}}}}}}},"307":{"$ref":"#/components/responses/NichtAngemeldetWeiterleitung"},"400":{"description":"Ungültiges Emoji oder Nachricht gelöscht.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Fehler"}}}},"403":{"description":"Keine Teilnahme, Blockade oder E-Mail nicht bestätigt.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Fehler"}}}},"404":{"$ref":"#/components/responses/NichtGefunden"}},"operationId":"putMessagesConversationIdMessageIdReaction"},"delete":{"tags":["Chat"],"summary":"Eigene Reaktion entfernen","description":"Nur aus der eigenen Seite aufrufbar: Browser-Anfragen von fremden Seiten beantwortet die App mit 403.","parameters":[{"name":"conversationId","in":"path","required":true,"description":"Kennung der Unterhaltung. Nur Teilnehmende haben Zugriff.","schema":{"type":"string"}},{"name":"messageId","in":"path","required":true,"description":"Kennung der Nachricht innerhalb der Unterhaltung.","schema":{"type":"string"}}],"security":[{"sitzungsCookie":[]}],"responses":{"200":{"description":"Entfernt (auch, wenn keine vorhanden war).","content":{"application/json":{"schema":{"type":"object","required":["reaction"],"properties":{"reaction":{"type":"object","properties":{"messageId":{"type":"string"}}}}}}}},"307":{"$ref":"#/components/responses/NichtAngemeldetWeiterleitung"},"403":{"description":"Keine Teilnahme oder Blockade.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Fehler"}}}},"404":{"$ref":"#/components/responses/NichtGefunden"}},"operationId":"deleteMessagesConversationIdMessageIdReaction"}},"/api/messages/{conversationId}/block":{"get":{"tags":["Chat"],"summary":"Blockadestand einer Unterhaltung","description":"Aus Sicht der angemeldeten Person. Ob das Gegenüber blockiert hat, verrät die Antwort nicht eigens — nur, ob die Unterhaltung gesperrt ist.","parameters":[{"name":"conversationId","in":"path","required":true,"description":"Kennung der Unterhaltung. Nur Teilnehmende haben Zugriff.","schema":{"type":"string"}}],"security":[{"sitzungsCookie":[]}],"responses":{"200":{"description":"Stand.","content":{"application/json":{"schema":{"type":"object","required":["blockedByMe","gesperrt"],"properties":{"blockedByMe":{"type":"boolean"},"gesperrt":{"type":"boolean"}}}}}},"307":{"$ref":"#/components/responses/NichtAngemeldetWeiterleitung"},"404":{"description":"Keine Teilnahme an der Unterhaltung.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Fehler"}}}}},"operationId":"getMessagesConversationIdBlock"}},"/api/messages/{conversationId}/gif":{"post":{"tags":["Chat"],"summary":"Sticker senden","description":"Tageslimit gemeinsam mit Textnachrichten (1000 je Konto).\n\nVoraussetzung: bestätigte E-Mail-Adresse (sonst 403 „E-Mail nicht bestätigt“).\n\nNur aus der eigenen Seite aufrufbar: Browser-Anfragen von fremden Seiten beantwortet die App mit 403.","parameters":[{"name":"conversationId","in":"path","required":true,"description":"Kennung der Unterhaltung. Nur Teilnehmende haben Zugriff.","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["gifId"],"properties":{"gifId":{"type":"string","description":"Kennung eines Stickers aus dem Katalog der App."}}}}}},"security":[{"sitzungsCookie":[]}],"responses":{"200":{"description":"Gesendet.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/NachrichtAngelegt"}}}},"307":{"$ref":"#/components/responses/NichtAngemeldetWeiterleitung"},"400":{"description":"Ungültige Eingabe (Inhalt, Datei, Dauer, Koordinaten) oder Gespräch durch Blockade gesperrt.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Fehler"}}}},"403":{"description":"Keine Teilnahme an der Unterhaltung — oder E-Mail-Adresse nicht bestätigt.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Fehler"}}}},"429":{"$ref":"#/components/responses/Tageslimit"}},"operationId":"postMessagesConversationIdGif"}},"/api/messages/{conversationId}/location":{"post":{"tags":["Chat"],"summary":"Standort in den Chat senden","description":"Teilt einen von der Person gewählten Punkt als Nachricht — das ist eine bewusste Einzelaktion, kein Live-Standort. Tageslimit gemeinsam mit Textnachrichten (1000 je Konto).\n\nVoraussetzung: bestätigte E-Mail-Adresse (sonst 403 „E-Mail nicht bestätigt“).\n\nNur aus der eigenen Seite aufrufbar: Browser-Anfragen von fremden Seiten beantwortet die App mit 403.","parameters":[{"name":"conversationId","in":"path","required":true,"description":"Kennung der Unterhaltung. Nur Teilnehmende haben Zugriff.","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Koordinaten"}}}},"security":[{"sitzungsCookie":[]}],"responses":{"200":{"description":"Gesendet.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/NachrichtAngelegt"}}}},"307":{"$ref":"#/components/responses/NichtAngemeldetWeiterleitung"},"400":{"description":"Ungültige Eingabe (Inhalt, Datei, Dauer, Koordinaten) oder Gespräch durch Blockade gesperrt.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Fehler"}}}},"403":{"description":"Keine Teilnahme an der Unterhaltung — oder E-Mail-Adresse nicht bestätigt.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Fehler"}}}},"429":{"$ref":"#/components/responses/Tageslimit"}},"operationId":"postMessagesConversationIdLocation"}},"/api/messages/{conversationId}/mute":{"post":{"tags":["Chat"],"summary":"Unterhaltung stumm schalten oder wieder laut","description":"Wer nicht teilnimmt, ändert nichts und bekommt `ok: false` — kein 403, damit die Antwort nicht verrät, ob es die Unterhaltung gibt.\n\nNur aus der eigenen Seite aufrufbar: Browser-Anfragen von fremden Seiten beantwortet die App mit 403.","parameters":[{"name":"conversationId","in":"path","required":true,"description":"Kennung der Unterhaltung. Nur Teilnehmende haben Zugriff.","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["stumm"],"properties":{"stumm":{"type":"boolean"}}}}}},"security":[{"sitzungsCookie":[]}],"responses":{"200":{"description":"Ergebnis.","content":{"application/json":{"schema":{"type":"object","required":["ok"],"properties":{"ok":{"type":"boolean"}}}}}},"307":{"$ref":"#/components/responses/NichtAngemeldetWeiterleitung"},"400":{"description":"`stumm` fehlt oder ist kein Wahrheitswert.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Fehler"}}}},"403":{"$ref":"#/components/responses/FremdeHerkunft"}},"operationId":"postMessagesConversationIdMute"}},"/api/messages/{conversationId}/photo":{"post":{"tags":["Chat"],"summary":"Foto senden","description":"Das Foto wird zu WebP umgerechnet und ohne Metadaten gespeichert. Abrufbar nur für Teilnehmende (`/uploads/messages/…`). Tageslimit: 100 Fotos je Konto.\n\nVoraussetzung: bestätigte E-Mail-Adresse (sonst 403 „E-Mail nicht bestätigt“).\n\nNur aus der eigenen Seite aufrufbar: Browser-Anfragen von fremden Seiten beantwortet die App mit 403.","parameters":[{"name":"conversationId","in":"path","required":true,"description":"Kennung der Unterhaltung. Nur Teilnehmende haben Zugriff.","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"multipart/form-data":{"schema":{"type":"object","required":["photo"],"properties":{"photo":{"type":"string","format":"binary","description":"Bild, höchstens 12 MB."},"caption":{"type":"string","description":"Bildunterschrift."}}}}}},"security":[{"sitzungsCookie":[]}],"responses":{"200":{"description":"Gesendet.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/NachrichtAngelegt"}}}},"307":{"$ref":"#/components/responses/NichtAngemeldetWeiterleitung"},"400":{"description":"Ungültige Eingabe (Inhalt, Datei, Dauer, Koordinaten) oder Gespräch durch Blockade gesperrt.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Fehler"}}}},"403":{"description":"Keine Teilnahme an der Unterhaltung — oder E-Mail-Adresse nicht bestätigt.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Fehler"}}}},"413":{"$ref":"#/components/responses/ZuGross"},"429":{"$ref":"#/components/responses/Tageslimit"},"500":{"description":"Speichern fehlgeschlagen.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Fehler"}}}}},"operationId":"postMessagesConversationIdPhoto"}},"/api/messages/{conversationId}/presence":{"get":{"tags":["Chat"],"summary":"Zuletzt gesehen und grobe Entfernung des Gegenübers","description":"Bei Blockade (in eine der beiden Richtungen) oder ohne Gegenüber sind beide Werte `null`. `distanceBucket` ist zusätzlich `null`, wenn nicht beide Personen in der Nachbarschaft sichtbar sind; die Entfernung kommt als grobe Stufe.","parameters":[{"name":"conversationId","in":"path","required":true,"description":"Kennung der Unterhaltung. Nur Teilnehmende haben Zugriff.","schema":{"type":"string"}}],"security":[{"sitzungsCookie":[]}],"responses":{"200":{"description":"Anwesenheit.","content":{"application/json":{"schema":{"type":"object","required":["lastSeenAt","distanceBucket"],"properties":{"lastSeenAt":{"type":["string","null"],"format":"date-time"},"distanceBucket":{"oneOf":[{"type":"null"},{"$ref":"#/components/schemas/Entfernungsstufe"}]}}}}}},"307":{"$ref":"#/components/responses/NichtAngemeldetWeiterleitung"},"403":{"description":"Keine Teilnahme an der Unterhaltung.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Fehler"}}}}},"operationId":"getMessagesConversationIdPresence"}},"/api/messages/{conversationId}/stream":{"get":{"tags":["Chat"],"summary":"Ereignisse einer Unterhaltung live empfangen (Server-Sent Events)","description":"Offener Ereignisstrom. Ereignisse: `connected` (`{ conversationId }`), `message` (`{ kind, messageId?, readerId? }` — nur Kennungen, den Inhalt holt der Client über `GET /api/messages/{conversationId}`), `typing` (`{ senderId }`) und `block` (`{}`; danach den Stand über `…/block` abfragen). Bei Blockade kommt außer `block` nichts mehr durch. Herzschlag alle 25 Sekunden; höchstens 8 gleichzeitige Ströme je Person. Solange der Strom steht, gilt die Unterhaltung als geöffnet — für neue Nachrichten darin gibt es dann keine Push-Mitteilung. Fehlertexte sind reiner Text.","parameters":[{"name":"conversationId","in":"path","required":true,"description":"Kennung der Unterhaltung. Nur Teilnehmende haben Zugriff.","schema":{"type":"string"}}],"security":[{"sitzungsCookie":[]}],"responses":{"200":{"description":"Ereignisstrom.","content":{"text/event-stream":{"schema":{"type":"string"}}}},"307":{"$ref":"#/components/responses/NichtAngemeldetWeiterleitung"},"403":{"description":"Keine Teilnahme (Text `Forbidden`).","content":{"text/plain":{"schema":{"type":"string"}}}}},"operationId":"getMessagesConversationIdStream"}},"/api/messages/{conversationId}/typing":{"post":{"tags":["Chat"],"summary":"„schreibt …“ melden","description":"Sendet das Ereignis `typing` an die Unterhaltung. Bei Blockade 403.\n\nNur aus der eigenen Seite aufrufbar: Browser-Anfragen von fremden Seiten beantwortet die App mit 403.","parameters":[{"name":"conversationId","in":"path","required":true,"description":"Kennung der Unterhaltung. Nur Teilnehmende haben Zugriff.","schema":{"type":"string"}}],"security":[{"sitzungsCookie":[]}],"responses":{"200":{"description":"Gemeldet.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Ok"}}}},"307":{"$ref":"#/components/responses/NichtAngemeldetWeiterleitung"},"403":{"description":"Keine Teilnahme oder Blockade.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Fehler"}}}}},"operationId":"postMessagesConversationIdTyping"}},"/api/messages/{conversationId}/verlauf":{"delete":{"tags":["Chat"],"summary":"Verlauf für die eigene Person löschen","description":"Wer nicht teilnimmt, ändert nichts und bekommt `ok: false`. `endgueltig` zeigt, dass beide Seiten gelöscht haben und die Unterhaltung entfernt wurde.\n\nNur aus der eigenen Seite aufrufbar: Browser-Anfragen von fremden Seiten beantwortet die App mit 403.","parameters":[{"name":"conversationId","in":"path","required":true,"description":"Kennung der Unterhaltung. Nur Teilnehmende haben Zugriff.","schema":{"type":"string"}}],"security":[{"sitzungsCookie":[]}],"responses":{"200":{"description":"Ergebnis.","content":{"application/json":{"schema":{"type":"object","required":["ok","endgueltig"],"properties":{"ok":{"type":"boolean"},"endgueltig":{"type":"boolean"}}}}}},"307":{"$ref":"#/components/responses/NichtAngemeldetWeiterleitung"},"403":{"$ref":"#/components/responses/FremdeHerkunft"}},"operationId":"deleteMessagesConversationIdVerlauf"}},"/api/messages/{conversationId}/voice":{"post":{"tags":["Chat"],"summary":"Sprachnachricht senden","description":"Audio als WebM oder MP4/M4A, höchstens 8 MB und 120 Sekunden. Abrufbar nur für Teilnehmende. Tageslimit: 100 Sprachnachrichten je Konto.\n\nVoraussetzung: bestätigte E-Mail-Adresse (sonst 403 „E-Mail nicht bestätigt“).\n\nNur aus der eigenen Seite aufrufbar: Browser-Anfragen von fremden Seiten beantwortet die App mit 403.","parameters":[{"name":"conversationId","in":"path","required":true,"description":"Kennung der Unterhaltung. Nur Teilnehmende haben Zugriff.","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"multipart/form-data":{"schema":{"type":"object","required":["audio","durationSeconds"],"properties":{"audio":{"type":"string","format":"binary"},"durationSeconds":{"type":"integer","minimum":1,"maximum":120}}}}}},"security":[{"sitzungsCookie":[]}],"responses":{"200":{"description":"Gesendet.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/NachrichtAngelegt"}}}},"307":{"$ref":"#/components/responses/NichtAngemeldetWeiterleitung"},"400":{"description":"Ungültige Eingabe (Inhalt, Datei, Dauer, Koordinaten) oder Gespräch durch Blockade gesperrt.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Fehler"}}}},"403":{"description":"Keine Teilnahme an der Unterhaltung — oder E-Mail-Adresse nicht bestätigt.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Fehler"}}}},"413":{"$ref":"#/components/responses/ZuGross"},"429":{"$ref":"#/components/responses/Tageslimit"},"500":{"description":"Speichern fehlgeschlagen.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Fehler"}}}}},"operationId":"postMessagesConversationIdVoice"}},"/api/neighborhood/overview":{"get":{"tags":["Nachbarschaft"],"summary":"Nachbarschaft: Sichtbarkeit, Personen, Anfragen und Heatmap","description":"Ist die eigene Sichtbarkeit aus, sind alle Listen leer. Die Heatmap enthält nur aggregierte Zellen mit Mindestanzahl; es gibt keine Einzelmarker und keine genauen Standorte.","security":[{"sitzungsCookie":[]}],"responses":{"200":{"description":"Übersicht.","content":{"application/json":{"schema":{"type":"object","required":["visible","neighbors","incomingRequests","heatmap"],"properties":{"visible":{"type":"boolean"},"neighbors":{"type":"array","items":{"$ref":"#/components/schemas/Nachbar"}},"incomingRequests":{"type":"array","items":{"$ref":"#/components/schemas/NachbarAnfrage"}},"heatmap":{"type":"object","required":["cells","center"],"properties":{"cells":{"type":"array","items":{"$ref":"#/components/schemas/HeatmapZelle"}},"center":{"oneOf":[{"type":"null"},{"$ref":"#/components/schemas/Koordinaten"}]}}}}}}}},"307":{"$ref":"#/components/responses/NichtAngemeldetWeiterleitung"}},"operationId":"getNeighborhoodOverview"}},"/api/neighborhood/visibility":{"post":{"tags":["Nachbarschaft"],"summary":"Sichtbarkeit in der Nachbarschaft ein- oder ausschalten","description":"Einschalten braucht einen Standort: aus dem Body oder, wenn dort keiner steht, aus der Postleitzahl im Profil. Er wird gerundet gespeichert. Ausschalten löscht den gespeicherten Standort.\n\nNur aus der eigenen Seite aufrufbar: Browser-Anfragen von fremden Seiten beantwortet die App mit 403.","requestBody":{"required":true,"content":{"application/json":{"schema":{"oneOf":[{"type":"object","required":["action"],"properties":{"action":{"const":"enable"},"lat":{"type":"number","minimum":-90,"maximum":90},"lng":{"type":"number","minimum":-180,"maximum":180}}},{"type":"object","required":["action"],"properties":{"action":{"const":"disable"}}}]}}}},"security":[{"sitzungsCookie":[]}],"responses":{"200":{"description":"Umgestellt.","content":{"application/json":{"schema":{"type":"object","required":["ok","visible"],"properties":{"ok":{"const":true},"visible":{"type":"boolean"}}}}}},"307":{"$ref":"#/components/responses/NichtAngemeldetWeiterleitung"},"400":{"description":"Ungültige Eingabe, oder kein Standort verfügbar (`{ \"error\": \"no_location\" }`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Fehler"}}}},"403":{"$ref":"#/components/responses/FremdeHerkunft"},"500":{"description":"Fehler.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Fehler"}}}}},"operationId":"postNeighborhoodVisibility"}},"/api/neighborhood/location/refresh":{"post":{"tags":["Nachbarschaft"],"summary":"Gespeicherten Standort auffrischen","description":"Wirkt nur bei eingeschalteter Sichtbarkeit; der Standort wird gerundet gespeichert. Höchstens 30 Aufrufe pro Stunde je Konto.\n\nNur aus der eigenen Seite aufrufbar: Browser-Anfragen von fremden Seiten beantwortet die App mit 403.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Koordinaten"}}}},"security":[{"sitzungsCookie":[]}],"responses":{"200":{"description":"Aufgefrischt.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Ok"}}}},"307":{"$ref":"#/components/responses/NichtAngemeldetWeiterleitung"},"400":{"description":"Ungültige Koordinaten.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Fehler"}}}},"403":{"$ref":"#/components/responses/FremdeHerkunft"},"429":{"description":"Zu viele Standort-Aktualisierungen.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Fehler"}}}}},"operationId":"postNeighborhoodLocationRefresh"}},"/api/neighborhood/requests":{"post":{"tags":["Nachbarschaft"],"summary":"Nachbarschaftsanfrage senden","description":"Nur an sichtbare Personen im Umkreis (20 km) und nicht bei Blockade. Je Konto gilt ein Tageslimit für Anfragen (429).\n\nNur aus der eigenen Seite aufrufbar: Browser-Anfragen von fremden Seiten beantwortet die App mit 403.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["recipientId","message"],"properties":{"recipientId":{"type":"string","minLength":1},"message":{"type":"string","minLength":1,"maxLength":300}}}}}},"security":[{"sitzungsCookie":[]}],"responses":{"200":{"description":"Ergebnis.","content":{"application/json":{"schema":{"type":"object","required":["status"],"properties":{"status":{"enum":["sent","reverse_exists","already_connected"]}}}}}},"307":{"$ref":"#/components/responses/NichtAngemeldetWeiterleitung"},"400":{"description":"Ungültige Eingabe, Anfrage an sich selbst oder Blockade.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Fehler"}}}},"403":{"description":"Zu weit entfernt oder nicht in der Nachbarschaft sichtbar.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Fehler"}}}},"429":{"description":"Tageslimit für Anfragen erreicht.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Fehler"}}}}},"operationId":"postNeighborhoodRequests"}},"/api/neighborhood/requests/{id}":{"post":{"tags":["Nachbarschaft"],"summary":"Nachbarschaftsanfrage annehmen oder ablehnen","description":"Beim Annehmen entsteht eine Unterhaltung, ihre Kennung steht in der Antwort.\n\nNur aus der eigenen Seite aufrufbar: Browser-Anfragen von fremden Seiten beantwortet die App mit 403.","parameters":[{"name":"id","in":"path","required":true,"description":"Kennung der Anfrage.","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["action"],"properties":{"action":{"enum":["accept","decline"]}}}}}},"security":[{"sitzungsCookie":[]}],"responses":{"200":{"description":"Beantwortet.","content":{"application/json":{"schema":{"type":"object","required":["conversationId"],"properties":{"conversationId":{"type":["string","null"]}}}}}},"307":{"$ref":"#/components/responses/NichtAngemeldetWeiterleitung"},"400":{"description":"Ungültige Eingabe.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Fehler"}}}},"403":{"description":"Nicht die Empfängerin oder der Empfänger der Anfrage.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Fehler"}}}},"409":{"description":"Mit dieser Person ist kein Chat möglich (Blockade).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Fehler"}}}}},"operationId":"postNeighborhoodRequestsId"}},"/api/notifications/subscribe":{"post":{"tags":["Benachrichtigungen"],"summary":"Push-Mitteilungen für dieses Gerät einschalten","description":"Speichert ein Web-Push-Abonnement. Nur bekannte Push-Dienste sind erlaubt. Höchstens 10 Abonnements je Konto — ältere weichen dem neuen. Ein Abonnement, das schon einem anderen Konto gehört, wird nur bei gleichen Schlüsseln übernommen. Body höchstens 8 KB.\n\nNur aus der eigenen Seite aufrufbar: Browser-Anfragen von fremden Seiten beantwortet die App mit 403.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["endpoint","keys"],"description":"Das Ergebnis von `PushSubscription.toJSON()` des Browsers.","properties":{"endpoint":{"type":"string","format":"uri"},"keys":{"type":"object","required":["p256dh","auth"],"properties":{"p256dh":{"type":"string","maxLength":200},"auth":{"type":"string","maxLength":100}}}}}}}},"security":[{"sitzungsCookie":[]}],"responses":{"200":{"description":"Gespeichert.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Ok"}}}},"307":{"$ref":"#/components/responses/NichtAngemeldetWeiterleitung"},"400":{"description":"Pflichtfelder fehlen, unbekannter Push-Dienst oder ungültige Schlüssel.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Fehler"}}}},"403":{"$ref":"#/components/responses/FremdeHerkunft"},"409":{"description":"Das Abonnement gehört zu einem anderen Konto.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Fehler"}}}},"413":{"description":"Zu groß.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Fehler"}}}},"500":{"description":"Datenbankfehler.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Fehler"}}}}},"operationId":"postNotificationsSubscribe"}},"/api/notifications/unsubscribe":{"delete":{"tags":["Benachrichtigungen"],"summary":"Push-Mitteilungen für dieses Gerät ausschalten","description":"Nur aus der eigenen Seite aufrufbar: Browser-Anfragen von fremden Seiten beantwortet die App mit 403.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["endpoint"],"properties":{"endpoint":{"type":"string"}}}}}},"security":[{"sitzungsCookie":[]}],"responses":{"200":{"description":"Entfernt (auch, wenn es kein Abonnement gab).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Ok"}}}},"307":{"$ref":"#/components/responses/NichtAngemeldetWeiterleitung"},"400":{"description":"`endpoint` fehlt oder ungültiges JSON.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Fehler"}}}},"403":{"$ref":"#/components/responses/FremdeHerkunft"},"500":{"description":"Datenbankfehler.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Fehler"}}}}},"operationId":"deleteNotificationsUnsubscribe"}},"/api/notifications/test-push":{"post":{"tags":["Benachrichtigungen"],"summary":"Testmitteilung an das jüngste Abonnement senden","description":"Höchstens 5 Tests pro Stunde und Konto. Ein abgelaufenes Abonnement wird dabei entfernt. Aus der Store-App mit `{ geraeteToken }` im Körper: dann an genau dieses Gerät über FCM.\n\nNur aus der eigenen Seite aufrufbar: Browser-Anfragen von fremden Seiten beantwortet die App mit 403.","security":[{"sitzungsCookie":[]}],"responses":{"200":{"description":"Gesendet.","content":{"application/json":{"schema":{"type":"object","required":["success"],"properties":{"success":{"const":true}}}}}},"307":{"$ref":"#/components/responses/NichtAngemeldetWeiterleitung"},"400":{"description":"Store-App: ungültiges Gerätetoken.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Fehler"}}}},"403":{"$ref":"#/components/responses/FremdeHerkunft"},"404":{"description":"Auf dem Gerät sind Mitteilungen noch nicht eingeschaltet.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Fehler"}}}},"429":{"description":"Zu viele Tests.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Fehler"}}}},"502":{"description":"Der Push-Dienst hat abgelehnt; das Abonnement ist möglicherweise abgelaufen.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Fehler"}}}},"503":{"description":"Mitteilungen sind serverseitig nicht eingerichtet.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Fehler"}}}}},"operationId":"postNotificationsTestPush"}},"/api/notifications/native":{"get":{"tags":["Benachrichtigungen"],"summary":"Mitteilungen in der Store-App verfügbar?","description":"Die Android-App fragt vor dem Einschalten, ob der Versand über Firebase Cloud Messaging eingerichtet ist.","security":[{"sitzungsCookie":[]}],"responses":{"200":{"description":"Stand.","content":{"application/json":{"schema":{"type":"object","required":["verfuegbar"],"properties":{"verfuegbar":{"type":"boolean"}}}}}},"307":{"$ref":"#/components/responses/NichtAngemeldetWeiterleitung"}},"operationId":"getNotificationsNative"},"post":{"tags":["Benachrichtigungen"],"summary":"Gerät der Store-App für Mitteilungen anmelden","description":"Speichert das FCM-Token der App. Ein Token, das schon einem anderen Konto gehört, wandert zum aufrufenden Konto (Kontowechsel auf dem Gerät). Höchstens 5 Geräte je Konto — ältere weichen. Body höchstens 4 KB.\n\nNur aus der eigenen Seite aufrufbar: Browser-Anfragen von fremden Seiten beantwortet die App mit 403.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["token","plattform"],"properties":{"token":{"type":"string","minLength":20,"maxLength":1024,"pattern":"^[A-Za-z0-9_:.-]+$"},"plattform":{"type":"string","enum":["android","ios"]}}}}}},"security":[{"sitzungsCookie":[]}],"responses":{"200":{"description":"Gespeichert.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Ok"}}}},"307":{"$ref":"#/components/responses/NichtAngemeldetWeiterleitung"},"400":{"description":"Ungültiges JSON, Token oder Plattform.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Fehler"}}}},"403":{"$ref":"#/components/responses/FremdeHerkunft"},"413":{"description":"Zu groß.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Fehler"}}}},"500":{"description":"Datenbankfehler.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Fehler"}}}},"503":{"description":"Mitteilungen in der App sind serverseitig nicht eingerichtet.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Fehler"}}}}},"operationId":"postNotificationsNative"},"delete":{"tags":["Benachrichtigungen"],"summary":"Gerät der Store-App abmelden","description":"Läuft auch beim Abmelden aus der App, wenn die Sitzung schon beendet ist. Mit Sitzung nur eigene Geräte; ohne genügt das Token (kennt nur das Gerät).\n\nNur aus der eigenen Seite aufrufbar: Browser-Anfragen von fremden Seiten beantwortet die App mit 403.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["token"],"properties":{"token":{"type":"string"}}}}}},"security":[{},{"sitzungsCookie":[]}],"responses":{"200":{"description":"Entfernt (auch, wenn es das Gerät nicht gab).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Ok"}}}},"400":{"description":"Ungültiges JSON oder Token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Fehler"}}}},"403":{"$ref":"#/components/responses/FremdeHerkunft"},"500":{"description":"Datenbankfehler.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Fehler"}}}}},"operationId":"deleteNotificationsNative"}},"/api/notifications/mhd-check":{"post":{"tags":["Benachrichtigungen"],"summary":"Mitteilungen zu ablaufenden Lebensmitteln jetzt erzeugen","description":"Prüft das MHD in allen Haushalten der Person und legt eine Sammelmitteilung an (Glocke und Push).\n\nNur aus der eigenen Seite aufrufbar: Browser-Anfragen von fremden Seiten beantwortet die App mit 403.","security":[{"sitzungsCookie":[]}],"responses":{"200":{"description":"Anzahl neu angelegter Mitteilungen.","content":{"application/json":{"schema":{"type":"object","required":["count"],"properties":{"count":{"type":"integer","minimum":0}}}}}},"401":{"$ref":"#/components/responses/NichtAngemeldet"},"403":{"$ref":"#/components/responses/FremdeHerkunft"}},"operationId":"postNotificationsMhdCheck"}},"/api/postal-codes/search":{"get":{"tags":["Sonstiges"],"summary":"Postleitzahlen und Orte suchen","description":"Sucht nach Postleitzahl (Anfang) oder Ortsname (Teilstring). Bei zu kurzer oder ungültiger Suche ist `results` leer, kein Fehler.","parameters":[{"name":"q","in":"query","required":true,"description":"Suchtext, 2 bis 120 Zeichen.","schema":{"type":"string","minLength":2,"maxLength":120}},{"name":"limit","in":"query","required":false,"description":"Höchstzahl der Treffer.","schema":{"type":"integer","minimum":1,"maximum":20,"default":10}}],"security":[{"sitzungsCookie":[]}],"responses":{"200":{"description":"Treffer.","content":{"application/json":{"schema":{"type":"object","required":["results"],"properties":{"results":{"type":"array","items":{"$ref":"#/components/schemas/Postleitzahl"}}}}}}},"307":{"$ref":"#/components/responses/NichtAngemeldetWeiterleitung"}},"operationId":"getPostalCodesSearch"}},"/api/presence/ping":{"post":{"tags":["Sonstiges"],"summary":"Anwesenheit melden","description":"Setzt „zuletzt gesehen“ und liefert die Zahl ungelesener Nachrichten. Die App ruft das regelmäßig auf, solange sie offen ist. Fehler bei der Zählung führen nur dazu, dass `ungelesen` fehlt.\n\nNur aus der eigenen Seite aufrufbar: Browser-Anfragen von fremden Seiten beantwortet die App mit 403.","security":[{"sitzungsCookie":[]}],"responses":{"200":{"description":"Gemeldet.","content":{"application/json":{"schema":{"type":"object","required":["ok"],"properties":{"ok":{"const":true},"ungelesen":{"type":"integer","minimum":0}}}}}},"307":{"$ref":"#/components/responses/NichtAngemeldetWeiterleitung"},"403":{"$ref":"#/components/responses/FremdeHerkunft"}},"operationId":"postPresencePing"}},"/api/users/{userId}/block":{"post":{"tags":["Sonstiges"],"summary":"Person blockieren","description":"Eine Blockade wirkt in beide Richtungen: kein Chat, keine Anwesenheit, keine Profilbilder. Haushaltsmitglieder und die eigene Person lassen sich nicht blockieren.\n\nNur aus der eigenen Seite aufrufbar: Browser-Anfragen von fremden Seiten beantwortet die App mit 403.","parameters":[{"name":"userId","in":"path","required":true,"description":"Kennung der zu blockierenden Person.","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["reason"],"properties":{"reason":{"enum":["SPAM","HARASSMENT","NOT_INTERESTED","OTHER"]},"note":{"type":["string","null"],"maxLength":300}}}}}},"security":[{"sitzungsCookie":[]}],"responses":{"200":{"description":"Blockiert.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Ok"}}}},"307":{"$ref":"#/components/responses/NichtAngemeldetWeiterleitung"},"400":{"description":"Ungültige Eingabe, eigene Person oder Haushaltsmitglied.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Fehler"}}}},"403":{"$ref":"#/components/responses/FremdeHerkunft"}},"operationId":"postUsersUserIdBlock"}},"/api/users/{userId}/unblock":{"post":{"tags":["Sonstiges"],"summary":"Eigene Blockade aufheben","description":"Nur wer blockiert hat, kann die Blockade aufheben.\n\nNur aus der eigenen Seite aufrufbar: Browser-Anfragen von fremden Seiten beantwortet die App mit 403.","parameters":[{"name":"userId","in":"path","required":true,"description":"Kennung der Person, die blockiert wurde.","schema":{"type":"string"}}],"security":[{"sitzungsCookie":[]}],"responses":{"200":{"description":"Aufgehoben.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Ok"}}}},"307":{"$ref":"#/components/responses/NichtAngemeldetWeiterleitung"},"403":{"$ref":"#/components/responses/FremdeHerkunft"},"404":{"description":"Es gab keine eigene Blockade.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Fehler"}}}}},"operationId":"postUsersUserIdUnblock"}}},"components":{"securitySchemes":{"sitzungsCookie":{"type":"apiKey","in":"cookie","name":"authjs.session-token","description":"Sitzungs-Cookie von Auth.js. Unter HTTPS heißt es `__Secure-authjs.session-token`. Es wird bei der Anmeldung gesetzt und ist `HttpOnly`."}},"schemas":{"Fehler":{"type":"object","required":["error"],"properties":{"error":{"type":"string","description":"Meldung oder kurzer Fehlercode. Keine internen Details."}},"description":"Fehlerform fast aller JSON-Antworten. Einzelne Routen antworten mit reinem Text (`Unauthorized`, `Forbidden`, `Not Found`) — das ist an der Route vermerkt."},"Ok":{"type":"object","required":["ok"],"properties":{"ok":{"type":"boolean","const":true}}},"ZuvieleAnfragen":{"type":"object","required":["error"],"properties":{"error":{"type":"string"},"retryAfter":{"type":"integer","description":"Sekunden bis zum nächsten erlaubten Versuch (nur bei IP-Limits der Anmeldung)."}}},"ChatNachricht":{"type":"object","required":["id","content","createdAt","isOwn","senderId","senderName","isDeleted","reactions"],"properties":{"id":{"type":"string"},"content":{"type":"string","description":"Text, höchstens 500 Zeichen. Bei Foto-Nachrichten die Bildunterschrift (bis 2000 Zeichen), bei Sprach-, Standort- und Sticker-Nachrichten leer."},"createdAt":{"type":"string","format":"date-time"},"readAt":{"type":["string","null"],"format":"date-time","description":"Wann die andere Seite die Nachricht gelesen hat."},"isOwn":{"type":"boolean","description":"Stammt die Nachricht von der angemeldeten Person?"},"senderId":{"type":"string"},"senderName":{"type":"string"},"imageUrl":{"type":["string","null"],"description":"Adresse eines Chat-Fotos (`/uploads/messages/…`, nur für Teilnehmende abrufbar)."},"audioUrl":{"type":["string","null"],"description":"Adresse einer Sprachnachricht (`/uploads/messages/…`)."},"durationSeconds":{"type":["integer","null"]},"location":{"oneOf":[{"type":"null"},{"type":"object","required":["lat","lng"],"properties":{"lat":{"type":"number"},"lng":{"type":"number"}}}],"description":"Vom Absender bewusst geteilter Standort einer Standort-Nachricht."},"gifId":{"type":["string","null"]},"editedAt":{"type":["string","null"],"format":"date-time"},"isDeleted":{"type":"boolean"},"reactions":{"type":"array","items":{"type":"object","required":["emoji","userId"],"properties":{"emoji":{"type":"string"},"userId":{"type":"string"}}}}}},"Nachrichtenseite":{"type":"object","required":["messages","hasMore"],"properties":{"messages":{"type":"array","items":{"$ref":"#/components/schemas/ChatNachricht"},"description":"Aufsteigend nach Zeit."},"hasMore":{"type":"boolean","description":"Gibt es jenseits dieser Seite noch Nachrichten?"}}},"NachrichtAngelegt":{"type":"object","required":["message"],"properties":{"message":{"type":"object","required":["id","createdAt"],"properties":{"id":{"type":"string"},"createdAt":{"type":"string","format":"date-time"}}}}},"Unterhaltung":{"type":"object","required":["otherUserId","otherUserName","unreadCount","blockedByMe","blockedMe"],"properties":{"id":{"type":["string","null"],"description":"Leer, solange es mit dieser Person noch keine Unterhaltung gibt."},"otherUserId":{"type":"string"},"otherUserName":{"type":"string"},"otherUserAvatarUrl":{"type":["string","null"]},"otherLastSeenAt":{"type":["string","null"],"format":"date-time"},"listingId":{"type":["string","null"]},"listingTitle":{"type":["string","null"]},"isFriend":{"type":"boolean"},"lastMessage":{"type":["string","null"]},"lastMessageKind":{"enum":["photo","gif","location","voice",null]},"lastMessageAt":{"type":["string","null"],"format":"date-time"},"lastMessageIsOwn":{"type":"boolean"},"lastMessageReadAt":{"type":["string","null"],"format":"date-time"},"unreadCount":{"type":"integer","minimum":0},"blockedByMe":{"type":"boolean"},"blockedMe":{"type":"boolean"}}},"NachbarAnfrage":{"type":"object","required":["id","requesterId","requesterName","message","createdAt"],"properties":{"id":{"type":"string"},"requesterId":{"type":"string"},"requesterName":{"type":"string"},"requesterAvatarUrl":{"type":["string","null"]},"message":{"type":"string"},"createdAt":{"type":"string","format":"date-time"}}},"Nachbar":{"type":"object","required":["userId","name","distanceBucket","status"],"description":"Eine sichtbare Person im Umkreis mit grober Entfernungsstufe. Gespeicherte Standorte sind gerundet; die Karte zeigt nur aggregierte Zellen.","properties":{"userId":{"type":"string"},"name":{"type":"string"},"avatarUrl":{"type":["string","null"]},"lastSeenAt":{"type":["string","null"],"format":"date-time"},"isFriend":{"type":"boolean"},"distanceBucket":{"$ref":"#/components/schemas/Entfernungsstufe"},"status":{"enum":["none","requested","connected"]},"conversationId":{"type":["string","null"]},"unreadCount":{"type":"integer","minimum":0}}},"Entfernungsstufe":{"enum":["<1","1-3","3-10",">10"],"description":"Grobe Entfernung in Kilometern."},"HeatmapZelle":{"type":"object","required":["lat","lng","count"],"description":"Aggregierte Zelle. Es werden nur Zellen mit mindestens drei sichtbaren Personen ausgeliefert — nie Einzelpositionen.","properties":{"lat":{"type":"number"},"lng":{"type":"number"},"count":{"type":"integer","minimum":3}}},"Koordinaten":{"type":"object","required":["lat","lng"],"properties":{"lat":{"type":"number","minimum":-90,"maximum":90},"lng":{"type":"number","minimum":-180,"maximum":180}}},"Postleitzahl":{"type":"object","properties":{"id":{"type":"string"},"plz":{"type":"string"},"place":{"type":"string"},"state":{"type":"string"},"lat":{"type":"number"},"lng":{"type":"number"},"population":{"type":["integer","null"]},"areaKm2":{"type":["number","null"]}},"description":"Treffer der Ortssuche. Weitere Felder können hinzukommen."}},"responses":{"NichtAngemeldetWeiterleitung":{"description":"Keine gültige Sitzung: Die Route leitet zum Login weiter (HTTP 307, `Location: /login` — mit `callbackUrl`, wenn die Anfrage von einer eigenen Seite kam). Es gibt keinen 401 mit Fehlertext. Programmatische Clients sollten Weiterleitungen nicht folgen und die Sitzung erneuern.","headers":{"Location":{"schema":{"type":"string"}}}},"NichtAngemeldet":{"description":"Keine gültige Sitzung.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Fehler"}},"text/plain":{"schema":{"type":"string","examples":["Unauthorized"]}}}},"FremdeHerkunft":{"description":"Die Anfrage kam nicht von StockPilot selbst (Schutz vor fremd ausgelösten Anfragen). Wird bei ändernden Aufrufen aus dem Browser mit fremdem Origin ausgelöst.","headers":{"Cache-Control":{"schema":{"type":"string","examples":["no-store"]}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Fehler"}}}},"Tageslimit":{"description":"Das Tageslimit für diese Art von Inhalt ist erreicht. Morgen geht es weiter.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Fehler"}}}},"ZuViele":{"description":"Zu viele Anfragen in kurzer Zeit.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ZuvieleAnfragen"}}}},"Ungueltig":{"description":"Ungültige Eingabe.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Fehler"}}}},"Verboten":{"description":"Kein Zugriff (keine Teilnahme, Blockade oder Rolle).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Fehler"}}}},"NichtGefunden":{"description":"Nicht gefunden — oder für die angemeldete Person nicht sichtbar.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Fehler"}}}},"ZuGross":{"description":"Datei oder Anfrage zu groß.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Fehler"}}}}}}}