Kleiner Anpassungen / Aktualisierung Dokumentation

This commit is contained in:
2026-08-11 18:51:59 +02:00
parent da688bb294
commit 1d295796b4
15 changed files with 1948 additions and 1421 deletions

View File

@@ -21,7 +21,7 @@ APP_SHORT_NAME=Einkauf
# Einziger nach aussen veroeffentlichter Port. Dahinter liegt nginx, # Einziger nach aussen veroeffentlichter Port. Dahinter liegt nginx,
# das sowohl die Oberflaeche als auch /api/ ausliefert. Der api-Container # das sowohl die Oberflaeche als auch /api/ ausliefert. Der api-Container
# hat bewusst KEINEN eigenen Port nach aussen. # hat bewusst KEINEN eigenen Port nach aussen.
HTTP_PORT=46600 HTTP_PORT=8080
# Mailpit-Weboberflaeche, nur mit "--profile dev". An 127.0.0.1 gebunden. # Mailpit-Weboberflaeche, nur mit "--profile dev". An 127.0.0.1 gebunden.
MAILPIT_PORT=8025 MAILPIT_PORT=8025
@@ -29,14 +29,14 @@ MAILPIT_PORT=8025
# Basis-URL, wie Nutzer die App im Browser erreichen - inklusive Port, # Basis-URL, wie Nutzer die App im Browser erreichen - inklusive Port,
# falls kein vorgelagerter Reverse Proxy auf 80/443 steht. # falls kein vorgelagerter Reverse Proxy auf 80/443 steht.
# Landet in Verifikations- und Einladungsmails, muss also stimmen. # Landet in Verifikations- und Einladungsmails, muss also stimmen.
PUBLIC_BASE_URL=http://einkauf.example.de:46600 PUBLIC_BASE_URL=http://einkauf.example.de:8080
# Welchen Absendern von X-Forwarded-For die API glauben darf. # Welchen Absendern von X-Forwarded-For die API glauben darf.
# "*" ist in Ordnung, solange api keinen oeffentlichen Port hat. # "*" ist in Ordnung, solange api keinen oeffentlichen Port hat.
FORWARDED_ALLOW_IPS=* FORWARDED_ALLOW_IPS=*
# Cookie nur über HTTPS ausliefern. # Cookie nur über HTTPS ausliefern.
# false bei direktem HTTP-Zugriff auf Port 46600 # false bei direktem HTTP-Zugriff auf Port 8080
# true sobald ein Reverse Proxy mit TLS davorsteht (dann muss # true sobald ein Reverse Proxy mit TLS davorsteht (dann muss
# PUBLIC_BASE_URL ebenfalls auf https:// zeigen) # PUBLIC_BASE_URL ebenfalls auf https:// zeigen)
# Ein Secure-Cookie über reines HTTP wird vom Browser verworfen - die # Ein Secure-Cookie über reines HTTP wird vom Browser verworfen - die

3
.gitignore vendored
View File

@@ -43,4 +43,5 @@ dist/
.vscode/ .vscode/
*.swp *.swp
.DS_Store .DS_Store
*.zip *.zip
doku/

1631
README.md

File diff suppressed because it is too large Load Diff

1465
docs/entwicklung.md Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
# Betrieb hinter dem Reverse Proxy (OPNsense) # Betrieb hinter dem Reverse Proxy (OPNsense)
Aufbau: Browser → HTTPS → nginx auf der OPNsense → HTTP → Port 46600 auf dem Aufbau: Browser → HTTPS → nginx auf der OPNsense → HTTP → Port 8080 auf dem
Docker-Host → nginx im `web`-Container → `api`. Docker-Host → nginx im `web`-Container → `api`.
## Drei Werte in der `.env` ## Drei Werte in der `.env`
@@ -15,7 +15,7 @@ Nach jeder Änderung `docker compose up -d`, nicht `restart` ein Neustart
übernimmt keine geänderten Umgebungswerte. übernimmt keine geänderten Umgebungswerte.
**`PUBLIC_BASE_URL`** landet in jeder Verifikations-, Einladungs- und **`PUBLIC_BASE_URL`** landet in jeder Verifikations-, Einladungs- und
Passwort-Reset-Mail. Steht dort noch `http://…:46600`, führen die Links ins Passwort-Reset-Mail. Steht dort noch `http://…:8080`, führen die Links ins
Leere, sobald jemand von außen klickt. Leere, sobald jemand von außen klickt.
**`COOKIE_SECURE=true`** ist ab jetzt Pflicht, aber auch erst ab jetzt möglich: **`COOKIE_SECURE=true`** ist ab jetzt Pflicht, aber auch erst ab jetzt möglich:

