Files
einkaufsapp/README.md

1406 lines
59 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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:
```yaml
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
```bash
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
1. `ADMIN_INITIAL_PASSWORD` in der `.env` setzen
2. `docker compose up -d` **nicht** `restart`: ein Neustart übernimmt keine
geänderten Werte aus der `.env`, der Container muss neu angelegt werden
3. `http://<host>:46600/` aufrufen, mit `ADMIN_EMAIL` und dem Startpasswort
anmelden
4. Die Oberfläche verlangt sofort ein neues Passwort erst danach ist alles
andere erreichbar
5. `ADMIN_INITIAL_PASSWORD` wieder aus der `.env` entfernen
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
```bash
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
```bash
# 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:
```bash
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:**
```bash
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:
```bash
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:
```bash
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:
```bash
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.
### Drucken
`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
1. Eigentümer gibt eine E-Mail-Adresse und die Berechtigung an
2. Es entsteht ein Datensatz mit Zufallstoken, versendet wird ein Link auf
`/invite?token=…`
3. Der Empfänger meldet sich an oder registriert sich; der Pfad bleibt dabei
erhalten, danach geht es direkt weiter
4. 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
1. Die Operation wandert in die Outbox (IndexedDB, überlebt einen Absturz)
2. Sie wird sofort auf den zwischengespeicherten Stand angewandt und angezeigt
3. Der Versand läuft danach; scheitert er, bleibt sie liegen
4. 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
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
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:
```bash
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`
```bash
./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
```bash
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.
```bash
$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.
```bash
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
```bash
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.
```bash
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 sowie 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.
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.
```bash
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.