mitbringsl/AGENTS.md
Tronax 37cbb816e5
Feature: Bring-style emoji & category icons for items
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).
2026-08-22 10:16:37 +02:00

21 KiB
Raw Blame History

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), Extensions pgcrypto + pg_trgm.
  • Sync: Local-First, keine Datenverluste Append-only op_log (SOURCE OF TRUTH), items/lists sind Projektionen; Konfliktlösung per LWW-Register (hlc_ts, client_id)
    • Tombstones (deleted_at). HLC = Hybrid Logical Clock.
    • Push: POST /api/lists/{id}/ops (idempotent via UNIQUE(client_id, client_seq)).
    • Pull: GET /api/lists/{id}/ops?since={seq}.
  • 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_token ans Backend (POST /auth/oidc). Backend verifiziert via github.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). Endpoint GET /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 als member). GET /api/lists + GET /api/lists/{id} + Push/Pull-Ops prüfen Membership (owner ODER list_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 im SessionManager); 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/config verrät der App vor dem Login die verfügbaren Methoden (password_enabled, OIDC-Provider mit display_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 (aktuell display_name), SettingsScreen mit 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_create in applyServerOpProjection ergänzt; Projektions-Write + op_log-Insert in einer Room-Transaktion; clientId/clientSeq persistent in SessionManager; kein fallbackToDestructiveMigration mehr (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.
  • 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 (Branch main)
  • 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) + Handler Register/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), reuses issueSession) + 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=, upsertItemName in Projektion (innerhalb Push-Transaktion).
  • Phase C Caddy: Caddyfile.behind-proxy (auto_https off, trusted_proxies), CADDY_HTTP_PORT/CADDY_HTTPS_PORT in docker-compose.
  • Phase D Android-Fundament: Gradle (Kotlin DSL, Version Catalog, Hilt/KSP, Compose BOM, Room, Retrofit, WorkManager), Theme, Nav. Verifiziert: ./gradlew assembleDebug und ./gradlew test erfolgreich.
  • 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 (CoroutineWorker Outbox 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 in ListsScreen, Material 3 Empty States.
  • Phase F README + docs: README.md Quickstart, 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 ohne TEST_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 in ItemVisualsTest.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:

  1. Join-Rate-Limiting: POST /api/lists/join hat 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.
  2. 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.
  3. 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.
  4. Tests für neuere Endpoints: GET /api/config, GET/PUT /api/me und das AUTH_PASSWORD_ENABLED=false-Verhalten (403) haben noch keine Testabdeckung das Test-Setup dafür steht in api_test.go bereit.
  5. Echtzeit-Push (langfristig): Periodic-Pull 15 min ist MVP; SSE/WebSocket für sofortige Updates wäre der nächste Schritt.
  6. 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 ist github.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.0 braucht Go ≥ 1.25, deshalb Go 1.26 (lokal + Docker-Image golang:1.26-alpine). go.mod hat go 1.26.
  • Docker-Build auf Windows/Docker Desktop: --network host im Build-Container funktioniert NICHT (WSL2-NAT). Für Tests: echtes docker network create + Container-Namen nutzen.
  • Compose braucht zwingend POSTGRES_PASSWORD (.env oder Env), da db.environment mit ${POSTGRES_PASSWORD:?...}-Assertion vor dem Build validiert wird.
  • Healthcheck im backend-Service wurde entfernt, weil distroless/static kein wget/curl enthält. Später: eigenes Binary, das /healthz per Go-HTTP prüft, oder healthcheck ü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, Remote origin konfiguriert (git.dietzlabs.net).
  • MVP (Phase AF) + Post-MVP-Features (optionale Accounts, Server-URL-Config, geteilte Listen) committed und gepusht.