Einkaufsapp – Phase 0 & 1
Backend-Grundgerüst mit Registrierung, Mailverifikation, Login und Administrationsschalter, dazu nginx als einziger Eintrittspunkt.
Ports
Nach außen ist genau ein Port offen: HTTP_PORT aus der .env,
voreingestellt 46600. Dahinter liegt nginx und verteilt:
| Pfad | Ziel |
|---|---|
/ |
statische Dateien (ab Phase 3 die gebaute PWA) |
/api/… |
FastAPI |
/docs, /openapi.json |
FastAPI-Dokumentation |
/healthz, /readyz |
Statusprüfungen |
Der api-Container hat keinen veröffentlichten Port. Wenn du ihn zum
Debuggen doch direkt erreichen willst, leg eine
docker-compose.override.yml an:
services:
api:
ports:
- "127.0.0.1:8000:8000"
Die Bindung an 127.0.0.1 ist wichtig – sonst hängt eine ungeschützte API
am öffentlichen Interface.
Start
cp .env.example .env
# DB_PASSWORD, DB_ROOT_PASSWORD und ADMIN_EMAIL setzen
docker compose --profile dev up -d --build
docker compose ps
docker compose logs -f api web
Erwartete Log-Ausgabe:
[entrypoint] Warte auf Datenbank db:3306 ...
[wait_for_db] Datenbank erreichbar.
[entrypoint] Migrationen einspielen ...
INFO [alembic.runtime.migration] Running upgrade -> 0001
[entrypoint] Starte API.
INFO: Application startup complete.
Ersteinrichtung im Browser
ADMIN_INITIAL_PASSWORDin der.envsetzendocker compose up -d– nichtrestart: ein Neustart übernimmt keine geänderten Werte aus der.env, der Container muss neu angelegt werdenhttp://<host>:46600/aufrufen, mitADMIN_EMAILund dem Startpasswort anmelden- Die Oberfläche verlangt sofort ein neues Passwort – erst danach ist alles andere erreichbar
ADMIN_INITIAL_PASSWORDwieder aus der.enventfernen
Ohne Startpasswort führt derselbe Weg über „Konto anlegen"; dann brauchst du für die Bestätigungsmail allerdings einen funktionierenden SMTP-Relay oder Mailpit.
Oberfläche: http://<host>:46600/
Interaktive API-Dokumentation: http://<host>:46600/docs
Abgefangene Mails (nur mit --profile dev): http://localhost:8025
Durchklicken
curl -s localhost:46600/readyz
# 1. Registrieren
curl -s -X POST localhost:46600/api/auth/register \
-H 'Content-Type: application/json' \
-d '{"email":"admin@example.com","password":"ein-langes-testpasswort"}'
# 2. Link aus Mailpit (http://localhost:8025) im Browser öffnen
# oder: curl -s "localhost:46600/api/auth/verify?token=<TOKEN>"
# 3. Anmelden – Cookies in eine Datei schreiben
curl -s -c cookies.txt -X POST localhost:46600/api/auth/login \
-H 'Content-Type: application/json' \
-d '{"email":"admin@example.com","password":"ein-langes-testpasswort"}'
# 4. Eigenes Profil lesen (nur Session-Cookie nötig)
curl -s -b cookies.txt localhost:46600/api/auth/me
# 5. Schreibender Request braucht zusätzlich den CSRF-Header
CSRF=$(grep ea_csrf cookies.txt | awk '{print $7}')
curl -s -b cookies.txt -H "X-CSRF-Token: $CSRF" \
-X PUT localhost:46600/api/admin/settings \
-H 'Content-Type: application/json' \
-d '{"allow_self_registration": false}'
Schritt 5 ohne den Header muss mit 403 CSRF-Token fehlt oder ungültig
abgewiesen werden – das ist der Test, ob der Schutz greift.
Mailversand prüfen
# Verbindung zum Relay, ohne etwas zu senden
curl -s -b cookies.txt localhost:46600/api/admin/mail/check
# Testnachricht
curl -s -b cookies.txt -H "X-CSRF-Token: $CSRF" \
-X POST localhost:46600/api/admin/mail/test \
-H 'Content-Type: application/json' \
-d '{"to":"dein-postfach@example.de"}'
docker compose logs --tail=20 api
Was hier absichtlich so ist
Erstes Administratorkonto – zwei Wege.
Ohne Startpasswort (ADMIN_INITIAL_PASSWORD leer): Du registrierst dich
regulär über POST /api/auth/register. Stimmt die Adresse mit ADMIN_EMAIL
überein, wird das Konto zum Administrator. Kein Passwort liegt jemals in der
Umgebung – dafür brauchst du für die Bestätigung entweder Mailpit oder ein
UPDATE in der Datenbank.
Mit Startpasswort: Setz ADMIN_INITIAL_PASSWORD in der .env. Beim ersten
Start legt die Anwendung das Konto an, bestätigt es sofort und markiert es mit
must_change_password. Bis das Passwort geändert ist, antworten alle Routen
außer GET /api/auth/me und POST /api/auth/password/change mit 403.
Die Absicherungen sind bewusst eng: Das Konto entsteht nur, wenn die Datenbank
noch gar keinen Nutzer enthält. Ein vergessener Eintrag in der .env kann
damit weder ein bestehendes Konto überschreiben noch ein zweites Admin-Konto
nachschieben – die Anwendung protokolliert stattdessen eine Warnung.
Trotzdem: Nach der ersten Anmeldung gehört der Wert aus der .env entfernt.
Er steht dort im Klartext, liegt in jedem Backup und ist über
docker inspect einkaufsapp_api für jeden lesbar, der Zugriff auf den Host
hat. Für Docker Secrets gibt es ADMIN_INITIAL_PASSWORD_FILE.
Registrierung antwortet immer gleich. Ob eine Adresse schon existiert, verrät die API nicht – weder über den Statuscode noch über die Laufzeit. Beim Login läuft die Passwortprüfung auch bei unbekanntem Konto gegen einen Dummy-Hash, damit die Antwortzeit konstant bleibt.
Rate Limiting sowohl pro IP als auch pro Konto. Nur pro IP zu begrenzen hilft nicht gegen verteilte Angriffe auf ein einzelnes Konto; nur pro Konto zu begrenzen macht das Aussperren fremder Nutzer trivial.
Produktivbetrieb ist die Voreinstellung. Der api-Container läuft mit
restart: unless-stopped, ohne Bind-Mount und ohne --reload – es läuft der
Code aus dem gebauten Abbild.
Zum Entwickeln:
cp docker-compose.override.yml.example docker-compose.override.yml
docker compose up -d
Compose zieht eine vorhandene docker-compose.override.yml automatisch mit
heran. Sie bindet ./backend ein, schaltet --reload an und setzt
restart: "no", damit ein Startfehler zu einem stehenden Container führt statt
zu einer Endlosschleife im Log. Die Datei steht in .gitignore und gehört
nicht auf den Produktivserver.
Ohne Override wirken Änderungen am Quelltext erst nach einem Neubau:
docker compose up -d --build
Selbstregistrierung: drei Zustände statt zwei. ALLOW_SELF_REGISTRATION
kennt true, false und admin. Bei true/false ist der Wert fest
verdrahtet, die Admin-API antwortet auf Änderungsversuche mit 409 und nennt
den Grund. Bei admin entscheidet die Datenbankeinstellung und der
Administrator darf zur Laufzeit umschalten. SELF_REGISTRATION_DEFAULT ist
dann der Startwert beim allerersten Start.
Die Alternative wäre gewesen, die Umgebungsvariable bei jedem Neustart über die
Admin-Einstellung schreiben zu lassen – dann wäre eine Abschaltung nach dem
nächsten docker compose up still wieder verschwunden.
SMTP-Relay. SMTP_SECURITY ersetzt die vorherigen Flags SMTP_STARTTLS
und SMTP_SSL; erlaubt sind none, starttls und ssl. Beim Start prüft die
Anwendung, dass keine Zugangsdaten über eine unverschlüsselte Verbindung gehen,
und bricht sonst mit einer klaren Meldung ab. Details zu SPF, DKIM, DMARC und
PTR stehen in docs/mail-zustellbarkeit.md.
Ein Port statt zwei. Oberfläche und API teilen sich dieselbe Origin. Damit entfällt CORS vollständig, Cookies verhalten sich vorhersagbar, und der Service Worker in Phase 4 kann API-Antworten ohne Sonderregeln cachen. Zwei Ports hätten drei zusätzliche Fehlerquellen eingeführt, ohne etwas zu gewinnen.
COOKIE_SECURE muss zum Zugriffsweg passen. Bei direktem HTTP-Zugriff auf
Port 46600 gehört false in die .env. Steht ein Reverse Proxy mit TLS davor
– bei dir vermutlich OPNsense –, dann true und PUBLIC_BASE_URL auf
https://…. Ein Secure-Cookie über reines HTTP wird vom Browser verworfen,
und die Anmeldung scheitert dann ohne verständliche Fehlermeldung.
CSP ohne unsafe-inline. Die Platzhalterseite lagert deshalb CSS und
JavaScript in eigene Dateien aus. Wenn du dort etwas ergänzt: Inline-<script>
und <style> werden vom Browser blockiert.
Migrationen statt create_all(). Ab jetzt ist jede Schemaänderung eine
Alembic-Revision. Neue Revision erzeugen:
docker compose exec api alembic revision --autogenerate -m "beschreibung"
docker compose exec api alembic upgrade head
Phase 2 – Listen und Katalog
Neue Endpunkte, alle unterhalb von /api/lists:
| Methode | Pfad | Rolle |
|---|---|---|
| GET / POST | /api/lists |
– |
| GET / PATCH / DELETE | /api/lists/{id} |
viewer / owner / owner |
| GET | /api/lists/{id}/members |
viewer |
| PUT / DELETE | /api/lists/{id}/members/{user} |
owner |
| GET / POST | /api/lists/{id}/markets |
viewer / editor |
| PUT / DELETE | /api/lists/{id}/markets/{mid} |
editor |
| GET / POST | /api/lists/{id}/categories |
viewer / editor |
| PUT / DELETE | /api/lists/{id}/categories/{cid} |
editor |
| GET / POST | /api/lists/{id}/articles |
viewer / editor |
| GET | /api/lists/{id}/articles/by-barcode/{code} |
viewer |
| PATCH / DELETE | /api/lists/{id}/articles/{aid} |
editor |
| GET / POST | /api/lists/{id}/items |
viewer / editor |
| PATCH / DELETE | /api/lists/{id}/items/{iid} |
editor |
| POST | /api/lists/{id}/items/clear-bought |
editor |
| GET | /api/lists/{id}/view |
viewer |
Migration einspielen und durchtesten:
docker compose restart api # entrypoint.sh führt alembic upgrade head aus
docker compose logs --tail=20 api
./smoke-phase2.sh http://localhost:46600 admin@example.de deinpasswort
Rollen
viewer liest, editor ändert Einträge und Katalog, owner verwaltet
zusätzlich Mitgliedschaften und kann die Liste umbenennen oder löschen. Wer
kein Mitglied ist, bekommt 404 statt 403 – sonst ließe sich über die
Statuscodes herausfinden, welche Listen-IDs existieren.
Revisionszähler
Jede Liste hat einen Zähler rev. Jede Änderung erhöht ihn und schreibt den
neuen Wert nach row_rev der geänderten Zeile. Ein Client kann damit später
fragen: „gib mir alles mit row_rev > N". Der Zähler wird unter
SELECT … FOR UPDATE fortgeschrieben, damit zwei gleichzeitige Änderungen
nicht dieselbe Nummer bekommen – sonst verpasst ein synchronisierender Client
eine davon.
clear-bought verbraucht bewusst nur eine Revision für alle entfernten
Einträge. So sieht ein Client eine geschlossene Änderung statt dutzender
einzelner.
Löschen
Überall Soft Delete über deleted_at. Ein Gerät, das eine Woche offline war,
muss noch erfahren, dass etwas verschwunden ist – bei einem harten DELETE
gäbe es dafür keine Spur. Aufräumen nach 30 Tagen kommt in Phase 9.
Ein gelöschter Markt reißt keine Einträge mit: sie verlieren nur den Marktbezug und landen in der Gruppe „Ohne Markt". Ein gelöschter Artikel nimmt seine Listeneinträge dagegen mit, sonst zeigten sie ins Leere.
Phase 3 – PWA ohne Build-Schritt
Die Oberfläche besteht aus nativen ES-Modulen unter web/html/js/. Kein npm,
kein Bundler, kein Node im Container – das web-Image bleibt das schlanke
nginx:alpine.
web/html/
├── index.html Hülle, lädt js/app.js als Modul
├── app.css inklusive @media print für A4
├── manifest.webmanifest
├── sw.js Service Worker (nur die Programmdateien)
├── icons/
└── js/
├── app.js Routing über location.hash
├── api.js einziger Ort mit fetch(), CSRF, Fehlerübersetzung
├── dom.js el(), Formatierung für Preise und Mengen
├── store.js ein Zustandsspeicher, Ansichten zeichnen daraus
└── views/
├── auth.js Anmelden, Registrieren, Passwort
├── lists.js Übersicht
├── list-detail.js gruppierte Liste, Abhaken, Preise
└── manage.js Märkte und Warengruppen
Übernehmen und neu bauen:
docker compose up -d --build web
Warum kein Framework
Statt React ein einzelner Zustandsspeicher (store.js), aus dem heraus die
betroffene Ansicht neu gezeichnet wird. Niemand fasst fremde DOM-Knoten an.
Das ist im Kern dasselbe Prinzip, nur ohne die rund 300 Pakete, die ein
Vite-React-Projekt mitbringt – und ohne postinstall-Skripte, über die die
Angriffe der letzten Zeit gelaufen sind.
Was fehlt: TypeScript, Hot Reload, Typprüfung. Bei dieser Größenordnung verschmerzbar.
Optimistische Änderungen
Abhaken, Preis eintragen, Markt wechseln: Die Anzeige ändert sich sofort, der
Serveraufruf läuft danach. Schlägt er fehl, wird der Serverstand neu geholt und
ein Hinweis eingeblendet – die Anzeige darf nicht auf einer Änderung
stehenbleiben, die nie angekommen ist. Diese Stelle (mutate() in
list-detail.js) wird in Phase 4 zur Outbox-Warteschlange ausgebaut.
Service Worker
Speichert nur die Programmdateien, nicht die API-Antworten. Eine veraltete Liste aus dem Cache wäre schlimmer als gar keine. Das kontrollierte Vorhalten der Daten übernimmt in Phase 4 IndexedDB.
Bei Änderungen an den Dateien in SHELL muss VERSION in sw.js hochgezählt
werden, sonst behalten installierte Clients die alte Fassung.
Auf dem Telefon installieren
Android/Chrome bietet „Zur Startseite hinzufügen" von selbst an. Bei iOS geht es nur über Teilen → „Zum Home-Bildschirm". Das ist auch die Voraussetzung dafür, dass Web Push in Phase 8 überhaupt funktioniert – im normalen Safari-Tab gibt es keine Benachrichtigungen.
Beides braucht HTTPS. Über reines HTTP auf Port 46600 lässt sich die PWA nicht
installieren und der Service Worker nicht registrieren (Ausnahme:
localhost). Spätestens jetzt lohnt sich der Reverse Proxy mit TLS – dann
COOKIE_SECURE=true und PUBLIC_BASE_URL auf https://… setzen.
app.css enthält bereits die @media print-Regeln: A4, Bedienelemente
ausgeblendet, leere Kästchen zum Abhaken, gepunktete Linie für den Preis. Im
Browser über Strg+P. Der serverseitig gerenderte Ausdruck aus Phase 9 kommt
zusätzlich – für den Fall, dass jemand ohne geöffnete App drucken will.
Phase 5 – Teilen
Jede Liste wird einzeln geteilt. Wer zehn Listen hat, kann jede davon mit einem anderen Personenkreis teilen; es gibt keine übergreifenden Gruppen.
| Methode | Pfad | Wer |
|---|---|---|
| GET / POST | /api/lists/{id}/invites |
Eigentümer |
| POST | /api/lists/{id}/invites/{iid}/resend |
Eigentümer |
| DELETE | /api/lists/{id}/invites/{iid} |
Eigentümer |
| POST | /api/lists/{id}/unshare |
Eigentümer |
| POST | /api/lists/{id}/transfer |
Eigentümer |
| PUT / DELETE | /api/lists/{id}/members/{uid} |
Eigentümer |
| POST | /api/lists/{id}/leave |
jedes Mitglied |
| GET | /api/invites/{token} |
Eingeladener |
| POST | /api/invites/{token}/accept |
Eingeladener |
In der Oberfläche: Liste öffnen → „Teilen“ (nur beim Eigentümer sichtbar).
Ablauf
- Eigentümer gibt eine E-Mail-Adresse und die Berechtigung an
- Es entsteht ein Datensatz mit Zufallstoken, versendet wird ein Link auf
/invite?token=… - Der Empfänger meldet sich an oder registriert sich; der Pfad bleibt dabei erhalten, danach geht es direkt weiter
- Beim Annehmen entsteht die Mitgliedschaft
Das läuft bewusst unabhängig davon, ob unter der Adresse schon ein Konto besteht. Damit verrät das Einladen nicht, wer registriert ist, und neue wie bestehende Nutzer gehen denselben Weg.
Entscheidungen, die Begründung verdienen
Die Adresse muss zum annehmenden Konto passen. Ein weitergeleiteter Link öffnet niemandem sonst den Zugang. Der Preis: Wer die Einladung an seine Arbeitsadresse bekommt, aber ein Konto mit privater Adresse hat, braucht eine neue Einladung.
Erneutes Senden erzeugt ein neues Token. In der Datenbank steht nur der Hash, der Klartext des alten Links ist nicht rekonstruierbar. Nebeneffekt: Ein versehentlich weitergeleiteter Link verliert dabei seine Gültigkeit – was praktisch ist, wenn genau das der Grund fürs erneute Senden war.
Rate Limiting pro Liste und pro IP. Jede Einladung erzeugt eine Mail an eine frei wählbare Adresse. Ohne Bremse wäre die Anwendung eine Versandhilfe für unerwünschte Nachrichten – und deine Relay-Reputation wäre das Erste, was darunter leidet.
Eigentumsübertragung macht den bisherigen Eigentümer zum Bearbeiter. Er verliert die Verwaltung, aber nicht den Zugang zu einer Liste, an der er mitarbeitet.
Wer einen Eintrag angelegt hat, steht im Untertitel – aber nur bei geteilten Listen. Bei einer Liste für sich allein wäre „von mir“ nur Rauschen. Ausgeliefert wird der Anzeigename beziehungsweise der lokale Teil der Adresse, nicht die vollständige E-Mail-Adresse.
Öffentliche Ansichtslinks
Zusätzlich zum Einladen einzelner Personen lässt sich ein Link erzeugen, über den auch Empfänger ohne Konto die Liste ansehen können.
| Methode | Pfad | Wer |
|---|---|---|
| GET / POST | /api/lists/{id}/public-links |
Eigentümer oder Berechtigte |
| DELETE | /api/lists/{id}/public-links/{sid} |
Eigentümer oder Berechtigte |
| POST | /api/lists/{id}/public-links/revoke-all |
Eigentümer oder Berechtigte |
| GET | /api/public/{token} |
jeder mit Link |
| POST | /api/public/{token}/items/{iid} |
jeder mit Link, wenn erlaubt |
Der Link hat die Form https://…/s/<token> und öffnet eine eigenständige
Ansicht ohne Anmeldung – gegliedert nach Markt und Warengruppe, auf A4
druckbar.
Was über den Link geht: ansehen, drucken, und – sofern beim Erzeugen erlaubt – Artikel abhaken. Sonst nichts. Kein Anlegen, kein Löschen, keine Preisänderung, keine Märkte, keine Mitgliederliste.
Was bewusst nicht sichtbar ist: wer welchen Artikel eingetragen hat. Bei einem Link ist der Empfängerkreis unbestimmt; Namen sind personenbezogene Daten und haben dort nichts verloren. Die Ansicht wird serverseitig anonymisiert, nicht erst im Browser ausgeblendet.
Das Zusatzrecht. Öffentliche Links darf zunächst nur der Eigentümer erzeugen. Er kann das Recht einzelnen Mitgliedern erteilen (Häkchen in der Teilen-Ansicht) und jederzeit wieder entziehen. Es hängt nicht an der Rolle – auch ein Mitglied mit „Nur lesen“ kann es bekommen, muss es aber nicht.
Ablaufdatum ist Pflicht, höchstens 365 Tage. Ein unbefristeter Link wäre ein dauerhaft offenes Fenster in fremde Daten. Höchstens 10 Links gleichzeitig aktiv je Liste.
Der Link wird nur einmal angezeigt. In der Datenbank steht nur ein Prüfwert, nicht die Adresse selbst – ein Datenbankleck gibt also keinen Zugriff auf die Listen. Der Preis: Wer den Link später noch einmal braucht, muss einen neuen erzeugen. Das ist eine bewusste Abwägung zugunsten der Sicherheit; sag Bescheid, wenn du es umgekehrt haben willst.
Nutzungsanzeige ohne Personenbezug. Der Eigentümer sieht, wie oft und wann zuletzt ein Link geöffnet wurde – keine IP-Adressen, keine Zeitreihe. Erkennbar soll sein, dass ein Link benutzt wird, nicht von wem.
robots.txt und X-Robots-Tag schließen /s/, /invite und /reset von der
Indexierung aus.
Phase 4 – Offline und Synchronisation
Die Listenansicht arbeitet ab jetzt gegen einen lokalen Speicher, nicht mehr direkt gegen den Server.
js/db.js IndexedDB: Zwischenspeicher und Outbox
js/sync.js Senden, Holen, Ereigniskanal, lokales Anwenden offener Operationen
Ablauf einer Änderung
- Die Operation wandert in die Outbox (IndexedDB, überlebt einen Absturz)
- Sie wird sofort auf den zwischengespeicherten Stand angewandt und angezeigt
- Der Versand läuft danach; scheitert er, bleibt sie liegen
- Erneut versucht wird bei Verbindungsrückkehr, beim Zurückholen der App in den Vordergrund und ersatzweise alle 30 Sekunden
Jede Operation trägt eine vom Client vergebene op_id. Kommt sie zweimal an –
weil die Antwort unterwegs verlorenging oder das Telefon neu gestartet wurde –,
erkennt der Server das über die Tabelle applied_op und führt sie nicht noch
einmal aus. Ohne diesen Schritt würde aus einem verlorenen „Butter hinzufügen"
beim nächsten Versuch zweimal Butter.
Was ich bewusst nicht gebaut habe
Im Architekturpapier stand ein Delta-Endpunkt changes?since=N. Der ist hier
nicht entstanden. Eine Einkaufsliste hat Dutzende Einträge, keine
Zehntausende – die vollständige Ansicht neu zu holen kostet ein paar Kilobyte
und spart eine ganze Fehlerklasse, die beim Zusammensetzen von Teilständen
entsteht. Der Revisionszähler bleibt und sorgt dafür, dass das nur passiert,
wenn sich wirklich etwas geändert hat.
Sollte sich das als zu grob erweisen, ist der Umbau später klein: row_rev
liegt auf jeder Zeile bereit.
Ereigniskanal
GET /api/lists/{id}/events liefert Server-Sent Events. Übertragen wird nur
der neue Revisionsstand, keine Nutzdaten – der Client entscheidet dann selbst,
ob er nachladen muss. Das hält den Kanal schlank und vermeidet
Reihenfolgeprobleme.
Ohne Redis wird der Stand alle zwei Sekunden abgefragt statt verteilt. Bei dieser Größenordnung völlig ausreichend; die Abfrage läuft im Threadpool, damit sie die Ereignisschleife nicht blockiert.
Wichtig für den Reverse Proxy: proxy_buffering off ist Pflicht. Ohne das
sammelt nginx die Ereignisse und liefert sie gebündelt aus – die Liste
aktualisiert sich dann verzögert oder gar nicht. Der web-Container hat dafür
einen eigenen location-Block; auf der OPNsense muss dasselbe eingestellt
werden (siehe docs/reverse-proxy.md).
Konfliktauflösung
Letzter Schreibvorgang gewinnt, mit dem Zeitstempel des Servers. Für eine Einkaufsliste reicht das: Der kritische Fall ist der Statuswechsel („hab ich schon eingepackt"), und der ist idempotent. Der Aufwand für CRDTs stünde in keinem Verhältnis.
Ein Sonderfall ist abgefangen: Fachlich fehlgeschlagene Operationen – etwa eine Änderung an einem Eintrag, den jemand anders inzwischen gelöscht hat – werden einzeln abgelehnt und gemeldet, statt den ganzen Stapel scheitern zu lassen. Sonst würde ein einziger toter Eintrag alle übrigen Änderungen eines Geräts dauerhaft blockieren.
Was der Nutzer sieht
- Offline: Hinweisbalken, Änderungen werden gesammelt
- Ausstehende Übertragung: Zähler im Hinweisbalken, betroffene Einträge grün hinterlegt mit einem Punkt hinter dem Namen
- Neu angelegte, noch nicht übertragene Einträge lassen sich nicht weiter ändern – sie haben serverseitig noch keine ID. Sobald die Übertragung durch ist, sind sie normal bedienbar.
Prüfskripte
Vor jedem Neubau sinnvoll – alle geben bei Problemen Rückgabewert 1 zurück:
bash tools/check-all.sh
Führt alle Einzelprüfungen aus und gibt bei Problemen Rückgabewert 1 zurück – damit lässt es sich als Git-Hook oder in einer CI verwenden.
Einzeln:
bash tools/check-env.sh .env # doppelte oder fehlerhafte .env-Einträge
python3 tools/check-routes.py # Endpunkte ohne Berechtigungsprüfung
python3 tools/check-schema.py # db/schema.sql gegen die Modelle
python3 tools/check-editable.py # Felder eines Eintrags überall verdrahtet?
python3 tools/check-nginx.py web # Kopfzeilen, doppelte Direktiven
node tools/check-js.mjs # undefinierte Bezeichner in den JS-Modulen
node tools/test-barcode.mjs # Strichcode-Decoder
cd backend && python3 -m compileall -q app alembic
Nach dem Entpacken eines Archivs fehlt den Skripten das Ausführungsbit (ZIP speichert es nicht zuverlässig). Einmalig:
chmod +x tools/*.sh smoke-phase2.sh
git update-index --chmod=+x tools/check-all.sh tools/check-env.sh smoke-phase2.sh
Der zweite Befehl sorgt dafür, dass das Recht im Repository erhalten bleibt – Git speichert es, ZIP nicht.
check-routes.py
Listet alle Endpunkte mit ihren Berechtigungsabhängigkeiten und meldet jeden, der weder eine Prüfung noch einen Eintrag in der Ausnahmeliste hat. FastAPI mahnt fehlende Absicherung nicht an – beim Hinzufügen einer Route ist sie schnell vergessen.
check-editable.py
Prüft, dass jedes änderbare Feld eines Listeneintrags auch tatsächlich überall
eingetragen ist: Modell, ItemUpdateIn, ItemOut, items.py, sync.py und
die Oberfläche.
Ein Feld hinzuzufügen bedeutet sechs Stellen. Fehlt eine, merkt es niemand: Die Anwendung startet, die Oberfläche zeigt ein Eingabefeld, der Server verwirft den Wert stillschweigend. Genau so ist der Artikelname zunächst untergegangen.
check-schema.py
Vergleicht db/schema.sql mit den SQLAlchemy-Modellen: fehlende oder
überzählige Tabellen und Spalten, und ob der eingetragene Migrationsstand zur
höchsten Revision passt. Die SQL-Datei ist von Hand geschrieben und wird beim
nächsten Modellumbau sonst leicht vergessen.
check-env.sh
./tools/check-env.sh .env
Findet mehrfach definierte Variablen, Leerzeichen vor dem = und
Anführungszeichen im Wert.
Der erste Fall ist der tückische: Docker Compose nimmt bei doppelten Schlüsseln die letzte Zeile. Wer einen Wert einfügt und die leere Vorlagenzeile darunter stehen lässt, schaltet die Funktion still wieder ab – ohne Fehlermeldung, nur mit einem Verhalten, das niemand erklären kann.
Beim Start meldet die Anwendung inzwischen auch selbst, wenn nur einer der beiden VAPID-Schlüssel gesetzt ist.
check-js.mjs
Sucht Bezeichner, die verwendet, aber nirgends deklariert oder importiert werden.
Ohne Build-Schritt gibt es keinen Linter, und der Browser meldet
x is not defined erst, wenn die betroffene Zeile tatsächlich ausgeführt
wird. Eine Funktion, die nur im Menü eines Eintrags gebraucht wird, kann so
lange fehlen, ohne dass es auffällt – genau das ist zwischen Phase 7 und dem
ersten Öffnen einer Liste passiert.
Bewusst eine grobe Prüfung ohne echten Parser: Sie kennt keine Blockgeltungsbereiche und meldet nichts, was irgendwo in der Datei deklariert ist. Für den Zweck – „ganz vergessen“ statt „am falschen Ort“ – reicht das.
Prüfskript für die nginx-Konfiguration
python3 tools/check-nginx.py web
Findet drei Fehlerklassen, die uns bereits begegnet sind:
Doppelte Direktiven. nginx bricht mit directive is duplicate ab. Passiert
leicht, wenn eine location proxy_common.conf einbindet und etwas
wiederholt, das dort schon steht.
Fehlende Sicherheitskopfzeilen. nginx vererbt add_header nur, wenn die
untergeordnete Ebene gar kein add_header setzt. Ein einziges Cache-Control
in einer location lässt alle Kopfzeilen des server-Blocks verschwinden –
und zwar ohne Fehlermeldung. Genau das war zwischen Phase 3 und Phase 4
unbemerkt der Fall: Die CSP, auf die wir uns bei der Entscheidung gegen npm
gestützt hatten, war auf der gesamten Anwendung nicht aktiv.
Ein types-Block im server-Kontext. Der ersetzt die geerbte MIME-Tabelle
vollständig, statt sie zu ergänzen – CSS und JavaScript kämen als
application/octet-stream an, und der Browser lehnt ES-Module ab.
Sinnvoll als Schritt vor jedem docker compose build web. Rückgabewert 1 bei
Problemen, damit es sich in ein Makefile oder einen Git-Hook einbauen lässt.
Reihenfolge per Anfasser
Märkte und Warengruppen werden gezogen, nicht durchnummeriert. sort_order
verwaltet die App selbst und schreibt nach jedem Verschieben 10, 20, 30 …
zurück – die Abstände lassen Platz, falls später einzeln eingefügt werden soll.
Gesendet werden nur die Einträge, deren Rang sich tatsächlich geändert hat.
js/sortable.js nutzt Pointer Events statt der HTML5-Drag-and-Drop-API:
Letztere funktioniert auf Touchgeräten praktisch nicht, und die App wird
überwiegend am Telefon bedient. Der Anfasser hat touch-action: none, sonst
scrollt die Seite, statt die Zeile zu ziehen.
Bedienbar auch ohne Zeigegerät: Anfasser mit Tab fokussieren, dann Pfeiltasten.
Fallstrick: null im DOM
Node.append(null) macht daraus die Zeichenkette "null" und zeigt sie an.
Die Funktion el() filtert solche Werte heraus, aber ein direktes
clear(root).append(…) umging diesen Schutz – sichtbar als ein einsames
„null" über der Verwaltungsansicht.
Dafür gibt es jetzt mount(node, …children) in dom.js. Alle Ansichten sind
umgestellt; clear(root).append( kommt im Quelltext nicht mehr vor.
Sammelendpunkt für den Gesamtstand
GET /api/lists/{id}/snapshot liefert Liste, gruppierte Ansicht, Märkte und
Warengruppen in einer Antwort. Vorher waren das vier Aufrufe – bei einer
Synchronisation nach jedem Abhaken summieren sich vier Rundreisen zu einer
spürbaren Verzögerung, besonders im Mobilfunknetz.
Dazu behoben: Nach erfolgreichem Versand lief die Synchronisation doppelt.
installTriggers rief selbst pull() auf und löste über onChange die
Ansicht aus, die ihrerseits syncNow() startete. Jetzt sendet der Auslöser
nur noch; das Holen übernimmt die Ansicht.
Aus acht Anfragen je Änderung sind damit zwei geworden: ein POST /ops und
ein GET /snapshot.
Anwendungsname
APP_NAME in der .env – erscheint in der Oberfläche, im Browsertitel, auf
dem Startbildschirm und in allen Mails. APP_SHORT_NAME ist die kürzere
Fassung fürs Symbol auf dem Startbildschirm.
$EDITOR .env # APP_NAME=Einkaufsliste
docker compose up -d api
Der web-Container muss nicht neu gebaut werden. Der Name kommt über
GET /api/config zur Laufzeit, und das Manifest erzeugt der api-Container
(GET /manifest.webmanifest, von nginx durchgereicht). Beim Bauen einzusetzen
hätte bedeutet, dass jede Umbenennung einen Neubau erfordert.
Der zuletzt bekannte Name liegt lokal in IndexedDB, damit die App auch ohne Verbindung nicht namenlos startet.
SMTP_FROM_NAME leer lassen, um APP_NAME als Absendernamen zu verwenden.
Listen umbenennen, löschen, verlassen
In der Übersicht bei jeder Zeile über „⋯“:
- Eigentümer: Umbenennen, Löschen
- Andere Mitglieder: Liste verlassen
Beim Löschen und Verlassen wird auch der lokale Zwischenspeicher der Liste aufgeräumt – sonst bliebe ein verwaister Stand samt eventuell wartender Operationen in IndexedDB liegen.
Behobener Fehler: Eine neu angelegte Liste erschien erst nach dem Neuladen der
Seite. create() schrieb den neuen Stand in den Zustandsspeicher, rief aber
render() nicht auf – und die Ansicht hatte sich beim Speicher nie angemeldet.
Phase 6 – Strichcode und Artikelattribute
Der Decoder ist selbst geschrieben
web/html/js/barcode.js liest EAN-13, EAN-8 und UPC-A. Rund 200 Zeilen, keine
Abhängigkeit.
Die übliche Lösung wäre ZXing – eine 250-KB-Datei aus dem npm-Ökosystem. Für drei Formate, die alle nach demselben Schema arbeiten, steht das in keinem Verhältnis, und es widerspräche der Entscheidung, die Lieferkette klein zu halten.
Wo die native BarcodeDetector-API vorhanden ist (Chrome, Android), wird sie
bevorzugt: schneller, und sie erkennt zusätzlich QR- und Datamatrix-Codes. Der
eigene Decoder greift überall sonst, insbesondere in Safari auf iPhone und
iPad.
node tools/test-barcode.mjs
Der Test erzeugt synthetische Signale, verzerrt sie (Unschärfe, Rauschen, verschiedene Maßstäbe, umgedreht gehalten) und prüft, ob der Decoder sie zurückliest – und ob er auf leeren Flächen und reinem Rauschen schweigt. 17 Fälle.
Wie er arbeitet: Aus dem Kamerabild werden 21 waagerechte Linien abgetastet, jede in eine Hell-Dunkel-Folge zerlegt und gegen die Zifferntabellen gehalten. Verglichen werden Verhältnisse, keine absoluten Breiten – der Abstand zur Kamera spielt dadurch keine Rolle. Ein Ergebnis gilt erst als sicher, wenn zwei Linien dasselbe ergeben und die Prüfziffer stimmt.
Dazu die Ruhezonenprüfung: Vor und hinter dem Code verlangt die Norm einen hellen Rand von mindestens sieben Modulen. Diese Prüfung ist der wirksamste Schutz gegen Fehltreffer – in zufälligem Rauschen gibt es keine so breiten hellen Flächen, in einem echten Kamerabild dagegen immer. Gemessen: 0 von 500 Fehltreffern auf reinem Rauschen, vorher gelegentlich einer.
Der Preis: Ein Code, der bis an den Bildrand angeschnitten ist (unter etwa 3 Module Rand), wird nicht gelesen. Der Sucherrahmen führt den Nutzer so, dass das selten vorkommt.
Scannen in der Liste
Beim Hinzufügen gibt es einen Knopf „Scannen“:
- Code bekannt → der Artikel landet direkt auf der Liste
- Code unbekannt → Name erfragen; der Code wird beim Artikel hinterlegt und beim nächsten Mal sofort gefunden
- Ohne Kamera oder über reines HTTP → Eingabe der Ziffern von Hand; der Knopf erscheint dann gar nicht erst
Scannen braucht eine Verbindung: Die Zuordnung Code → Artikel steht auf dem Server. Offline bliebe eine Ziffernfolge ohne Bedeutung – die App bricht dort ehrlich ab, statt etwas Unbrauchbares in die Warteschlange zu legen.
Kamerazugriff verlangt HTTPS. Über http://…:46600 verweigert der Browser
ihn; hinter dem Reverse Proxy funktioniert es.
Artikelstamm
Neuer Punkt „Artikel“ in der Liste. Je Artikel:
- Name, Strichcode (auch scanbar), Notiz
- Vorgabe für Markt und Warengruppe – gilt für neue Einträge
- „Erhältlich bei“: mehrere Märkte ankreuzbar
- Frei definierbare Eigenschaften: Verpackungseinheit, Farbe, Größe, was immer gebraucht wird
Artikel entstehen weiterhin automatisch beim Eintragen; hier werden sie nachträglich ausgestaltet.
Eigenschaft je Eintrag
Neben Menge und Einheit gibt es ein Feld „Eigenschaft“: die nähere Bestimmung des gewünschten Artikels – bei Pfeffer etwa „bunt, ganz“ oder „schwarz, gemahlen“, bei Milch „laktosefrei“.
Bewusst getrennt von der Notiz: Die Eigenschaft gehört zum Produkt und steht deshalb neben dem Namen, die Notiz ist eine Bemerkung für den Einkaufenden („beim Metzger fragen“). Beide lassen sich im „⋯“-Menü nachträglich ändern.
Das Feld schlägt vor, was auf dieser Liste schon vorkommt – „bunt, ganz“ muss also nur einmal getippt werden.
Das ist etwas anderes als die Eigenschaften im Artikelstamm: Dort steht, was den Artikel dauerhaft beschreibt (Verpackungseinheit, Farbe). Hier steht, was diesmal gebraucht wird.
Migration 0007 fügt die Spalte hinzu.
Layout der Eingabezeile
Name über die volle Breite, darunter Menge/Einheit/Eigenschaft, darunter die Schaltflächen. Vorher teilten sich Felder und Schaltflächen eine Zeile – auf schmalen Geräten lief „Hinzufügen“ aus der Box.
Produktdatenbank
Gescannte Codes werden bei Open Food Facts nachgeschlagen – rund drei Millionen überwiegend Lebensmittel, frei zugänglich.
PRODUCT_LOOKUP=openfoodfacts # oder "off"
Über den Server, nicht aus dem Browser. Bei einer Abfrage direkt aus der
App wüsste Open Food Facts, wer wann welches Produkt scannt – genau die
Datenspur, die vermieden werden soll. Außerdem bliebe die CSP nicht bei
connect-src 'self': Eine Ausnahme für eine fremde Domain wäre eine dauerhafte
Schwächung für eine gelegentliche Abfrage.
Du hattest ursprünglich „direkt vom Client“ gewählt. Ich habe es trotzdem als
Proxy gebaut, weil der Aufwand mit dem Zwischenspeicher ohnehin anfiel und die
Nachteile der direkten Variante bleiben. Umstellen wäre eine Änderung in
app/product_lookup.py und eine Zeile in der CSP – sag Bescheid, wenn du es so
willst.
Zwischenspeicher. Jeder Code wird höchstens einmal je Gültigkeitszeitraum abgefragt (Treffer 180 Tage, Fehlschläge 14). Das schont auch den fremden Dienst.
Reihenfolge beim Auflösen: erst der eigene Artikelstamm, dann Open Food Facts. Was hier gepflegt wurde, ist verlässlicher als eine Fremdquelle, und ein eigener Name soll nicht überschrieben werden.
Bestätigung. Ein Treffer in der Fremdquelle landet nicht ungeprüft im eigenen Bestand: Der Dialog zeigt Herkunft und Vorschlag, übernommen wird, was der Nutzer dort stehen lässt. Ein Artikel aus dem eigenen Stamm geht dagegen ohne Rückfrage auf die Liste – dort ist nichts zu prüfen.
Voraussetzung: Der api-Container muss world.openfoodfacts.org erreichen.
Die Daten stehen unter der Open Database License; für den Eigengebrauch
unproblematisch, bei Weiterverbreitung ist die Lizenz zu beachten.
Runde Abhak-Kästchen
Die Kästchen wirkten oval. Ursache: Die allgemeine button-Regel setzt
padding: .7rem 1.1rem; die 2,2 rem waagerechte Innenabstand waren mehr als
die vorgesehenen 2 rem Breite und haben den Knopf auseinandergezogen. Jetzt
padding: 0 plus aspect-ratio: 1.
Phase 7 – Preisdatenbank und Vergleich
| Methode | Pfad | Zweck |
|---|---|---|
| GET | /api/lists/{id}/prices |
Vergleich über alle Artikel |
| GET | /api/lists/{id}/price-hints |
kompakt für die Listenansicht |
| GET | /api/lists/{id}/articles/{aid}/prices |
Verlauf eines Artikels |
In der Oberfläche: Liste öffnen → „Preise“.
Erfassung läuft nebenbei
Sobald an einem Eintrag ein Preis steht und er einem Markt zugeordnet ist, entsteht ein Datenpunkt. Ohne Markt ergibt ein Preis keinen Vergleichswert und wird nicht aufgenommen.
Gegen Wildwuchs: Derselbe Preis am selben Markt für dieselbe Packungsgröße gilt innerhalb von zwölf Stunden als ein Wert. Ohne das entstünde bei jedem Tippen im Preisfeld ein neuer Eintrag.
Die Datenpunkte überleben das Aufräumen der Liste – gerade darum geht es: Was einmal beobachtet wurde, bleibt für den Vergleich erhalten.
Ohne Personenbezug
price_point führt Liste, Artikel, Markt, Betrag, Menge und Zeitpunkt – kein
user_id. Aus „wer hat wann wo was zu welchem Preis gekauft“ ließe sich ein
Bewegungs- und Konsumprofil bilden; für den Zweck ist das nicht nötig.
Das ist ein bewusster Verzicht: Man kann damit nicht nachvollziehen, wer einen falschen Preis eingetragen hat.
Was die Ansicht zeigt
Je Artikel eine Karte mit den Märkten nach Preis sortiert, der günstigste markiert, die anderen mit dem Aufschlag daneben. Aufgeklappt: Zahl der Beobachtungen, Spanne, Preis je Mengeneinheit und der Verlauf.
Keine Matrix Artikel × Markt – bei vier Märkten und dreißig Artikeln wird die auf einem Telefon unlesbar. Gesucht wird ohnehin „wo ist X am günstigsten“.
Preis je Einheit wird nur berechnet, wenn eine Menge erfasst ist. 1,29 € für einen Liter ist etwas anderes als 1,29 € für 200 Milliliter – ohne Bezugsgröße wäre der Vergleich irreführend, deshalb bleibt das Feld dann leer.
Hinweis beim Einkaufen
Im „⋯“-Menü eines Eintrags steht, wo der Artikel zuletzt am günstigsten war – und was er im gerade zugeordneten Markt gekostet hat. Nur bei Artikeln, deren Märkte sich tatsächlich unterscheiden.
Die Hinweise sind eine Nebeninformation: Kommen sie nicht, arbeitet die Liste ohne sie weiter.
Stückzahl und Gebinde getrennt (Migration 0010)
Bis hierher steckten beide in quantity/unit. Bei einer Flasche zu 500 ml
und 2,99 € rechnete die Summenbildung 500 × 2,99 = 1495 €.
Jetzt drei Felder:
| Feld | Bedeutung | Beispiel |
|---|---|---|
count |
Stückzahl – wie viele Packungen | 6 |
pack_size / pack_unit |
Gebinde – wie groß eine Packung ist | 500 ml |
price_cents |
Preis für ein Gebinde | 2,99 € |
Die Summe ist price_cents × count. In der Zeile steht „6 × 500 ml“, und bei
mehr als einem Stück erscheint die Zeilensumme unter dem Preisfeld – damit
sichtbar ist, worauf sich der eingetragene Betrag bezieht.
Was die Migration mit vorhandenen Daten macht: Die alten Werte werden als
Gebinde übernommen (aus „500 ml“ wird pack_size=500, pack_unit=ml), die
Stückzahl auf 1 gesetzt. Das ist die sichere Richtung: Wo bisher tatsächlich
eine Stückzahl gemeint war, stimmt die Summe danach – sie wird nur nicht mehr
vervielfacht. Wer sechs Flaschen will, trägt die 6 einmal nach.
Die Stückzahl steht als eigenes Feld links neben dem Namen, nicht im Untertitel. Beim Einkaufen ist „wie viele“ die Angabe, die man am leichtesten übersieht – und dann packt man zu wenig ein. Mehr als ein Stück wird zusätzlich fett hervorgehoben. Der Untertitel führt nur noch Gebinde, Eigenschaft und Notiz.
Preis je Einheit bezieht sich jetzt eindeutig auf das Gebinde: 2,99 € für 500 ml sind 5,98 € je Liter, unabhängig davon, wie viele Flaschen auf der Liste stehen.
Open Food Facts liefert die Trennung gleich mit: „6 x 33 cl“ wird zu Stückzahl 6 und Gebinde 33 cl, „500 g“ zu Gebinde 500 g ohne Stückzahl.
Outbox von vor der Umstellung: Der Server nimmt die alten Feldnamen weiterhin an und legt sie als Gebinde ab – das entspricht ihrer bisherigen Bedeutung. Wartende Änderungen gehen also nicht verloren.
Phase 8 – Push-Benachrichtigungen
python3 tools/vapid-keys.py # Ausgabe in die .env übernehmen
docker compose up -d --build
VAPID ist die Art, wie sich dieser Server gegenüber den Push-Diensten der Browserhersteller ausweist. Das Schlüsselpaar gehört dir – kein Konto bei Google oder Apple nötig, und außer der verschlüsselten Nachricht fließt nichts an Dritte. Auch der Push-Dienst kann den Inhalt nicht lesen.
Einschalten: Listenübersicht → Einstellungen → „Auf diesem Gerät einschalten“. Dort gibt es auch eine Testnachricht und die Liste der angemeldeten Geräte.
Die Zwei-Stunden-Regel
Wer eine Liste ändert, löst bei allen anderen Mitgliedern eine Meldung aus –
aber höchstens eine je Liste und Person alle zwei Stunden
(PUSH_THROTTLE_HOURS).
Ohne das wäre die Funktion unbrauchbar: Wer im Laden steht und abhakt, erzeugt in wenigen Minuten Dutzende Änderungen. Die anderen bekämen ebenso viele Meldungen und würden Benachrichtigungen nach dem ersten Einkauf abschalten.
Zwei Feinheiten:
- Konnte nichts zugestellt werden (kein Gerät angemeldet, Endpunkt tot), startet die Sperrfrist nicht. Sonst verpasst man die nächste Änderung, ohne die erste bekommen zu haben.
- Ein Stapel aus der Outbox, der nur aus Wiederholungen bestand, löst nichts aus – dort hat sich nichts geändert.
Was in der Meldung steht
Listenname und wer sie geändert hat. Keine Artikelnamen.
Eine Benachrichtigung erscheint auf dem gesperrten Bildschirm, und was dort steht, sieht jeder, der das Gerät gerade in der Hand hält. „Marco hat die Liste geändert“ ist harmlos, eine Aufzählung von Medikamenten wäre es nicht.
Gleiches tag je Liste bedeutet: Eine neue Meldung ersetzt die alte, statt
sich zu stapeln.
Aufräumen
Meldet ein Push-Dienst einen Endpunkt als ungültig (404 oder 410 – typisch nach Deinstallation oder Löschen der Websitedaten), wird die Anmeldung sofort entfernt. Nach zehn aufeinanderfolgenden Fehlschlägen ebenfalls.
iOS
Auf iPhone und iPad funktioniert Web Push nur, wenn die App über „Teilen → Zum Home-Bildschirm“ installiert wurde. Im normalen Safari-Tab gibt es keine Benachrichtigungen – eine Einschränkung von Apple, kein Fehler der Anwendung. Die Einstellungsseite erkennt das und erklärt es an Ort und Stelle, statt einen Knopf anzubieten, der nichts tut.
Phase 9 – Ausdruck, Aufräumen, Betrieb
Druckansicht vom Server
| Pfad | Wer |
|---|---|
/api/lists/{id}/print |
Mitglieder |
/api/public/{token}/print |
jeder mit öffentlichem Link |
Erreichbar über „Drucken“ in der Listenansicht und in der öffentlichen Ansicht. Öffnet eine eigenständige A4-Seite: Markt → Warengruppe → Artikel alphabetisch, Kästchen zum Abhaken, Stückzahl, Gebinde, gepunktete Linie für den Preis, Summe je Markt und Gesamtsumme.
Die App bringt zwar schon @media print mit. Die Serverseite kommt hinzu, weil
der Ausdruck auch ohne geöffnete App möglich sein soll – aus einem Lesezeichen,
über einen geteilten Link, oder von einem Rechner, auf dem niemand die PWA
installiert hat.
Die Gliederung stammt aus app/list_view.py, derselben Quelle wie die
Bildschirmansicht. Ein zweiter Sortieralgorithmus würde mit der Zeit abweichen,
und dann zeigt der Ausdruck eine andere Reihenfolge als das Telefon in der Hand.
Zurückgestellte Artikel sind standardmäßig nicht dabei: Wer ausdruckt, will
die Einkaufsliste, nicht die Merkliste. Über ?include_deferred=true
umschaltbar.
Jinja2 mit autoescape – Artikelnamen und Notizen sind freie Nutzereingaben
und landen direkt im HTML.
Aufräumen
Läuft von selbst im api-Container: 30 Sekunden nach dem Start, danach alle
24 Stunden. Kein Cron-Container, kein zusätzlicher Dienst – das wäre eine
weitere Stelle, an der etwas kaputtgehen kann, für eine Aufgabe, die einmal am
Tag ein paar Zeilen löscht.
Weich gelöschte Daten verschwinden nach 30 Tagen endgültig
(CLEANUP_DELETED_DAYS). Kürzer sollte die Frist nicht sein: So lange kann ein
Gerät offline bleiben und beim nächsten Abgleich noch erfahren, dass eine Liste
gelöscht wurde. Danach bliebe sie dort stehen, bis jemand sie von Hand entfernt.
Sofort auslösen: POST /api/admin/cleanup (Administrator).
Benutzerverwaltung
Einstellungen → Benutzerverwaltung (nur für Administratoren sichtbar; der Endpunkt prüft zusätzlich – eine ausgeblendete Schaltfläche ist kein Schutz).
| Methode | Pfad |
|---|---|
| GET | /api/admin/users, /api/admin/stats |
| POST | /api/admin/users – anlegen und einladen |
| POST | /api/admin/users/{id}/welcome – Willkommensnachricht erneut |
| POST / DELETE | /api/admin/users/{id}/email – Adresswechsel starten / zurückziehen |
| POST | /api/admin/users/{id}/deactivate, /activate |
| POST | /api/admin/users/{id}/delete |
| GET / PUT | /api/admin/settings |
Konto anlegen
Die Person bekommt eine Willkommensnachricht mit einem Link, über den sie ihr Passwort selbst festlegt. Ein vom Administrator vergebenes Passwort wäre ihm bekannt und ginge zudem im Klartext per Mail.
Der Klick auf den Link bestätigt zugleich die Adresse – eine zusätzliche Verifikationsmail wäre ein weiterer Schritt ohne Erkenntnisgewinn.
Erneut senden geht nur, solange das Konto nicht eingerichtet ist. Danach wäre es kein Willkommensgruß mehr, sondern ein vom Administrator ausgelöstes Zurücksetzen des Passworts – das soll vom Kontoinhaber ausgehen und läuft über „Passwort vergessen".
Adressänderung
Gewöhnliche Konten: Die neue Adresse muss bestätigen. Die bisherige bekommt einen Hinweis, damit eine untergeschobene Änderung auffällt.
Administratorkonten: beide Adressen müssen bestätigen. Der Grund ist kein formaler: Wer Zugriff auf ein Administratorkonto erlangt, könnte sonst die Adresse auf eine eigene umstellen und sich dauerhaft einnisten – der rechtmäßige Inhaber verlöre den Weg zurück über „Passwort vergessen". Die Zustimmung von der alten Adresse macht das unmöglich, solange der Angreifer nicht auch das Postfach hat.
Wird die Änderung wirksam, werden alle Sitzungen des Kontos beendet.
Deaktivieren und Löschen
Beim Anmeldeversuch an einem deaktivierten Konto erscheint eine eigene Meldung – aber erst nach erfolgreicher Passwortprüfung. Vorher wäre sie ein Hinweis darauf, dass es das Konto gibt; danach weiß die Person das ohnehin. Wer nur „falsches Passwort" liest, obwohl es stimmt, sucht den Fehler an der falschen Stelle.
Beim Löschen werden die Listen des Kontos geregelt: Geteilte Listen gehen an das dienstälteste andere Mitglied über, Listen ohne weitere Mitglieder werden gelöscht. Eine geteilte Liste mitzulöschen würde anderen Leuten Daten wegnehmen, mit denen das ausscheidende Konto nichts mehr zu tun hat. Zur Bestätigung muss die Adresse eingetippt werden.
Administratorkonten sind vor Deaktivierung und Löschung geschützt – auch vor anderen Administratoren und vor sich selbst. Sonst könnte sich die Verwaltung aussperren. Wer einen Administrator entfernen will, nimmt ihm zuerst die Rechte; das geht bewusst nur direkt in der Datenbank.
Fristen
Alle Fristen der Anwendung stehen unter Einstellungen →
Benutzerverwaltung → Fristen und lassen sich dort einsehen und ändern.
Zentral hinterlegt in backend/app/runtime_settings.py, Rangfolge:
Einstellungstabelle → Umgebungsvariable → Vorgabewert im Quelltext
Die Umgebungsvariable ist also der Startwert; sobald in der Verwaltung etwas geändert wird, gilt der gespeicherte Wert.
| Gruppe | Frist | Vorgabe |
|---|---|---|
| Konten | deaktivieren nach … Monaten ohne Anmeldung | 12 |
| löschen nach … Monaten Deaktivierung | 12 | |
| Listen | archivieren nach … Monaten ohne Öffnen | 12 |
| löschen nach … Monaten im Archiv | 12 | |
| Aufbewahrung | Gelöschtes endgültig entfernen nach … Tagen | 30 |
| Quittungen der Warteschlange … Tage | 7 | |
| Produktabfragen gültig … Tage | 180 | |
| Sitzungen und Links | angemeldet bleiben … Tage | 30 |
| Einladung gültig … Tage | 14 | |
| Willkommenslink gültig … Tage | 14 | |
| Bestätigungslink gültig … Stunden | 24 | |
| Passwortlink gültig … Stunden | 1 | |
| Adresswechsel bestätigen binnen … Stunden | 48 |
Nicht dort einstellbar und deshalb in der Oberfläche gesondert aufgeführt:
die 24 Stunden der Missbrauchsabwehr, der Abstand der Aufräumläufe
(CLEANUP_INTERVAL_HOURS) und die Aufbewahrung der Webserver-Protokolle –
Letztere ist Sache der Protokollrotation auf dem Server.
python3 tools/check-retention.py
Prüft dreierlei: dass jede einstellbare Frist auch tatsächlich irgendwo angewandt wird (eine Einstellung ohne Wirkung ist schlimmer als keine – sie behauptet eine), dass jede Tabelle mit Ablaufzeitpunkt vom Aufräumlauf erfasst wird, und dass dort keine festen Zahlen mehr stehen.
Was der Aufräumlauf tut
Einmal täglich, im api-Container:
| Was | Wann |
|---|---|
| Abgelaufene Sitzungen | sofort nach Ablauf |
| Verbrauchte Mail-Token | 7 Tage nach Ablauf |
| Zähler der Missbrauchsabwehr | nach 24 Stunden |
| Quittungen der Warteschlange | nach cleanup_ops_days |
| Abgelaufene Einladungen und öffentliche Links | cleanup_deleted_days nach Ablauf |
| Abgeschlossene Adresswechsel | cleanup_deleted_days nach Ablauf |
| Weich gelöschte Listen, Einträge, Artikel, Märkte, Warengruppen | cleanup_deleted_days nach dem Löschen |
| Produktzwischenspeicher | doppelte Gültigkeit, mindestens 1 Jahr |
| Konten deaktivieren und löschen | auto_deactivate_months, auto_delete_months |
| Listen archivieren, vorwarnen und löschen | auto_archive_months, archive_delete_months |
Achtung bei den Einladungen: Die Frist läuft nach Ablauf, nicht ab Versand. Eine Einladung mit 14 Tagen Gültigkeit verschwindet also erst nach 44 Tagen aus der Datenbank – das gehört so in die Datenschutzerklärung.
0 bedeutet abgeschaltet, nicht „sofort". Ein Tippfehler soll nicht dazu führen, dass beim nächsten Durchlauf alle Konten verschwinden.
Administratorkonten sind ausgenommen. Die Übersicht zeigt vorab, wie viele Konten beim nächsten nächtlichen Durchlauf betroffen wären – die Automatik soll nicht überraschen.
Grundlage ist user.last_seen_at. Der Wert wird bei der Anmeldung gesetzt und
danach höchstens stündlich nachgeführt, damit auch jemand mitzählt, der sich
monatelang nicht neu anmeldet, weil die Sitzung hält.
Archiv
Jede Liste kann archiviert werden – je Person getrennt. Wer eine gemeinsame Liste nicht mehr braucht, nimmt sie aus seiner Übersicht, ohne sie den anderen wegzunehmen.
In der Listenübersicht erscheint unterhalb der aktiven Listen ein aufklappbarer Abschnitt „Archiv". Archivieren und Zurückholen stehen im „⋯"-Menü jeder Zeile und stehen allen Mitgliedern offen, nicht nur dem Eigentümer.
| Methode | Pfad |
|---|---|
| GET | /api/lists?archived=false|true – ohne Parameter alle |
| POST | /api/lists/{id}/archive, /unarchive |
Automatisch archivieren
Wer eine Liste zwölf Monate nicht geöffnet hat, findet sie danach im Archiv.
Grundlage ist list_member.last_opened_at, gesetzt bei jedem Zugriff und
höchstens stündlich nachgeführt.
Das wirkt sich individuell aus: Öffnet ein Mitglied die Liste regelmäßig und ein anderes nie, bleibt sie beim einen aktiv und wandert beim anderen ins Archiv.
Automatisch löschen
Maßgeblich ist allein das Archivdatum des Eigentümers. Dass ein eingeladenes Mitglied die Liste weggeräumt hat, sagt nichts darüber, ob sie noch gebraucht wird – sonst könnte jemand, der einmal eingeladen wurde und nie wieder hineinschaut, eine Löschung auslösen, die den Eigentümer trifft.
Zwei Sicherungen gegen Datenverlust:
Vorwarnungen per E-Mail an den Eigentümer, 7 Tage und 1 Tag vor der Löschung. Jede Stufe genau einmal – eine tägliche Erinnerung wäre Belästigung statt Warnung. Ein Klick auf „Aus dem Archiv holen" setzt die Frist zurück und löscht auch die Warnvermerke, damit beim nächsten Mal wieder gewarnt wird.
Wer die Liste zwischenzeitlich geöffnet hat, verliert sie nicht – auch wenn das Archivdatum alt ist. Ohne diese Prüfung könnte eine manuell archivierte, aber weiterhin benutzte Liste verschwinden.
Gelöscht wird per Soft Delete: Offline gebliebene Geräte erfahren noch davon,
endgültig entfernt der Aufräumlauf nach CLEANUP_DELETED_DAYS.
Kopfleiste
Alle Ansichten teilen sich eine Leiste (web/html/js/appbar.js): links der Weg
zurück, in der Mitte der Kontext (Listenname), rechts ein Menü mit den
Einträgen untereinander.
Vorher hatte jede Ansicht ihre eigene Zeile aus Textlinks. Bei sechs Einträgen unterschiedlicher Länge – „Drucken", „Märkte & Gruppen", „Teilen (2)" – brach das auf schmalen Geräten unregelmäßig um, und die Grundlinien lagen versetzt.
Zwei Feinheiten:
- Eine einzelne Aktion erscheint direkt, ohne Menü. Ein Menü mit einem Eintrag wäre ein Klick zu viel.
- Verweise bleiben Verweise. „Drucken" ist ein
<a>, damit „In neuem Tab öffnen" und das Kopieren der Adresse funktionieren.
Das Menü schließt bei Klick daneben, mit Escape und beim Ansichtswechsel. Im Ausdruck ist die gesamte Leiste ausgeblendet.
Einträge bearbeiten
Im „⋯"-Menü eines Eintrags lässt sich alles ändern: Artikel, Anzahl, Gebinde, Einheit, Eigenschaft, Notiz, Markt, Warengruppe, Preis und Status.
Artikel wechseln oder umbenennen
Der Name gehört nicht dem Eintrag, sondern dem Artikel dahinter – und der kann von mehreren Einträgen benutzt werden, trägt einen Strichcode, Vorgaben für Markt und Warengruppe und hängt an der Preishistorie. Ein Namenswechsel am Eintrag ist deshalb mehrdeutig:
"Mlich" → "Milch" ein Tippfehler soll verschwinden
"Milch" → "Butter" dieser Eintrag soll etwas anderes sein
Die Regel: Benutzt außer diesem Eintrag niemand den Artikel, wird er umbenannt – der Tippfehler verschwindet dann restlos. Sonst hängt der Eintrag auf einen anderen Artikel um, und die übrigen Einträge bleiben unberührt.
Bleibt der bisherige Artikel dabei ohne Einträge zurück und hängt nichts Gepflegtes daran (kein Strichcode, keine Eigenschaften, keine Vorgaben, keine beobachteten Preise), verschwindet er mit. Wo etwas dranhängt, bleibt er stehen – dort stört er nicht und lässt sich von Hand löschen.
Wer einen Artikel ausdrücklich überall umbenennen will, tut das unter „Artikel"; dort ist der Name ein Feld wie jedes andere.
Vorschläge beim Eintippen
Ab drei Zeichen im Feld „Artikel hinzufügen" erscheint eine Liste dessen, was auf dieser Liste schon eingetragen wurde – von allen Mitgliedern, aus den letzten 24 Monaten.
Quelle sind die Listeneinträge, auch die bereits gelöschten: Gerade die abgehakten und aufgeräumten sind die interessanten. Was jemand vor drei Jahren einmal gekauft hat, hilft dagegen nicht mehr und würde die Liste nur verlängern.
Ein Antippen füllt Name, Anzahl, Gebinde, Eigenschaft, Markt und Warengruppe mit den zuletzt verwendeten Werten – trägt den Artikel aber noch nicht ein. Der Fokus springt ins Gebinde-Feld, damit noch Änderungen möglich sind. Erst „Hinzufügen" oder Enter übernimmt.
Der Zeilenabstand der Eingabemaske kommt aus einer einzigen Regel
(.add-row > * + *), nicht aus margin-bottom an jeder Zeile. Beim Umbau der
Namenszeile – sie steckt seit der Vorschlagsliste in einem .name-wrap – war
eine solche Einzelregel wirkungslos geworden, ohne dass es auffiel.
Warengruppe und Markt direkt beim Eintragen
Unter den Detailfeldern stehen zwei Auswahlfelder – erst die Warengruppe, dann der Markt. Ohne sie müsste jeder Eintrag nachbearbeitet werden; beim Füllen einer Liste sind das schnell zwanzig Handgriffe.
Beide bleiben nach dem Hinzufügen stehen und überleben den Ansichtswechsel (gespeichert je Liste in IndexedDB). Wer eine Liste füllt, trägt meist mehrere Artikel für denselben Laden und oft auch für dasselbe Regal ein. Eine getroffene Auswahl ist am grünen Rahmen erkennbar.
Die leere Auswahl heißt „Vorgabe des Artikels", nicht „ohne Markt". Der Unterschied ist wesentlich: Bei leerer Auswahl greift der Server auf die im Artikelstamm hinterlegte Vorgabe zurück. Wer wirklich keine Zuordnung will, entfernt sie danach im „⋯"-Menü des Eintrags.
Vorschläge belegen beide Felder vor – mit dem Markt und der Warengruppe, in denen der Artikel zuletzt stand. Tippst du den Namen danach frei um, bleiben sie stehen: Sie sind sichtbar ausgewählt, und sie stillschweigend zurückzusetzen wäre überraschender, als sie stehenzulassen.
Sortiert nach Häufigkeit, dann nach Aktualität; höchstens acht Vorschläge.
GET /api/lists/{id}/articles/suggest?q=…
Datenbankschema
db/schema.sql enthält das vollständige Schema (Revision 0011).
Für den Normalbetrieb wird die Datei nicht gebraucht – der api-Container
führt beim Start alembic upgrade head aus und legt alles selbst an. Sie ist da
für die Nachvollziehbarkeit im Repository, für das Aufsetzen ohne Alembic und
zum Vergleich, wenn eine Migration irgendwo halb durchgelaufen ist.
docker compose up -d db
docker compose exec -T db mariadb -u root -p einkaufsapp < db/schema.sql
Der Eintrag in alembic_version am Ende ist wichtig: Ohne ihn würde Alembic
beim nächsten Start alle Migrationen erneut anwenden und an den vorhandenen
Tabellen scheitern.
Rechtliche Seiten
web/html/impressum.html und web/html/datenschutz.html – Entwürfe, die
vor der Veröffentlichung ausgefüllt und geprüft werden müssen. Alle offenen
Stellen sind mit […] markiert; die Kästen mit der Klasse draft und die
Erläuterungen mit der Klasse hint gehören danach entfernt.
Verlinkt sind beide in einer Fußzeile, die außerhalb von #app in
index.html steht. Dadurch erscheint sie in jeder Ansicht – auch auf der
Anmeldeseite und in der öffentlichen Listenansicht – ohne dass jede Ansicht
sie selbst einbauen müsste. Im Ausdruck und über dem Kamerasucher ist sie
ausgeblendet.
Die Datenschutzerklärung beschreibt, was diese Anwendung tatsächlich tut, nicht was üblicherweise in solchen Texten steht. Enthalten sind unter anderem: die beiden technisch notwendigen Cookies, die lokale Speicherung in IndexedDB, die Verarbeitung fremder E-Mail-Adressen bei Einladungen, die Push-Dienste der Browserhersteller als Empfänger in Drittländern, Open Food Facts als serverseitig aufgerufener Dienst, die vollständige Aufstellung der Speicherfristen und der Hinweis, dass Listeninhalte in den Bereich des Art. 9 DSGVO geraten können, wenn dort Medikamente stehen.
Auszufüllen sind: Verantwortlicher, Hosting-Anbieter, SMTP-Anbieter, Aufsichtsbehörde des Bundeslandes und die Fristen, falls du sie in der Verwaltung anders eingestellt hast.
Datensparsamkeit im Betrieb
Drei Stellen, an denen bewusst weniger gespeichert wird, als technisch möglich wäre:
Keine externen Schriften. Verwendet wird die Systemschrift des Geräts
(system-ui). Es wird nichts von fremden Servern nachgeladen – die CSP führt
font-src 'self' ausdrücklich auf, obwohl default-src es schon abdeckt,
damit die Sperre beim Lesen der Datei erkennbar ist.
Zugriffsprotokolle mit gekürzter IP-Adresse. Der letzte Block wird auf Null
gesetzt (map in nginx.conf). Der Referer wird gar nicht protokolliert –
bei einem öffentlichen Listenlink stünde dort der Zugangsschlüssel, sobald
jemand von dort aus weiterklickt.
Missbrauchsbremse ohne Klartextadressen. Die Zähler brauchen einen gleichbleibenden Schlüssel je Absender, keinen lesbaren. Verwendet wird ein Streuwert aus IP- beziehungsweise E-Mail-Adresse und einem zufälligen, installationsweiten Zusatz. Das ist Pseudonymisierung, keine Anonymisierung – wer Datenbank samt Zusatz hat, könnte eine vermutete Adresse nachrechnen. Es verhindert aber, dass beim Blick in die Tabelle Adressen mitgelesen werden, und die Werte verschwinden ohnehin nach 24 Stunden.
Sicherheit
docs/sicherheit.md – Ergebnisse der Prüfung auf SQL-Injection, XSS,
Berechtigungen, Sitzungsbehandlung und Datensparsamkeit, dazu die bekannten
Grenzen.
Betriebsdokumentation
docs/betrieb.md – Sicherung, Wiederherstellung, Aktualisierung,
Speicherplatzkontrolle.
Kurzfassung: Gesichert gehört der Datenbankauszug (nicht das Volume – ein
Dateisystemabbild einer laufenden Datenbank ist nicht zuverlässig
wiederherstellbar) und die .env. Der Quelltext gehört ins
Versionsverwaltungssystem, die .env ausdrücklich nicht.
Das dortige Cron-Skript legt den Auszug unter /var/backups/einkaufsapp ab –
von dort nimmt ihn deine vorhandene Borgmatic-Sicherung mit.