1461 lines
62 KiB
Markdown
1461 lines
62 KiB
Markdown
# 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 IndexedDB.
|
||
|
||
Bei Änderungen an den Dateien in `SHELL` muss `VERSION` in `sw.js` hochgezählt
|
||
werden, sonst behalten installierte Clients die alte Fassung.
|
||
|
||
### Aktualisierung installierter Apps
|
||
|
||
Eine installierte PWA wird oft wochenlang nicht vollständig geschlossen. Der
|
||
Browser lädt den Service Worker zwar im Hintergrund neu, aber der neue bleibt
|
||
im Wartezustand, bis alle Fenster geschlossen sind – und die laufende Seite
|
||
arbeitet weiter mit dem alten JavaScript. Für den Nutzer sieht es aus, als käme
|
||
das Update nie an.
|
||
|
||
`web/html/js/update.js` löst das:
|
||
|
||
1. Nachfragen, ob etwas bereitliegt – beim Zurückkehren in den Vordergrund,
|
||
bei Verbindungsrückkehr und alle 30 Minuten
|
||
2. Wenn ja: unten ein Hinweis mit „Jetzt neu laden" und einem Kreuz zum
|
||
Wegklicken
|
||
3. Auf Klick übernimmt der neue Service Worker, und über `controllerchange`
|
||
lädt die Seite **einmal** neu – auch in weiteren offenen Fenstern
|
||
|
||
**`skipWaiting()` steht nicht mehr im `install`-Ereignis.** Genau dort war der
|
||
Fehler: Der neue Service Worker übernahm sofort die Kontrolle, während die
|
||
geladene Seite noch mit dem alten JavaScript lief – gemischte Stände, und
|
||
aktualisiert wurde trotzdem erst beim nächsten vollständigen Schließen. Jetzt
|
||
wartet er, bis die Anwendung ihn ausdrücklich dazu auffordert.
|
||
|
||
**Kein ungefragtes Neuladen.** Ein erzwungener Neustart mitten in einer Eingabe
|
||
würde den halb getippten Artikelnamen verlieren. Die Daten selbst sind sicher –
|
||
sie liegen in der Outbox –, aber der Ärger wäre trotzdem da. Wer den Hinweis
|
||
wegklickt, bekommt die neue Fassung beim nächsten Start; das geschieht ohnehin,
|
||
sobald alle Fenster geschlossen sind.
|
||
|
||
### 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, **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.
|
||
|
||
```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.
|