#94 — Vorratsschrank — Lagerbestand mit Barcode-Scan (Phase 1) #94
Labels
No labels
priority/could
priority/must
priority/should
priority/wont
status/blocked
status/claimed
status/done-migrated
type/bug
type/feature
type/infra
type/tech-debt
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
robert/todo#94
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Story: Vorratsschrank — Lagerbestand mit Barcode-Scan (Phase 1)
Depends on:
#89(Listentyp-Vereinheitlichung — Vorratsschrank ist der sechste Listentyp darin, ohneErledigt-/Abhak-Workflow; siehe
#89's aktualisierte Typenliste).As a Haushaltsmitglied,
I want to den Lagerbestand unseres Vorratsschranks per Barcode-Scan beim Ein-/Auslagern pflegen, Soll-Mengen
definieren und die Einkaufsliste automatisch aktualisieren lassen,
so that wir jederzeit wissen, was vorrätig ist, nie unbemerkt etwas ausgeht und nichts doppelt gekauft wird.
Acceptance criteria:
Barcode-Scan & Ein-/Auschecken
nötig.
den aktuellen Bestandswert implementiert werden. Da der Bestand haushaltsweit geteilt ist und mehrere
Personen gleichzeitig scannen können (das ist der zentrale Zweck dieser Story), braucht es eine atomare
Erhöhung/Verringerung auf DB-Ebene (z. B.
UPDATE ... SET quantity = quantity + 1), sonst gehen beizeitgleichen Scans Änderungen verloren — ein Bug-Muster, das in diesem Projekt bereits mehrfach erst
nachträglich in der Code-Review gefunden wurde (siehe Decisions Log
#61/#71/#80/#85), hier von vornhereinvermeiden.
#95).globale Produkt-Wissensdatenbank wie
#90, statt eine zweite aufzubauen. Konkret: der Barcode wird alsoptionales Zusatzfeld an einen bestehenden (namensbasierten) Produkt-Eintrag angehängt, statt eine zweite,
barcode-basierte Produktidentität parallel zu führen (siehe
#90's entsprechende Ergänzung).Soll-Mengen & Einkaufslisten-Integration
durch ein Auschecken unter die definierte Soll-Menge, wird die fehlende Menge automatisch auf genau diese
Liste geschrieben (kein Griff nach dem persönlichen Default aus
#85— der Vorratsschrank isthaushaltsweit geteilt, ein persönlicher Default wäre mehrdeutig, wenn Haushaltsmitglieder
unterschiedliche Defaults haben). Der Eintrag nutzt dasselbe geteilte "Quelle: manuell/synchronisiert/
Vorratsschrank"-Datenmodell, das
#90für den Online-Warenkorb-Abgleich vorsieht — keine zwei getrennten"automatische Quelle"-Mechanismen.
vorhanden ist, erscheint ein Hinweis mit Vorschlag zur Mengenanpassung, z. B.: "Du hast noch 3 Dosen
Kichererbsen im Schrank. Da du mindestens 1 Dose vorrätig haben möchtest, nur 3 statt 5 auf die Liste
schreiben?" — Nutzer kann den Vorschlag annehmen oder die ursprüngliche Menge behalten.
Übersicht
Feature-Umfang (Anwendung der bestehenden Todo-Item-Features auf Vorratsschrank-Artikel)
oben ausdrücklich nicht Teil dieser Story.
Dringlichkeitssignal; eine zusätzliche manuelle Einstufung wäre redundant.
geteilten Bestand.
Kategorien-Sortierung aus
#80.das übernimmt bereits das Soll-Mengen-System oben.
hat wann was ein-/ausgecheckt) besonders wertvoll.
2026-08-07 — kein Bedarf).
Einkaufsliste.
#86): Ja — Produktname soll direkt korrigierbar sein wie bei Todos.setzen), statt eine neue Interaktionsform zu erfinden.
Out of scope for this story:
#95.stabile, dokumentierte API für "Scan-Event: Barcode X, Ein-/Auschecken" bereitstellen, die ein späteres
externes Gerät aufrufen kann, ohne dass umgebaut werden muss (gleiches Prinzip wie
#90'sOnline-Warenkorb-Hinweis).
Bedarf für ein Ablaufdatum-Konzept).
Entschieden (2026-08-07):
Realität, dass es auch nur einen physischen Schrank gibt. Die Mitgliedschaft läuft über ein eigenes
Sharing/Invite analog zu einer Liste, ist aber an dieselben Freigaben wie die verknüpfte Ziel-Einkaufsliste
(siehe oben) gekoppelt, statt unabhängig verwaltet zu werden.
Vorratsschranks fest ausgewählt (siehe oben), nicht über den persönlichen Default aus
#85ermittelt.Zur Kenntnis:
unproblematisch, da die manuelle Eingabe als Fallback ohnehin vorgesehen ist.
design (
94_pantry_inventory_phase1_design.md)Design:
#94— Vorratsschrank (Pantry), Phase 1Scope-Entscheidung (Architect) — bewusst kleiner als die volle AC-Feature-Parität-Liste
Die Story listet unter "Feature-Umfang" auch Labels, Kommentare, Aktivitäts-Feed und CSV-Import/-Export als
"Ja". Labels/Kommentare/Aktivitäts-Feed sind in diesem Repo aktuell hart an
TodoListId/TodoNrgebunden(
LabelEntity,CommentEntity,ActivityEventEntity— keine polymorphe/generische Struktur), genau wie#89für Shopping Lists strukturelle Kopien statt einer gemeinsamen Tabelle gebaut hat. Das für Pantry ebenfalls
sauber nachzubauen ist ein eigener, non-trivialer Batch Arbeit, der mit der Kernaufgabe dieser Story
(Bestandsführung, atomare Scans, Soll-Mengen, Einkaufslisten-Kopplung — das, was im "As a / I want to / so
that" tatsächlich beschrieben ist) nichts zu tun hat. CSV-Import (anders als Export) braucht ebenfalls einen
komplett neuen Backend-Handler (
ImportPantryProductsFromCsvCommand, analog zu#90'sImportShoppingProductsFromCsvCommand) und ist eine reine Datenportabilitäts-Annehmlichkeit, kein Kernversprechender Story. Entscheidung: Labels/Kommentare/Aktivitäts-Feed und CSV-Import/-Export werden in dieser
Runde zurückgestellt und als Folge-Story dokumentiert (
#101, siehe unten), statt die Kernfunktion (Barcode-Scan, atomare Bestandsführung, Soll-Mengen-Kopplung an die Einkaufsliste) durch zusätzlichen Umfang zu
verwässern — gleiches Prinzip wie
#89(WS-Live-Sync →#99) und#90(Kamera-Scan selbst →#94, jetzt hiergebaut). Kategorien/Sektionen und manuelle Drag&Drop-Sortierung bleiben im Umfang — beide sind für eine
brauchbare Bestandsübersicht selbst nötig (keine separierbare Zusatzfunktion) und folgen 1:1 dem bereits
bestehenden Shopping-Kategorien-Muster (
#80/#81/#90), keine neue Bauweise.Sharing-Modell: an die Ziel-Einkaufsliste gekoppelt, keine eigene Mitgliederverwaltung
Die Story sagt explizit: "Die Mitgliedschaft läuft über ein eigenes Sharing/Invite analog zu einer Liste, ist
aber an dieselben Freigaben wie die verknüpfte Ziel-Einkaufsliste gekoppelt, statt unabhängig verwaltet zu
werden." Architect-Lesart: keine eigene
PantryToUserEntity/Invitation-Struktur — Zugriff auf einenVorratsschrank wird direkt über die Mitgliedschaft der verknüpften
ShoppingListEntitygeprüft(
PantryEntity.TargetShoppingListId → ShoppingListEntity.Users). Wer Mitglied der Ziel-Einkaufsliste ist, hatautomatisch Zugriff auf den Vorratsschrank; wer die Ziel-Einkaufsliste verlässt, verliert ihn automatisch mit.
Das spart eine komplette zweite Sharing-Infrastruktur (kein neues Invite/Member-UI für Pantry) und bildet die
im Text beschriebene Kopplung 1:1 ab, statt sie nur lose zu simulieren.
Ziel-Einkaufsliste — analog zu
AuthorizeShoppingListOwnerAccessForCurrentUserQuery.— analog zu
AuthorizeShoppingListAccessForCurrentUserQuery.jedes Haushaltsmitglied mit Zugriff auf die Liste soll einen Vorratsschrank dafür anlegen können.
Datenmodell
PantryEntity:Id, Title, TargetShoppingListId (FK ShoppingListEntity). Kein eigenesUsers-Feld(siehe oben).
PantryCategoryEntity: Id, PantryId, Name, Icon?, IsDefault, SortOrder — strukturelle Kopie vonShoppingCategoryEntity. Ein Vorratsschrank startet mit einer einzelnen "Allgemein"-Default-Sektion(keine 14-Sektionen-CSV wie
#90— dafür gibt es keine passende Quelle; Nutzer legt bei Bedarf eigene an,gleiches Muster wie
#80's ursprüngliches "General"-Seeding).PantryProductEntity: `Id, PantryId, CategoryId?, Name (PantryProductName), Quantity (PantryQuantity, intExecuteUpdateAsyncmitSetProperty(x => x.Quantity, x => x.Quantity + 1)bzw. einem geklemmten Dekrement(
x.Quantity > 0 ? x.Quantity - 1 : 0, als eine SQL-CASE WHEN-Übersetzung) — kein Read-Modify-Write, exaktwie in der Story gefordert. Rückgabewert für den DTO kommt aus einem Folge-
SELECTdirekt nach dem Update(das eigentliche Zähler-Update selbst bleibt atomar; der zurückgegebene DTO-Wert kann bei echter Gleichzeitig-
keit theoretisch schon den nächsten Scan wiederspiegeln — akzeptabel, das ist erwartetes Verhalten bei
geteiltem Zugriff, keine Dateninkonsistenz).
"Geteiltes Quellen-Modell" für automatische Einkaufslisten-Einträge — Klärung einer mehrdeutigen AC-Formulierung
Die Story sagt: "Der Eintrag nutzt dasselbe geteilte 'Quelle: manuell/synchronisiert/Vorratsschrank'-
Datenmodell, das
#90für den Online-Warenkorb-Abgleich vorsieht." Wörtlich genommen würde das bedeuten,ShoppingProductEntity.IsInCart/InCartSourcezu setzen, wenn Pantry ein Produkt wegen Soll-Mengen-Unterschreitung neu auf die Liste schreibt — das ist aber semantisch falsch: ein wegen Mangel neu
hinzugefügtes Produkt ist gerade nicht schon im Warenkorb, das wäre das Gegenteil der eigentlichen
Bedeutung von
IsInCart. Architect-Klärung: Der eigentliche Punkt der AC ist "keine zwei parallelenQuellen-Enums" — das wird erfüllt, indem
ShoppingProductInCartSource(der bestehende Typ, bereits mitManual/OnlineSynced/PantryAuto) für ein zweites, neues Feld wiederverwendet wird:ShoppingProductEntity.ActivatedVia (ShoppingProductInCartSource?)— protokolliert, warum ein Produkt aktivauf der Liste steht (
null= wie bisher manuell,PantryAuto= automatisch wegen Vorratsschrank-Soll-Menge).Ein Feld für "warum ist es auf der Liste", ein anderes für "ist es schon im Warenkorb" — beide nutzen
denselben Enum-Typ (die eigentliche Wiederverwendung, die die AC verlangt), aber getrennte Spalten mit
getrennter, korrekter Bedeutung.
OnlineSyncedbleibt für beide Felder reserviert, ungenutzt in dieser Runde.Soll-Mengen-Workflow
CheckOutPantryProductCommandHandlerberechnet nach dematomaren Dekrement
fehlend = TargetQuantity - neueMenge(nur wennTargetQuantitygesetzt undneueMenge < TargetQuantity) und schreibt das fehlende Delta auf dieTargetShoppingListId-Liste(
CreateShoppingProductCommand, falls Name noch nicht existiert, dannActivateShoppingProductCommandmit der fehlenden Menge als Text,
ActivatedVia = PantryAuto). Läuft im selben Handler wie das Auscheckenselbst (kein Event-Bus in diesem Projekt üblich für synchrone Folge-Effekte, siehe z. B.
DeactivateShoppingProductCommandHandler's direkter Aufruf-Stil).GetPantryStockForProductNameQuery(shoppingListId, productName), case-insensitiv gegen alle mit dieserListe als Ziel verknüpften Pantries. Frontend ruft sie vor dem Bestätigen der Mengen-Popup auf und zeigt bei
Treffer den in der AC beschriebenen Hinweistext mit Vorschlag.
Unbekannter Barcode → Open Food Facts
Neuer
IOpenFoodFactsClient(typedHttpClient,world.openfoodfacts.org/api/v2/product/{barcode}.json,Timeout + Fehler-Swallowing →
nullstatt Exception, damit ein Ausfall des externen Diensts nie einen Scanblockiert, nur auf "manuelle Eingabe nötig" zurückfällt). Barcode wird vor dem Aufruf gegen
^\d{8,14}$validiert (typische EAN/UPC-Länge) — verhindert, dass beliebiger Nutzer-Text in die externeRequest-URL eingebettet wird (SSRF-/Robustheits-Härtung, siehe Security-Review unten).
Kamera-Barcode-Scan (Frontend)
Neue Abhängigkeit
@zxing/browser(reiner JS-Port von ZXing, keine nativen Abhängigkeiten, MIT-Lizenz) fürgetUserMedia-Video-Stream-Dekodierung. Neue KomponentePantryBarcodeScanner.tsx: öffnet die Kamera, dekodiertkontinuierlich, ruft bei Treffer
CheckInPantryProductCommand/CheckOutPantryProductCommand(UmschalterEin-/Auschecken). Fällt bei fehlender Kamera-Berechtigung oder fehlender Kamera auf ein Texteingabefeld zurück
(gleiches Sicherheitsnetz-Muster wie
#90'sShoppingBarcodeScanDialog, die weiterhin nur Text-Eingabe bietet).Nicht in einem echten Browser mit echter Kamera verifizierbar in dieser Sandbox — wie bereits bei
#52/#85für andere gerätespezifische Funktionen dokumentiert, ist das ein bekanntes, nicht in dieser Runde behebbares
Verifikations-Limit, kein stillschweigend übersprungener Test.
Out of scope / Folge-Punkte (dokumentiert)
#101(neu angelegt).#95vorhanden.CheckInPantryProductCommand/CheckOutPantryProductCommandbereits als stabile, eigenständig aufrufbare Commands (kein UI-gekoppelterNebeneffekt), sodass ein späteres externes Gerät sie direkt aufrufen kann.
security_prereview (
94_pantry_inventory_phase1_security_prereview.md)Security Pre-Review:
#94— Vorratsschrank (Pantry), Phase 1Reviewed before implementation, per
ai/roles/00_team_overview.md's feature cycle (design → securitypre-review → implementation).
1. Atomare Bestandsänderung (Story's eigener Architect-Hinweis)
Risiko: Read-Modify-Write auf einen geteilten Zähler bei gleichzeitigen Scans mehrerer Haushaltsmitglieder
verliert Updates (klassische Race Condition, in diesem Projekt laut Story bereits mehrfach nachträglich in
Code-Reviews gefunden —
#61/#71/#80/#85).Mitigation:
ExecuteUpdateAsyncmitSetProperty(x => x.Quantity, x => x.Quantity + 1)(Dekrement mitgeklemmtem
CASE WHEN) — eine einzelne atomare SQL-UPDATE-Anweisung, kein vorherigesSELECTdes aktuellenWerts. Verbindlich für Backend-Implementierung, keine Ausnahme.
2. Externe HTTP-Anfrage an Open Food Facts (SSRF-artiges Risiko)
Risiko: Ein vom Client kommender "Barcode"-String fließt direkt in eine vom Server ausgehende URL ein. Ohne
Validierung könnte ein manipulierter Wert (z. B. mit
../oder einer vollständigen alternativen Host-Angabe,falls die URL-Konstruktion das zulässt) den Server dazu bringen, gegen ein anderes Ziel als die beabsichtigte
Open-Food-Facts-API zu requesten (interne Netzwerk-Scans, etc.).
Mitigation:
ShoppingProductBarcode-Value-Object selbst, das laut
#90bewusst frei/symbologie-agnostisch bleibt — eine Änderung dort würde#90's bestehendes Verhalten überraschend mitändern) gegen^\d{8,14}$geprüft, direkt im neuenIOpenFoodFactsClient. Ein Wert, der nicht passt, wird nie in die externe URL eingesetzt — die Abfrageliefert dann direkt "nicht gefunden" zurück, ohne HTTP-Aufruf.
https://world.openfoodfacts.org/...) ist eine feste Konstante im Code, nicht konfigurierbarüber Nutzereingabe — der Barcode füllt ausschließlich einen Pfad-Segment-Platzhalter, nie das Host/Schema.
try/catchum den gesamten Aufruf — ein Ausfall/Hänger des externen Diensts darf niemals deneigenen Request blockieren oder eine unbehandelte Exception nach außen durchreichen; fällt bei jedem Fehler
auf "nicht gefunden, manuelle Eingabe nötig" zurück.
Authentifizierung).
3. Sharing-Kopplung an die Ziel-Einkaufsliste (kein eigenes Pantry-Sharing)
Risiko: Wenn die Autorisierungsprüfung nicht sauber über
TargetShoppingListIdverkettet, könnte einNutzer ohne Zugriff auf die verknüpfte Einkaufsliste trotzdem auf den Vorratsschrank zugreifen (oder
umgekehrt: ein Mitglied verliert beim Verlassen der Liste keinen Pantry-Zugriff, weil die Prüfung gecacht
oder falsch verkettet ist).
Mitigation: Jede Pantry-Autorisierungs-Query (
AuthorizePantryAccessForCurrentUserQuery,AuthorizePantryOwnerAccessForCurrentUserQuery) liest live gegenShoppingListToUserEntity(keinZwischenspeichern der Mitgliedschaft auf der
PantryEntityselbst) — identisches Live-Prüf-Muster wieAuthorizeShoppingListAccessQueryHandler. EinPantryEntity.TargetShoppingListId-Wechsel (falls je erlaubt)muss immer sofort die neue Ziel-Liste maßgeblich machen, nie die alte cachen — für Phase 1 wird die Ziel-Liste
nach Anlage nicht änderbar (kein
SetPantryTargetListCommandin dieser Story), was dieses Risiko vonvornherein eliminiert statt es zu mitigieren.
4. Löschung der Ziel-Einkaufsliste
Risiko: Löscht der Owner die verknüpfte Einkaufsliste, wird der Vorratsschrank verwaist (Zugriffsprüfung
findet keine
ShoppingListEntitymehr, alle Nutzer verlieren Zugriff, Daten bleiben aber unlöschbar in der DBliegen — ein "unreachable but never deleted"-Datenleck-Muster, das
#89'sDeleteCurrentUserAccountCommandHandler-Review bereits einmal für ein ähnliches Szenario gefunden hat).
Mitigation:
PantryEntity.TargetShoppingListIdbekommtOnDelete(DeleteBehavior.Cascade)— löscht derOwner die Einkaufsliste, wird der verknüpfte Vorratsschrank (und all seine Artikel/Kategorien) automatisch mit
gelöscht, kein verwaistes Datenrelikt.
5. Konsum externer Antwortdaten (Open Food Facts)
Risiko: Der Produktname aus der externen API fließt in die eigene DB und wird später im Frontend gerendert
— React escaped standardmäßig alle Text-Interpolationen, daher kein direktes XSS-Risiko, aber Längen-/
Plausibilitäts-Validierung fehlt ohne weiteres.
Mitigation: Der von Open Food Facts gelieferte Name durchläuft denselben
PantryProductName-Vogen-Validator (Längenbegrenzung) wie jeder nutzereingegebene Name — keine Sonderbehandlung, kein Vertrauens-
Bonus für externe Daten.
Ergebnis
Keine blockierenden Findings — alle vier identifizierten Risiken sind bereits in die geplante
Implementierung eingearbeitet (siehe Design-Dokument). Freigabe zur Implementierung.
security_final (
94_pantry_inventory_phase1_security_final.md)Security Final Review:
#94— Vorratsschrank (Pantry), Phase 1Reviewed after implementation, against the actually-shipped code (not the plan) — verifying every
finding from the pre-review (
94_pantry_inventory_phase1_security_prereview.md) was actuallyimplemented, plus a fresh pass over the finished diff.
Pre-Review Findings — Verified Against Code
CheckInPantryProductCommandHandler/CheckOutPantryProductCommandHandlerboth use
ExecuteUpdateAsyncwith in-SQL arithmetic (p.Quantity + 1, geklemmtesp.Quantity > 0 ? p.Quantity - 1 : 0)— kein Read-Modify-Write. Verifiziert gegen echtes Postgres in diesem Zyklus (Docker war für diesen
einen Durchlauf verfügbar) — alle Pantry-Handler-Tests liefen tatsächlich gegen eine reale DB, nicht nur
gegen den Docker-unabhängigen Compile-Check. Kein LINQ-Übersetzungsfehler auf diesem Pfad.
OpenFoodFactsClient.TryLookupProductNamevalidiert den Barcode gegen^\d{8,14}$, bevor er in die Request-URL eingesetzt wird; feste Basis-URL im Code, kein Nutzer-Einflussauf Host/Schema; 5s-Timeout + vollständiges Exception-Swallowing. Bestätigt durch
OpenFoodFactsClientTests.cs(4 Tests, inkl. "never calls out for a barcode that does not look like areal EAN/UPC").
AuthorizePantryAccessQueryHandler/AuthorizePantryOwnerAccessQueryHandlerlesenlive gegen
ShoppingListToUserEntity(kein Caching aufPantryEntity). KeinSetPantryTargetListCommandexistiert — die Ziel-Liste ist nach Anlage unveränderlich, wie geplant.Bestätigt durch
AuthorizePantryAccessQueryHandlerTests/AuthorizePantryOwnerAccessQueryHandlerTests(Zugriff gewährt für Mitglied/Owner der Ziel-Liste, verweigert für Fremde).
PantryEntity.TargetShoppingListIdhatOnDelete(DeleteBehavior.Cascade)(siehe
PantryEntity.cs+ generierte Migration) — verifiziert per FK-Konstellation in der Migration.PantryProductName-Validator (zusätzlich inOpenFoodFactsClientselbst auf 100 Zeichen gekappt, bevorer überhaupt an
PantryProductName.From(...)übergeben wird — doppelte Absicherung, keine Ausnahme fürexterne Daten).
Zusätzliche Findings dieser Runde
(
ShoppingCategoryResolver's Vogen-.Value.ToLower()-Vergleiche übersetzen sich nicht zu SQL) ist einreiner Korrektheits-/Verfügbarkeits-Bug (bricht die Auto-Kategorisierung), kein Sicherheitsproblem — siehe
Team-Coach-Memory für die volle technische Einordnung.
ScanPantryProductBarcodeCommand's Fallback-Pfad (unbekannter Barcode + manueller Name) legt einneues Produkt in der Default-Kategorie an, ohne erneute Autorisierungsprüfung außerhalb der bereits
vorhandenen
AuthorizePantryAccessForCurrentUserQueryauf Command-Ebene — korrekt, kein zusätzlicherCheck nötig, da derselbe Nutzer/dieselbe Anfrage.
GetPantryStockForProductNameQueryist aufAuthorizeShoppingListAccessForCurrentUserQueryautorisiert(nicht Pantry-spezifisch) — korrekt, da die Anfrage vom Kontext der Einkaufsliste ausgeht (Mengen-Dialog),
nicht vom Vorratsschrank; ein Nutzer mit Zugriff auf die Einkaufsliste darf wissen, ob eine damit
verknüpfte Pantry bereits Bestand hat (das ist exakt der Zweck der Kopplung).
Ergebnis
Keine blockierenden Findings. Alle fünf Pre-Review-Punkte sind im finalen Code korrekt umgesetzt und durch
Tests abgedeckt. Freigabe.