0
tools/check-all.sh Normal file → Executable file
View File

0
tools/check-env.sh Normal file → Executable file
View File

0
smoke-phase2.sh → tools/smoke-phase2.sh Normal file → Executable file
View File

176
tools/smoke-test.sh Executable file
View File

@@ -0,0 +1,176 @@
#!/usr/bin/env bash
# Durchlauf durch die Phase-2-Endpunkte. Legt eine Testliste an, füllt
# sie und prüft die gruppierte Ansicht.
#
# bash tools/smoke-test.sh http://localhost:8080 admin@example.de
#
# Das Passwort wird abgefragt, nicht als Argument übergeben. Damit landet
# es nicht in der Shell-History, und Sonderzeichen wie ! machen keinen
# Ärger: die History-Expansion der Bash greift nur bei interaktiver
# Eingabe auf der Kommandozeile, nicht bei "read".
#
# Alternativ per Umgebungsvariable, z.B. aus einem Passwortmanager:
# EA_PASSWORD="$(pass show einkaufsapp)" bash tools/smoke-test.sh URL MAIL
#
# Benötigt curl sowie jq oder python3.
set -euo pipefail
BASE="${1:-http://localhost:8080}"
EMAIL="${2:?E-Mail angeben}"
if [ -n "${EA_PASSWORD:-}" ]; then
PASSWORD="$EA_PASSWORD"
else
printf 'Passwort für %s: ' "$EMAIL" >&2
IFS= read -rs PASSWORD
printf '\n' >&2
fi
[ -n "$PASSWORD" ] || { echo "Kein Passwort angegeben." >&2; exit 1; }
# jq ist bequem, aber nicht zwingend. Ohne jq springt python3 ein -
# beides kann JSON korrekt erzeugen und lesen, was hier der Punkt ist:
# Passwoerter und Umlaute duerfen nicht per String-Bastelei in die
# Nutzlast wandern.
if command -v jq >/dev/null 2>&1; then
JSON_TOOL=jq
elif command -v python3 >/dev/null 2>&1; then
JSON_TOOL=python3
else
echo "Benötigt jq oder python3. Installieren mit: apt install jq" >&2
exit 1
fi
# json_obj key value [key value ...] -> JSON-Objekt auf stdout
json_obj() {
if [ "$JSON_TOOL" = jq ]; then
local args=() filter="{" first=1 i=1
while [ $# -gt 0 ]; do
args+=(--arg "k$i" "$1" --arg "v$i" "$2")
[ $first -eq 1 ] || filter="$filter,"
filter="$filter(\$k$i): \$v$i"
first=0; i=$((i+1)); shift 2
done
jq -n "${args[@]}" "$filter}"
else
python3 -c 'import json,sys; a=sys.argv[1:]; print(json.dumps(dict(zip(a[::2], a[1::2]))))' "$@"
fi
}
# json_get <schluessel> - liest einen Wert aus JSON auf stdin
json_get() {
if [ "$JSON_TOOL" = jq ]; then
jq -r ".$1 // empty"
else
python3 -c 'import json,sys
try:
d = json.load(sys.stdin)
except Exception:
sys.exit(0)
v = d.get(sys.argv[1]) if isinstance(d, dict) else None
print("" if v is None else v)' "$1"
fi
}
JAR="$(mktemp)"
chmod 600 "$JAR"
trap 'rm -f "$JAR"' EXIT
say() { printf '\n\033[1m== %s\033[0m\n' "$1"; }
api() {
local method="$1" path="$2" body="${3:-}"
local csrf
csrf="$(awk '/ea_csrf/ {print $7}' "$JAR")"
if [ -n "$body" ]; then
curl -sS -b "$JAR" -c "$JAR" -X "$method" "$BASE$path" \
-H 'Content-Type: application/json' -H "X-CSRF-Token: $csrf" \
--data-binary "$body"
else
curl -sS -b "$JAR" -c "$JAR" -X "$method" "$BASE$path" \
-H "X-CSRF-Token: $csrf"
fi
}
say "Anmelden"
# JSON strukturiert erzeugen, nicht per String-Interpolation: sonst
# zerbricht jedes " oder \ im Passwort die Nutzlast.
LOGIN_JSON="$(json_obj email "$EMAIL" password "$PASSWORD")"
curl -sS -c "$JAR" -X POST "$BASE/api/auth/login" \
-H 'Content-Type: application/json' \
--data-binary "$LOGIN_JSON" > /tmp/ea_login.$$ 2>&1
WHO="$(json_get email < /tmp/ea_login.$$)"
if [ -z "$WHO" ]; then
echo " Anmeldung fehlgeschlagen: $(json_get detail < /tmp/ea_login.$$)" >&2
rm -f /tmp/ea_login.$$
exit 1
fi
echo " angemeldet als $WHO"
rm -f /tmp/ea_login.$$
unset PASSWORD LOGIN_JSON
say "CSRF-Schutz muss greifen (erwartet 403)"
code=$(curl -sS -o /dev/null -w '%{http_code}' -b "$JAR" -X POST "$BASE/api/lists" \
-H 'Content-Type: application/json' --data-binary '{"name":"Ohne CSRF"}')
[ "$code" = "403" ] && echo " ok: $code" || { echo " FEHLER: $code statt 403"; exit 1; }
say "Liste anlegen"
LIST=$(api POST /api/lists "$(json_obj name Wocheneinkauf)" | json_get id)
echo " list_id=$LIST"
say "Märkte anlegen"
EDEKA=$(api POST "/api/lists/$LIST/markets" '{"name":"Edeka","sort_order":1}' | json_get id)
DM=$(api POST "/api/lists/$LIST/markets" '{"name":"dm","sort_order":2}' | json_get id)
echo " Edeka=$EDEKA dm=$DM"
say "Doppelter Marktname muss abgelehnt werden (erwartet 409)"
code=$(curl -sS -o /dev/null -w '%{http_code}' -b "$JAR" -X POST "$BASE/api/lists/$LIST/markets" \
-H 'Content-Type: application/json' \
-H "X-CSRF-Token: $(awk '/ea_csrf/ {print $7}' "$JAR")" \
--data-binary '{"name":"Edeka"}')
[ "$code" = "409" ] && echo " ok: $code" || { echo " FEHLER: $code statt 409"; exit 1; }
say "Warengruppen anlegen"
MOLK=$(api POST "/api/lists/$LIST/categories" '{"name":"Molkerei","sort_order":1}' | json_get id)
OBST=$(api POST "/api/lists/$LIST/categories" '{"name":"Obst und Gemüse","sort_order":2}' | json_get id)
say "Artikel mit Attributen anlegen"
api POST "/api/lists/$LIST/articles" "$(cat <<JSON
{"name":"Vollmilch","barcode":"4001234567890",
"default_market_id":"$EDEKA","default_category_id":"$MOLK",
"attributes":[{"name":"Verpackungseinheit","value":"1 Liter"},
{"name":"Fettgehalt","value":"3,5 %"}],
"available_market_ids":["$EDEKA"]}
JSON
)" | json_get name | sed 's/^/ Artikel angelegt: /'
say "Einträge anlegen (Artikel wird bei Bedarf erzeugt)"
for a in Butter Äpfel Bananen Zahnpasta; do
api POST "/api/lists/$LIST/items" "$(json_obj article_name "$a")" > /dev/null
done
MILCH=$(api POST "/api/lists/$LIST/items" \
'{"article_name":"Vollmilch","quantity":2,"unit":"l"}' | json_get id)
say "Zuordnungen setzen"
api PATCH "/api/lists/$LIST/items/$MILCH" \
"{\"market_id\":\"$EDEKA\",\"category_id\":\"$MOLK\",\"price_cents\":129}" \
| json_get article_name | sed 's/^/ Preis gesetzt für: /'
say "Barcode-Suche"
api GET "/api/lists/$LIST/articles/by-barcode/4001234567890" | json_get name | sed 's/^/ gefunden: /'
say "Gruppierte Ansicht"
api GET "/api/lists/$LIST/view" | python3 "$(dirname "$0")/render_view.py"
say "Abhaken und aufräumen"
api PATCH "/api/lists/$LIST/items/$MILCH" '{"status":"bought"}' | json_get status | sed 's/^/ neuer Status: /'
api POST "/api/lists/$LIST/items/clear-bought" | json_get removed | sed 's/^/ entfernte Einträge: /'
say "Fremde Liste darf nicht sichtbar sein (erwartet 404)"
code=$(curl -sS -o /dev/null -w '%{http_code}' -b "$JAR" \
"$BASE/api/lists/00000000-0000-0000-0000-000000000000")
[ "$code" = "404" ] && echo " ok: $code" || { echo " FEHLER: $code statt 404"; exit 1; }
say "Fertig"
echo "Testliste $LIST bleibt bestehen. Löschen mit:"
echo " curl -b <cookiejar> -X DELETE $BASE/api/lists/$LIST -H 'X-CSRF-Token: ...'"

