Every shopping item now gets a visual like in Bring: a product emoji on a softly tinted category tile. - ItemVisuals.kt: pure client-side classifier. German dictionary (~180 entries) maps item names to emoji + one of 13 categories (produce, drinks, bakery, dairy, meat&fish, frozen, pantry, sweets, household, drugstore, baby, pet, other), each with an accent color. Longest-keyword-first matching so compounds resolve to their most specific entry (Wassermelone is produce, not a drink); juice compounds (Apfelsaft, Orangensaft, ...) are listed explicitly because their fruit prefix is longer than 'saft'. Unknown items fall back to the generic cart on neutral grey. Works fully offline, nothing leaves the device. - ListDetailScreen: emoji tile in every item row, emoji in the autocomplete suggestions dropdown, and a live emoji preview as the text field's leading icon while typing. - ItemVisualsTest: longest-match priority, case/whitespace insensitivity, fallbacks, category coverage. Verified: assembleDebug + unit tests green (JDK 21).
21 KiB
21 KiB
AGENTS.md – Kontext & Fortschritt für KI-Agenten
Diese Datei hält den Projekt-Stand und die Architekturentscheidungen fest, damit ein Agent (z.B. zuhause) sofort weiterarbeiten kann. Sie ist Teil des Repos.
Projekt: Mitbringsl
Local-First Einkaufslisten-App (Bring-Alternative, werbefrei). Backend: Go (net/http, pgx, golang-migrate, go-oidc) als Docker-Container. App: Kotlin + Jetpack Compose, Room, Hilt, WorkManager, offline-first. Deployment: docker-compose (Caddy + backend + migrate + postgres:16), auto-HTTPS.
Vollständiger Plan liegt als genehmigtem Plan zugrunde (siehe Abschnitt "Roadmap").
Getroffene Architekturentscheidungen (verbindlich)
- Backend-Sprache: Go (nicht Rust).
- DB: PostgreSQL (
postgres:16-alpine), Extensionspgcrypto+pg_trgm. - Sync: Local-First, keine Datenverluste – Append-only
op_log(SOURCE OF TRUTH),items/listssind Projektionen; Konfliktlösung per LWW-Register(hlc_ts, client_id)- Tombstones (
deleted_at). HLC = Hybrid Logical Clock.
- Push:
POST /api/lists/{id}/ops(idempotent viaUNIQUE(client_id, client_seq)). - Pull:
GET /api/lists/{id}/ops?since={seq}.
- Tombstones (
- Auth:
- Eigene User: E-Mail/Passwort, Argon2id (PHC-Format).
- OIDC: Google + Generic OIDC. Die App macht den Code+PKCE-Flow selbst
und schickt nur das
id_tokenans Backend (POST /auth/oidc). Backend verifiziert viagithub.com/coreos/go-oidc/v3(Signatur gegen JWKS, iss/aud/exp) und stellt eigene opaque Session-Tokens aus (sessions-Tabelle, SHA-256-Hash gespeichert). - AppAuth-Android bewusst NICHT verwendet (seit 2021 verwaist). Stattdessen: Google via Credential Manager, Generic OIDC via Custom Tabs + eigenes PKCE.
- Vorschläge: aggregiert aus Item-Namen aller User (
item_names-Tabelle, pg_trgm fuzzy search). EndpointGET /api/suggestions?q=. - Post-MVP (umgesetzt):
- Geteilte Listen per Invite-Code:
lists.invite_code(8 Zeichen, UNIQUE, Migration 000002).POST /api/lists/{id}/invite(Owner/Member lesen Code, wird lazy generiert),POST /api/lists/join(beitreten alsmember).GET /api/lists+GET /api/lists/{id}+ Push/Pull-Ops prüfen Membership (owner ODERlist_members-Eintrag). Kein Leave/Revoke-Endpoint, kein Rate-Limiting auf join (bekannt, bewusst offengehalten). - Account optional / Local-Only-Mode: App startet ohne Login direkt in die
Listen (
local_user-Fallback imSessionManager); Sync/Account ist optionaler Einstieg. Teilen/Beitreten erfordert aktive Server-Verbindung (App-gated). - Self-Hosted Server-URL: konfigurierbar in der App (Auth-Screen), umgesetzt
via
DynamicBaseUrlInterceptor(schreibt scheme/host/port pro Request um). - Server-getriebene Auth-Discovery: Öffentlicher Endpoint
GET /api/configverrät der App vor dem Login die verfügbaren Methoden (password_enabled, OIDC-Provider mitdisplay_name, z.B. „Authentik").AUTH_PASSWORD_ENABLED=false→ Register/Login per Passwort → 403 (OIDC-only-Enforcement). App-Login zeigt nur noch die angebotenen Methoden (ConnectView → LoginView). - Profil + Settings-Screen:
GET/PUT /api/me(aktuelldisplay_name),SettingsScreenmit Account & Sync, Profil, Theme (System/Hell/Dunkel, persistiert in SessionManager), „Lokale Daten zurücksetzen" (nur Room-Tabellen, Serverdaten bleiben) und About. - Kritische Sync-/Datensicherheits-Fixes (Android): SyncWorker pullt jetzt
alle getrackten Listen (nicht nur welche mit pending Outbox – vorher kamen
Remote-Änderungen stiller Listen nie an); lokale HLC tickt mit jedem eingehenden
Server-Op (
tick(op.hlcTs)→ LWW korrekt bei Uhr-Drift);list_createinapplyServerOpProjectionergänzt; Projektions-Write + op_log-Insert in einer Room-Transaktion;clientId/clientSeqpersistent in SessionManager; keinfallbackToDestructiveMigrationmehr (Source of Truth darf nie gewispt werden); ProGuard-Keep-Rules für kotlinx.serialization/Retrofit (R8-Releases crashten sonst);network_security_config(Cleartext nur für localhost/10.0.2.2); WorkManager-Default-Initializer deaktiviert (Hilt). - Authentik-OIDC: läuft über den bestehenden Generic-OIDC-Provider
(
provider: "generic"); die App erlaubt manuelle id_token-Eingabe.
- Geteilte Listen per Invite-Code:
- MVP-Scope: keine Echtzeit-Push (nur Periodic-Pull 15 min + Pull-on-Online); keine Web-UI.
Technologie-Stacks (final)
Backend
| Bereich | Wahl |
|---|---|
| Go-Version | 1.26 (go.mod hat go 1.26; Docker-Image golang:1.26-alpine) |
| HTTP | stdlib net/http (Go 1.22+ Routing mit Methoden + Path-Vars) |
| DB-Driver | github.com/jackc/pgx/v5 (pgxpool) |
| Migrationen | github.com/golang-migrate/migrate/v4 + source/iofs (eingebettet via embed.FS) |
| OIDC | github.com/coreos/go-oidc/v3 + golang.org/x/oauth2 |
| Passwörter | Argon2id (golang.org/x/crypto/argon2) |
| Sessions | opaque Tokens (32 B base64url), sessions-Tabelle, SHA-256-Hash |
| Config | github.com/caarlos0/env/v11 (struct-tag env) |
| Logging | log/slog mit NewJSONHandler → stdout |
| Docker | Multi-Stage, CGO_ENABLED=0, gcr.io/distroless/static-debian12:nonroot |
| Queries | direkt mit pgx (sqlc wurde aus Skalierbarkeit bewusst auf später verschoben) |
Android (noch nicht begonnen)
| Bereich | Wahl |
|---|---|
| Build | Kotlin DSL + libs.versions.toml, Single-Module :app |
| Kotlin/AGP/Gradle | Kotlin 2.x (K2), AGP 8.9+, Gradle 8.11+, JDK 17 |
| UI | Compose BOM (2025.x) + Material 3 |
| Arch | MVVM + ViewModel + StateFlow, UDF |
| DB | Room (KSP), Source of Truth via Flow |
| Netzwerk | Retrofit + OkHttp + kotlinx.serialization |
| Sync | WorkManager CoroutineWorker (Outbox-Drain + Cursor-Pull) |
| DI | Hilt (KSP) + hilt-navigation-compose + hilt-work |
| IDs | Client-seitige UUIDs |
| Auth | Google: Credential Manager; Generic OIDC: Custom Tabs + PKCE selbst |
| Suche | OutlinedTextField + DropdownMenu, Room-FTS, Server-Fallback |
| SDK | minSdk 26, compile/target 36 |
Repository-Struktur
mitbringsl/
├── AGENTS.md ← DIES DATEI
├── README.md
├── .gitignore
├── backend/
│ ├── cmd/
│ │ ├── server/main.go # HTTP-Server-Einstieg (verdrahtet cfg/logger/pool/api)
│ │ └── migrate/main.go # Migrations-Runner (iofs-embedded)
│ ├── internal/
│ │ ├── config/config.go # Config (caarlos0/env)
│ │ ├── logging/logging.go # slog JSON-Setup
│ │ ├── store/db.go # pgxpool-Setup
│ │ ├── auth/ # PHASE B – Password + Session + OIDC
│ │ │ ├── password.go # Argon2id im PHC-Format (HashPassword/VerifyPassword)
│ │ │ ├── password_test.go # PHC-Roundtrip-Tests
│ │ │ ├── user.go # UserStore: CreateUser/GetByEmail/GetByID/GetByOIDCSubject/CreateOIDCUser/UpdateDisplayName
│ │ │ ├── session.go # SessionStore: opaque Tokens, SHA-256-Hash, Create/Lookup/Revoke
│ │ │ ├── oidc.go # OIDCService: id_token-Verifikation (JWKS/iss/aud/exp), Provider-Caching
│ │ │ └── pgcode.go # isUniqueViolation (SQLSTATE 23505)
│ │ ├── sync/ # PHASE C – Hybrid Logical Clock
│ │ │ ├── hlc.go # HLC: Tick/Now/After, wall_ms<<16|counter, global mutex
│ │ │ └── hlc_test.go # HLC Unit-Tests (monoton, kausal, keine Duplikate)
│ │ ├── store/ # PHASE C – Store-Schicht
│ │ │ ├── db.go # pgxpool-Setup
│ │ │ ├── opstore.go # AppendOps (idempotent, LWW-Projektion), PullOps (cursor)
│ │ │ ├── liststore.go # CreateList/GetLists/GetList/GetInviteCode/JoinByInviteCode
│ │ │ ├── liststore_test.go # Invite/Join/Membership-Integrationstests (brauchen TEST_DATABASE_URL)
│ │ │ ├── itemstore.go # GetItems
│ │ │ └── suggeststore.go # Search (pg_trgm fuzzy)
│ │ └── httpapi/
│ │ ├── api.go # API-Objekt + Router (alle Routen aktiv)
│ │ ├── api_test.go # E2E-HTTP-Tests: Share/Join/Sync + 401-Gating (TEST_DATABASE_URL)
│ │ ├── render.go # JSON-Render + Problem + Fehler-Sentinale + decodeJSON
│ │ ├── middleware.go # requestID/logging/recover/cors + Chain
│ │ ├── health.go # /healthz + /readyz
│ │ ├── auth.go # Register/Login/Logout/OIDC-Handler + RequireAuth-Middleware (403 wenn Passwort-Login deaktiviert)
│ │ ├── config.go # GET /api/config (öffentlich): verfügbare Auth-Methoden für die App
│ │ ├── me.go # GET/PUT /api/me (eigenes Profil, display_name)
│ │ ├── lists.go # GET/POST /api/lists, GET /api/lists/{id}, POST /invite, POST /join
│ │ ├── ops.go # POST/GET /api/lists/{id}/ops (Push/Pull, Membership-Check)
│ │ └── suggest.go # GET /api/suggestions?q=
│ ├── migrations/
│ │ ├── embed.go # //go:embed *.sql
│ │ ├── 000001_init_schema.up.sql # users/sessions/lists/list_members/items/op_log/item_names
│ │ ├── 000001_init_schema.down.sql
│ │ ├── 000002_add_invite_code.up.sql # lists.invite_code UNIQUE + Backfill owner→list_members
│ │ └── 000002_add_invite_code.down.sql
│ ├── Dockerfile # Multi-Stage, baut server + migrate
│ ├── .dockerignore
│ ├── go.mod / go.sum
├── deploy/
│ ├── docker-compose.yml # caddy + backend + migrate + db (CADDY_HTTP/HTTPS_PORT)
│ ├── Caddyfile # standalone: auto-HTTPS (Let's Encrypt)
│ ├── Caddyfile.behind-proxy # hinter externem Reverse Proxy: auto_https off, trusted_proxies
│ ├── .env.example # alle env-Vars dokumentiert inkl. Caddy-Ports
│ └── db/init/001_extensions.sql # CREATE EXTENSION pgcrypto, pg_trgm
├── docs/
│ ├── ARCHITECTURE.md
│ ├── SYNC.md # HLC/LWW/op_log
│ └── API.md # REST Specs (inkl. join/invite)
└── android/ # Single-Module :app (Compose M3, Room, Hilt)
└── app/src/main/java/com/example/mitbringsl/
├── data/auth/ # SessionManager (Local-User-Fallback, Server-URL, clientId/clientSeq, Theme-Pref)
├── data/local/ # Room DB (lists, items, op_log), DAOs (LWW upsert)
├── data/remote/ # Retrofit-API, DTOs, DynamicBaseUrlInterceptor, AuthInterceptor
├── data/repository/ # ShoppingRepository, AuthRepository
├── data/sync/ # HybridLogicalClock, SyncManager, SyncWorker (pullt ALLE Listen, HLC-Tick pro Server-Op)
├── di/ # Hilt Modules
├── ui/auth/ # AuthScreen (ConnectView: Server-URL + /api/config-Discovery, dann LoginView)
├── ui/lists/ # ListsScreen/ViewModel (Join-Dialog, Offline-Banner, Settings-Zahnrad)
├── ui/detail/ # ListDetailScreen/ViewModel (Share-Button, Autocomplete), ItemVisuals (Emoji-/Kategorie-Icons)
├── ui/settings/ # SettingsScreen/ViewModel (Account, Profil, Theme, Reset, About)
└── util/ # NetworkMonitor
Roadmap / Fortschritt
Legende: ✅ erledigt · 🚧 in Arbeit · ⬜ offen
- ✅ Repo-Struktur +
.gitignore+README.md+git init(Branchmain) - ✅ Phase A – Backend-Fundament: Config, slog, pgxpool,
/healthz+/readyz, Server-Main. - ✅ Phase A – Migrationen: vollständiges Init-Schema (up+down) +
migrate-Binary (iofs). - ✅ Phase A – Docker: Dockerfile (Go 1.26 → distroless nonroot), docker-compose
(caddy/backend/migrate/db), Caddyfile,
.env.example. Verifiziert: Image baut, beide Binaries laufen im Container (Smoke-Test OK). - ✅ Phase B – Auth (Password): Argon2id im PHC-Format +
UserStore(Create/GetByEmail/GetByID) +SessionStore(opaque Tokens, SHA-256-Hash, Create/Lookup/Revoke) + HandlerRegister/Login/Logout+RequireAuth-Middleware. Verifiziert:go test ./internal/auth/...grün, E2E-Smoke-Test gegen echtes PostgreSQL via Docker (Register/Login/Logout/Duplicate/Short-PW/Wrong-PW alle korrekt). - ✅ Phase B – OIDC:
OIDCService(go-oidc v3, JWKS-Signatur, iss/aud/exp, Provider-Caching) +POST /auth/oidc(find-or-create User via(oidc_issuer, oidc_subject), reusesissueSession) + Config-Validierung (enabled → client_id/issuer Pflicht). Verifiziert: E2E-Flow gegen lokalen Mock-IdP (Discovery → JWKS → signiertes id_token → User angelegt → 2. Login findet gleichen User → tampered Token → 401). Alle Fehlerpfade geprüft (disabled→400, unknown provider→400, missing fields→400, invalid→401). - ✅ Phase C – Sync-Kern: HLC (
internal/sync/hlc.go),op_log-Append idempotent mit LWW-Projektion (items/lists) + Tombstones.internal/store/opstore.go. Verifiziert:go build/vet/test ./...grün. HLC-Tests (monoton, kausal, keine Duplikate). - ✅ Phase C – Endpoints:
GET/POST /api/lists,GET /api/lists/{id},POST/GET /api/lists/{id}/ops(Push idempotent + Pull mit Cursor?since=). - ✅ Phase C – Suggestions:
suggeststore.go(pg_trgm fuzzy),GET /api/suggestions?q=,upsertItemNamein Projektion (innerhalb Push-Transaktion). - ✅ Phase C – Caddy:
Caddyfile.behind-proxy(auto_https off, trusted_proxies),CADDY_HTTP_PORT/CADDY_HTTPS_PORTin docker-compose. - ✅ Phase D – Android-Fundament: Gradle (Kotlin DSL, Version Catalog, Hilt/KSP, Compose BOM, Room, Retrofit, WorkManager), Theme, Nav.
Verifiziert:
./gradlew assembleDebugund./gradlew testerfolgreich. - ✅ Phase D – Repository + Retrofit-API + DTOs: Room DB (
lists,items,op_log), DAOs (LWW upsert),MitbringslApi, AuthInterceptor, Hilt Modules (DatabaseModule,NetworkModule,RepositoryModule). - ✅ Phase D – Login-Screen:
AuthScreen(E-Mail/Passwort Login + Registrierung),AuthViewModel,AuthRepository,SessionManager. - ✅ Phase E – SyncEngine:
HybridLogicalClock(client-seitig),SyncWorker(CoroutineWorkerOutbox Drain + Server Cursor Pull),SyncManager(15 min periodisch + Sofort-Sync),ShoppingRepository(local-first mutations via Room +op_log). - ✅ Phase E – Listen-Übersicht + Detail + AddItemBar:
ListsScreen&ListsViewModel,ListDetailScreen&ListDetailViewModel(Sectioning erledigt/offen, Autocomplete Suggestions dropdown), Compose Navigation 3. - ✅ Phase F – Polish:
NetworkMonitor(ConnectivityManager StateFlow), Offline-Banner inListsScreen, Material 3 Empty States. - ✅ Phase F – README + docs:
README.mdQuickstart,docs/ARCHITECTURE.md,docs/SYNC.md(HLC/LWW/op_log),docs/API.md(REST Specs). - ✅ Post-MVP – Account optional: App startet ohne Login direkt in die Listen (Local-Only-Default), Sync/Account optional.
- ✅ Post-MVP – Self-Hosted Server-URL + Authentik: Server-URL in der App
konfigurierbar (
DynamicBaseUrlInterceptor), Authentik über Generic-OIDC. - ✅ Post-MVP – Geteilte Listen: Invite-Codes (Migration 000002), Join/Invite-
Endpoints, Membership-Checks in
GetLists/GetList/Push/Pull. - ✅ Post-MVP – Tests für Share/Join:
liststore_test.go(Store-Level) +api_test.go(E2E über HTTP): Invite/Join/Idempotenz/Rollen/Access-Control (Fremde → 404 auf allen Listen-Endpoints), Cross-Member-Sync via op_log, 401-Gating aller geschützten Routen. Beide Dateien skippen ohneTEST_DATABASE_URL(Docker-Rezept im Dateikopf). - ✅ Post-MVP – Kritische Android-Fixes (Sync/Build/Data-Safety): SyncWorker pullt alle getrackten Listen, HLC-Tick pro Server-Op, Room-Transaktionen um Projektion+Outbox, persistente clientId/clientSeq, kein destructive Migration, ProGuard-Rules, Network-Security-Config, Hilt-WorkManager-Init.
- ✅ Post-MVP – Auth-Discovery + OIDC-only:
GET /api/config(öffentlich),AUTH_PASSWORD_ENABLED(403-Enforcement),OIDC_GENERIC_DISPLAY_NAME, App-Connect-Flow (Server-URL → Methoden-Discovery → LoginView). - ✅ Post-MVP – Settings-Screen + Profil:
GET/PUT /api/me(UserStore.UpdateDisplayName),SettingsScreen/SettingsViewModel(Account&Sync, Profil, Theme, Reset, About), Theme-Pref in SessionManager. - ✅ Post-MVP – Produkt-Icons (Bring-Style):
ui/detail/ItemVisuals.kt– rein clientseitiger Classifier: deutsches Wörterbuch (~180 Einträge) mappt Item-Namen auf Emoji + Kategorie (13 Kategorien mit Akzentfarbe). Longest-Keyword-Match („Apfelsaft" → Getränk, nicht Obst; Saft-Komposita explizit gelistet), unbekannte Items → 🛒/Sonstiges. UI: Icon-Kachel in ItemRow, Icons im Autocomplete-Dropdown, Live-Emoji im Eingabefeld (leadingIcon). Unit-Tests inItemVisualsTest.kt. - ✅ Post-MVP – Build hinter restriktiven Proxies: foojay-Resolver &
jvmToolchain auskommentiert, Gradle-Wrapper 9.6.1,
.ssl-work/ignoriert.
Wo genau weitermachen?
MVP + Post-MVP-Features sind abgeschlossen. Das Backend ist feature-complete für den aktuellen Scope. Offene Punkte, geordnet nach Nutzen:
- Join-Rate-Limiting:
POST /api/lists/joinhat kein Rate-Limit; 8-Zeichen- Code ist brute-force-bar (62⁸ ≈ 2×10¹⁴, aber trotzdem). Z.B. pro Session/IP drosseln oder fehlerhafte Joins verzögern. - Leave-List / Member-Removal / Code-Revocation: Es gibt keinen Endpoint,
eine Liste zu verlassen, Mitglieder zu entfernen oder einen Invite-Code zu
rotieren.
list_members-Rollen (owner/member) werden bisher kaum genutzt. - Invite-Code-Optimierung: Codes sind UUID-Präfixe (
uuid[:8]), nicht kollisionsresistent geprüft (UNIQUE-Constraint fängt es, aber CreateList kann dann fehlschlagen). Besser: kryptografisches Alphabet ohne Verwechslungsbuchstaben. - Tests für neuere Endpoints:
GET /api/config,GET/PUT /api/meund dasAUTH_PASSWORD_ENABLED=false-Verhalten (403) haben noch keine Testabdeckung – das Test-Setup dafür steht inapi_test.gobereit. - Echtzeit-Push (langfristig): Periodic-Pull 15 min ist MVP; SSE/WebSocket für sofortige Updates wäre der nächste Schritt.
- Android-Tests: UI/ViewModel-Tests fehlen fast komplett (nur 2 Stock-Tests).
Wichtige technische Notizen / Fallstricke (bereits gelöst)
- golang-migrate
iofs-Pfad: Der Import istgithub.com/golang-migrate/migrate/v4/source/iofs(MIT/source/). Die Pfade ohne/source/gibt es nicht mehr → Build-Fehler. - Go-Version:
pgx/v5 v5.10.0braucht Go ≥ 1.25, deshalb Go 1.26 (lokal + Docker-Imagegolang:1.26-alpine).go.modhatgo 1.26. - Docker-Build auf Windows/Docker Desktop:
--network hostim Build-Container funktioniert NICHT (WSL2-NAT). Für Tests: echtesdocker network create+ Container-Namen nutzen. - Compose braucht zwingend
POSTGRES_PASSWORD(.envoder Env), dadb.environmentmit${POSTGRES_PASSWORD:?...}-Assertion vor dem Build validiert wird. - Healthcheck im
backend-Service wurde entfernt, weil distroless/static keinwget/curlenthält. Später: eigenes Binary, das/healthzper Go-HTTP prüft, oderhealthchecküber Caddy/extern. - SQL-Queries werden direkt mit pgx geschrieben (kein sqlc im MVP), da sqlc lokal nicht installiert ist und ein extra Code-Gen-Schritt nötig wäre. Bei Bedarf später problemlos nachrüstbar.
Build- & Test-Befehle
# Backend lokal bauen
cd backend && go build ./... && go vet ./...
# Unit-Tests (ohne DB)
cd backend && go test ./...
# Integrationstests (Store + E2E-HTTP) gegen Docker-PostgreSQL:
docker run -d --name mitbringsl-test-pg -e POSTGRES_USER=app \
-e POSTGRES_PASSWORD=testpw -e POSTGRES_DB=appdb -p 55432:5432 postgres:16-alpine
cd backend && TEST_DATABASE_URL="postgres://app:testpw@localhost:55432/appdb?sslmode=disable" \
go test ./...
docker rm -f mitbringsl-test-pg # danach aufräumen
# Backend-Image bauen
cd backend && docker build -t mitbringsl-backend:test .
# Komplettes Stack starten (braucht deploy/.env)
cd deploy && cp .env.example .env && docker compose up -d --build
# Android bauen
cd android && ./gradlew assembleDebug && ./gradlew test
Git-Status
- Branch
main, Remoteoriginkonfiguriert (git.dietzlabs.net). - MVP (Phase A–F) + Post-MVP-Features (optionale Accounts, Server-URL-Config, geteilte Listen) committed und gepusht.