Kleiner Anpassungen / Aktualisierung Dokumentation
This commit is contained in:
@@ -21,7 +21,7 @@ APP_SHORT_NAME=Einkauf
|
||||
# Einziger nach aussen veroeffentlichter Port. Dahinter liegt nginx,
|
||||
# das sowohl die Oberflaeche als auch /api/ ausliefert. Der api-Container
|
||||
# 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_PORT=8025
|
||||
@@ -29,14 +29,14 @@ MAILPIT_PORT=8025
|
||||
# Basis-URL, wie Nutzer die App im Browser erreichen - inklusive Port,
|
||||
# falls kein vorgelagerter Reverse Proxy auf 80/443 steht.
|
||||
# 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.
|
||||
# "*" ist in Ordnung, solange api keinen oeffentlichen Port hat.
|
||||
FORWARDED_ALLOW_IPS=*
|
||||
|
||||
# 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
|
||||
# PUBLIC_BASE_URL ebenfalls auf https:// zeigen)
|
||||
# Ein Secure-Cookie über reines HTTP wird vom Browser verworfen - die
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -44,3 +44,4 @@ dist/
|
||||
*.swp
|
||||
.DS_Store
|
||||
*.zip
|
||||
doku/
|
||||
|
||||
1465
docs/entwicklung.md
Normal file
1465
docs/entwicklung.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
# 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`.
|
||||
|
||||
## 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.
|
||||
|
||||
**`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.
|
||||
|
||||
**`COOKIE_SECURE=true`** ist ab jetzt Pflicht, aber auch erst ab jetzt möglich:
|
||||
|
||||
0
tools/check-all.sh
Normal file → Executable file
0
tools/check-all.sh
Normal file → Executable file
0
tools/check-env.sh
Normal file → Executable file
0
tools/check-env.sh
Normal file → Executable file
0
smoke-phase2.sh → tools/smoke-phase2.sh
Normal file → Executable file
0
smoke-phase2.sh → tools/smoke-phase2.sh
Normal file → Executable file
176
tools/smoke-test.sh
Executable file
176
tools/smoke-test.sh
Executable 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: ...'"
|
||||
@@ -1118,6 +1118,7 @@ body.modal-open { overflow: hidden; }
|
||||
}
|
||||
|
||||
.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 button.primary { margin-top: 0; }
|
||||
|
||||
|
||||
@@ -70,7 +70,14 @@ export function parseCents(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;
|
||||
const value = Number(cleaned);
|
||||
return Number.isFinite(value) && value >= 0 ? value : null;
|
||||
@@ -100,5 +107,13 @@ export function formatQuantity(value) {
|
||||
if (value === null || value === undefined) return "";
|
||||
const num = Number(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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -652,7 +652,14 @@ export async function listDetailView(
|
||||
// Sonst bestätigen lassen - auch bei einem Treffer in der
|
||||
// Produktdatenbank. Fremde Angaben sollen nicht ungeprüft in den
|
||||
// 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;
|
||||
|
||||
await change("item.create", {
|
||||
@@ -662,10 +669,8 @@ export async function listDetailView(
|
||||
pack_size: parseQuantity(confirmed.packSize),
|
||||
pack_unit: confirmed.packUnit || null,
|
||||
variant: confirmed.variant || null,
|
||||
...(marketField && marketField.value
|
||||
? { market_id: marketField.value } : {}),
|
||||
...(categoryField && categoryField.value
|
||||
? { category_id: categoryField.value } : {}),
|
||||
...(confirmed.marketId ? { market_id: confirmed.marketId } : {}),
|
||||
...(confirmed.categoryId ? { category_id: confirmed.categoryId } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -6,15 +6,23 @@
|
||||
// und sie ändern können, bevor sie im eigenen Bestand landen.
|
||||
"use strict";
|
||||
|
||||
import { el } from "../dom.js";
|
||||
import { el, formatQuantity } from "../dom.js";
|
||||
|
||||
/**
|
||||
* @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) => {
|
||||
const known = lookup.found && lookup.source === "openfoodfacts";
|
||||
const markets = context.markets || [];
|
||||
const categories = context.categories || [];
|
||||
|
||||
const nameField = el("input", {
|
||||
type: "text",
|
||||
@@ -33,8 +41,10 @@ export function confirmScan(lookup) {
|
||||
const qtyField = el("input.qty", {
|
||||
type: "text",
|
||||
inputMode: "decimal",
|
||||
value: lookup.pack_size != null
|
||||
? String(lookup.pack_size).replace(".", ",") : "",
|
||||
// formatQuantity statt roher Zeichenkette: Die API liefert
|
||||
// "200.000", und "200,000" im Feld sieht nach zweihunderttausend
|
||||
// aus.
|
||||
value: lookup.pack_size != null ? formatQuantity(lookup.pack_size) : "",
|
||||
placeholder: "Gebinde",
|
||||
title: "Packungsgröße",
|
||||
});
|
||||
@@ -51,6 +61,29 @@ export function confirmScan(lookup) {
|
||||
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() {
|
||||
const name = nameField.value.trim();
|
||||
if (!name) {
|
||||
@@ -63,6 +96,8 @@ export function confirmScan(lookup) {
|
||||
packSize: qtyField.value,
|
||||
packUnit: unitField.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("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("button.primary", { type: "button", onclick: accept }, "Hinzufügen"),
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
// Bei jeder Änderung an den Dateien unten hochzählen. Der Wert entscheidet,
|
||||
// wann alte Zwischenspeicher verworfen werden.
|
||||
const VERSION = "v32";
|
||||
const VERSION = "v33";
|
||||
const SHELL_CACHE = `einkaufsapp-shell-${VERSION}`;
|
||||
|
||||
const SHELL = [
|
||||
|
||||
Reference in New Issue
Block a user