View File

@@ -1118,6 +1118,7 @@ body.modal-open { overflow: hidden; }
} }
.modal-card .add-fields { margin-top: .6rem; } .modal-card .add-fields { margin-top: .6rem; }
.modal-card .add-select { margin-top: .5rem; }
.modal-card .menu-actions { margin-top: 1.2rem; } .modal-card .menu-actions { margin-top: 1.2rem; }
.modal-card .menu-actions button.primary { margin-top: 0; } .modal-card .menu-actions button.primary { margin-top: 0; }

View File

@@ -70,7 +70,14 @@ export function parseCents(text) {
} }
export function parseQuantity(text) { export function parseQuantity(text) {
const cleaned = String(text).trim().replace(",", "."); // Leerzeichen und Tausenderpunkte entfernen, Komma als Dezimaltrenner.
// Die Punkte fallen weg, weil eine Eingabe wie "1.500" im Deutschen
// eintausendfünfhundert meint - und weil formatQuantity keine
// Tausendertrennung mehr erzeugt, bleibt kein anderer Fall übrig.
const cleaned = String(text)
.trim()
.replace(/[\s\u00a0.]/g, "")
.replace(",", ".");
if (!cleaned) return null; if (!cleaned) return null;
const value = Number(cleaned); const value = Number(cleaned);
return Number.isFinite(value) && value >= 0 ? value : null; return Number.isFinite(value) && value >= 0 ? value : null;
@@ -100,5 +107,13 @@ export function formatQuantity(value) {
if (value === null || value === undefined) return ""; if (value === null || value === undefined) return "";
const num = Number(value); const num = Number(value);
if (!Number.isFinite(num)) return String(value); if (!Number.isFinite(num)) return String(value);
return num.toLocaleString("de-DE", { maximumFractionDigits: 3 }); // OHNE Tausendertrennung. Der Wert landet auch in Eingabefeldern, und
// parseQuantity liest den deutschen Tausenderpunkt als Dezimalpunkt:
// Aus einer 1000-Gramm-Packung wurde beim Bearbeiten eine mit 1 Gramm.
// Bei Mengen bringt die Trennung ohnehin wenig - "1000 g" liest sich
// gut genug.
return num.toLocaleString("de-DE", {
maximumFractionDigits: 3,
useGrouping: false,
});
} }

View File

@@ -652,7 +652,14 @@ export async function listDetailView(
// Sonst bestätigen lassen - auch bei einem Treffer in der // Sonst bestätigen lassen - auch bei einem Treffer in der
// Produktdatenbank. Fremde Angaben sollen nicht ungeprüft in den // Produktdatenbank. Fremde Angaben sollen nicht ungeprüft in den
// eigenen Artikelstamm wandern. // eigenen Artikelstamm wandern.
const confirmed = await confirmScan(lookup); // Auswahl aus der Eingabezeile als Vorbelegung mitgeben; was der
// Dialog zurückliefert, gilt.
const confirmed = await confirmScan(lookup, {
markets,
categories,
market: marketField ? marketField.value : null,
category: categoryField ? categoryField.value : null,
});
if (!confirmed) return; if (!confirmed) return;
await change("item.create", { await change("item.create", {
@@ -662,10 +669,8 @@ export async function listDetailView(
pack_size: parseQuantity(confirmed.packSize), pack_size: parseQuantity(confirmed.packSize),
pack_unit: confirmed.packUnit || null, pack_unit: confirmed.packUnit || null,
variant: confirmed.variant || null, variant: confirmed.variant || null,
...(marketField && marketField.value ...(confirmed.marketId ? { market_id: confirmed.marketId } : {}),
? { market_id: marketField.value } : {}), ...(confirmed.categoryId ? { category_id: confirmed.categoryId } : {}),
...(categoryField && categoryField.value
? { category_id: categoryField.value } : {}),
}); });
} }

View File

@@ -6,15 +6,23 @@
// und sie ändern können, bevor sie im eigenen Bestand landen. // und sie ändern können, bevor sie im eigenen Bestand landen.
"use strict"; "use strict";
import { el } from "../dom.js"; import { el, formatQuantity } from "../dom.js";
/** /**
* @param {object} lookup Antwort von /api/lists/{id}/barcode/{code} * @param {object} lookup Antwort von /api/lists/{id}/barcode/{code}
* @returns {Promise<{name: string, quantity: string, unit: string, variant: string}|null>} * @param {object} context
* @param {Array} context.markets Märkte der Liste
* @param {Array} context.categories Warengruppen der Liste
* @param {string|null} context.market Vorauswahl aus der Eingabezeile
* @param {string|null} context.category dito
* @returns {Promise<{name, count, packSize, packUnit, variant,
* marketId, categoryId}|null>}
*/ */
export function confirmScan(lookup) { export function confirmScan(lookup, context = {}) {
return new Promise((resolve) => { return new Promise((resolve) => {
const known = lookup.found && lookup.source === "openfoodfacts"; const known = lookup.found && lookup.source === "openfoodfacts";
const markets = context.markets || [];
const categories = context.categories || [];
const nameField = el("input", { const nameField = el("input", {
type: "text", type: "text",
@@ -33,8 +41,10 @@ export function confirmScan(lookup) {
const qtyField = el("input.qty", { const qtyField = el("input.qty", {
type: "text", type: "text",
inputMode: "decimal", inputMode: "decimal",
value: lookup.pack_size != null // formatQuantity statt roher Zeichenkette: Die API liefert
? String(lookup.pack_size).replace(".", ",") : "", // "200.000", und "200,000" im Feld sieht nach zweihunderttausend
// aus.
value: lookup.pack_size != null ? formatQuantity(lookup.pack_size) : "",
placeholder: "Gebinde", placeholder: "Gebinde",
title: "Packungsgröße", title: "Packungsgröße",
}); });
@@ -51,6 +61,29 @@ export function confirmScan(lookup) {
placeholder: "Eigenschaft", placeholder: "Eigenschaft",
}); });
// Warengruppe und Markt wie in der Eingabezeile - der gescannte
// Artikel soll ohne Nacharbeit an der richtigen Stelle landen.
// Vorbelegt mit dem, was im Formular schon ausgewählt war.
const categoryField = categories.length
? el("select.category-select", { title: "Warengruppe" },
el("option", { value: "" }, "— Vorgabe des Artikels —"),
categories.map((c) => el("option", { value: c.id }, c.name)))
: null;
if (categoryField && context.category
&& categories.some((c) => c.id === context.category)) {
categoryField.value = context.category;
}
const marketField = markets.length
? el("select.market-select", { title: "Markt" },
el("option", { value: "" }, "— Vorgabe des Artikels —"),
markets.map((m) => el("option", { value: m.id }, m.name)))
: null;
if (marketField && context.market
&& markets.some((m) => m.id === context.market)) {
marketField.value = context.market;
}
function accept() { function accept() {
const name = nameField.value.trim(); const name = nameField.value.trim();
if (!name) { if (!name) {
@@ -63,6 +96,8 @@ export function confirmScan(lookup) {
packSize: qtyField.value, packSize: qtyField.value,
packUnit: unitField.value.trim(), packUnit: unitField.value.trim(),
variant: variantField.value.trim(), variant: variantField.value.trim(),
marketId: marketField ? marketField.value || null : null,
categoryId: categoryField ? categoryField.value || null : null,
}); });
} }
@@ -83,6 +118,8 @@ export function confirmScan(lookup) {
el("label", {}, "Name"), nameField, el("label", {}, "Name"), nameField,
el("div.add-fields", {}, countField, qtyField, unitField, variantField), el("div.add-fields", {}, countField, qtyField, unitField, variantField),
categoryField ? el("div.add-select", {}, categoryField) : null,
marketField ? el("div.add-select", {}, marketField) : null,
el("div.menu-actions", {}, el("div.menu-actions", {},
el("button.primary", { type: "button", onclick: accept }, "Hinzufügen"), el("button.primary", { type: "button", onclick: accept }, "Hinzufügen"),

View File

@@ -5,7 +5,7 @@
// Bei jeder Änderung an den Dateien unten hochzählen. Der Wert entscheidet, // Bei jeder Änderung an den Dateien unten hochzählen. Der Wert entscheidet,
// wann alte Zwischenspeicher verworfen werden. // wann alte Zwischenspeicher verworfen werden.
const VERSION = "v32"; const VERSION = "v33";
const SHELL_CACHE = `einkaufsapp-shell-${VERSION}`; const SHELL_CACHE = `einkaufsapp-shell-${VERSION}`;
const SHELL = [ const SHELL = [