Kenzo Signer
Back to Overview
In Progress

Storli - Ein modernes Inventar- und Einkaufsmanagementsystem

Ein vollständiges Full-Stack-Projekt mit React/Next.js Frontend, Rust Backend und PostgreSQL - Von der Architektur bis zur Produktion. Dies ist ein Zwischen-Update eines laufenden Projekts.

Next.js 16React 19TypeScriptRustAxumPostgreSQLDockerWoodpecker CITailwindCSS 4JWTnext-intl

Storli - Ein modernes Inventar- und Einkaufsmanagementsystem

Status: Dieses Projekt ist derzeit in aktiver Entwicklung. Dies ist ein Zwischen-Update, das den aktuellen Stand der Implementierung dokumentiert.

Einleitung

Storli ist ein umfassendes Inventar- und Einkaufsmanagementsystem, das entwickelt wurde, um Gruppen bei der Verwaltung von Produktkatalogen, Einkaufswagen und Lagerbeständen zu unterstützen. Das Projekt kombiniert modernste Web-Technologien mit einem robusten Backend und einer durchdachten Infrastruktur.

Was macht Storli besonders?

  • Gruppenbasierte Zusammenarbeit: Mehrere Benutzer können gemeinsam Einkäufe planen und Lagerbestände verwalten
  • Barcode-Scanning: Native Integration der BarcodeDetector API für schnelles Produkterkennung
  • Mehrsprachigkeit: Vollständige Unterstützung für Deutsch und Englisch
  • Moderne Technologien: React 19, Next.js 16, Rust, PostgreSQL
  • Self-Hosted: Vollständige Kontrolle über Daten und Deployment
  • Production-Ready: CI/CD Pipeline mit automatischem Deployment auf Synology NAS

Projektübersicht

Kernfeatures

🔐 Authentifizierung und Benutzerverwaltung

  • JWT-basierte Authentifizierung mit 30-tägiger Token-Gültigkeit
  • Argon2-Passwort-Hashing für maximale Sicherheit
  • Rollenbasierte Zugriffskontrolle (Admin, User)
  • Passwort- und E-Mail-Änderung
  • Kontolöschung mit Datenbereinigung

👥 Gruppenverwaltung

  • Erstellen und Verwalten von Gruppen
  • Einladungssystem mit 7-tägiger Token-Gültigkeit
  • Rollen innerhalb von Gruppen: Owner, Admin, Member
  • Mehrfache Gruppenmitgliedschaft pro Benutzer
  • Automatische Benachrichtigungen bei Gruppenaktivitäten

📦 Produktkatalog

  • Zentrale Produktverwaltung mit Unternehmenszuordnung
  • Produktbilder in der Datenbank gespeichert
  • Barcode-Zuordnung (EAN-13, EAN-8, UPC-A, UPC-E, Code-128)
  • Preise in Rappen/Cents mit CHF-Formatierung
  • Admin-kontrollierte Produkterstellung

🛒 Einkaufswagen-System

  • Mehrere benannte Warenkörbe pro Gruppe
  • Artikel gruppiert nach Unternehmen
  • Mindestbestellwert-Tracking
  • Mengenänderungen mit Audit-Trail (wer hat was hinzugefügt)
  • Persistente Warenkörbe über Sitzungen hinweg

📊 Lagerverwaltung

  • Gruppenbezogene Lagerorte
  • Produktzuordnung zu Lagerorten
  • Primärer Lagerort pro Produkt
  • Inventarbewegungen (Ein-/Ausgänge)
  • Notizen zu Produktstandorten

📷 Barcode-Scanning

  • Native BarcodeDetector API Integration
  • Unterstützung für multiple Barcode-Formate
  • Manueller Barcode-Eingabe-Fallback
  • Barcode-zu-Produkt-Verlinkung für unbekannte Codes
  • Instant-Produktsuche nach Scan

📈 Empfehlungssystem

  • Automatische Produktvorschläge basierend auf Kaufhistorie
  • "Nächstes Fälligkeitsdatum" Berechnung
  • Visuelle Dringlichkeitsindikatoren
  • Zeitbasierte Analysen (seit letztem Kauf)

🔔 Benachrichtigungssystem

  • Echtzeit-Benachrichtigungen (Polling alle 30 Sekunden)
  • Benachrichtigungstypen:
    • Gruppeneinladungen
    • Produktempfehlungen
    • Mitgliederänderungen
    • Rollenänderungen
  • Granulare Benachrichtigungseinstellungen
  • Ungelesene-Zähler

⚙️ Benutzereinstellungen

  • Erscheinungsbild: Theme (hell/dunkel/System), Sprache, kompakte Ansicht
  • Benachrichtigungen: Individuelle Toggles für jeden Benachrichtigungstyp
  • Privatsphäre: Profilsichtbarkeit, Kaufhistorie-Sichtbarkeit
  • Shopping: Standard-Sortierung, Budget-Warnungen
  • Automatische Erstellung bei Registrierung
  • Geräteübergreifende Synchronisation

Technologie-Stack

Frontend: Next.js + React

// storli_frontend/package.json
{
  "dependencies": {
    "next": "^16.0.10",
    "react": "19.2.0",
    "react-dom": "19.2.0",
    "next-intl": "^4.6.0",
    "lucide-react": "^0.555.0",
    "class-variance-authority": "^0.7.1",
    "clsx": "^2.1.1",
    "tailwind-merge": "^3.4.0"
  }
}

Warum Next.js 16?

  • App Router: Moderne, dateisystembasierte Routing mit Server Components
  • React 19: Neueste React-Features wie Server Actions und Transitions
  • Optimierte Performance: Automatisches Code-Splitting und Image Optimization
  • TypeScript-First: Vollständige TypeScript-Integration

Design-System:

  • TailwindCSS 4: Utility-First CSS mit Dark Mode Support
  • lucide-react: Konsistente Icon-Library
  • CVA (class-variance-authority): Type-safe component variants

Internationalisierung:

// storli_frontend/i18n.ts
import { getRequestConfig } from 'next-intl/server';

export default getRequestConfig(async ({ locale }) => ({
  messages: (await import(`./messages/${locale}.json`)).default
}));

Backend: Rust + Axum

# storli_backend/Cargo.toml
[dependencies]
axum = { version = "0.7", features = ["json", "multipart"] }
sqlx = { version = "0.7", features = ["runtime-tokio", "postgres", "uuid", "chrono", "macros", "migrate"] }
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
jsonwebtoken = "9"
argon2 = "0.5"
tower-http = { version = "0.5", features = ["cors", "fs", "limit"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
governor = "0.6"

Warum Rust + Axum?

  • Performance: Rust bietet C++-ähnliche Performance ohne Garbage Collection
  • Speichersicherheit: Compiler garantiert Memory Safety ohne Runtime-Overhead
  • Async/Await: Tokio Runtime für hochperformante async Operationen
  • Type Safety: SQLx bietet compile-time checked SQL queries
  • Axum Framework: Modernes, ergonomisches Web-Framework basierend auf Tower

Sicherheits-Features:

// Argon2 Password Hashing
let argon2 = Argon2::default();
let password_hash = argon2
    .hash_password(password.as_bytes(), &salt)?
    .to_string();

// JWT Token Generation
let claims = Claims {
    sub: user_id.to_string(),
    exp: (Utc::now() + Duration::days(30)).timestamp() as usize,
};
let token = encode(&Header::default(), &claims, &encoding_key)?;

Datenbank: PostgreSQL

Schema-Design:

-- Benutzer mit Rollen
CREATE TABLE users (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    email TEXT UNIQUE NOT NULL,
    password_hash TEXT NOT NULL,
    role TEXT NOT NULL DEFAULT 'user',
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- Gruppen
CREATE TABLE groups (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    name TEXT NOT NULL,
    created_by UUID NOT NULL REFERENCES users(id),
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- Gruppenmitglieder mit Rollen
CREATE TABLE group_members (
    group_id UUID NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
    user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    role TEXT NOT NULL, -- 'owner', 'admin', 'member'
    joined_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    PRIMARY KEY (group_id, user_id)
);

-- Produkte
CREATE TABLE products (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    company_id UUID NOT NULL REFERENCES companies(id),
    name TEXT NOT NULL,
    description TEXT,
    price_cents BIGINT,
    image_url TEXT,
    barcode TEXT UNIQUE,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- Gruppen-Warenkörbe
CREATE TABLE group_carts (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    group_id UUID NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
    name TEXT NOT NULL,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    UNIQUE(group_id, name)
);

-- Warenkorb-Artikel
CREATE TABLE group_cart_items (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    cart_id UUID NOT NULL REFERENCES group_carts(id) ON DELETE CASCADE,
    product_id UUID NOT NULL REFERENCES products(id) ON DELETE CASCADE,
    quantity INTEGER NOT NULL CHECK (quantity > 0),
    added_by UUID REFERENCES users(id) ON DELETE SET NULL,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

Warum PostgreSQL?

  • ACID-Garantien: Zuverlässige Transaktionen
  • JSON/JSONB: Flexible Metadaten-Speicherung
  • UUIDs: Verteilte ID-Generierung
  • Constraints: Datenintegrität auf Datenbankebene
  • Migrationen: SQLx bietet integriertes Migration-Management

Architektur und Design-Entscheidungen

Frontend-Architektur

Context-basierte State-Verwaltung

// lib/group-context.tsx
export function GroupProvider({ children }: { children: React.ReactNode }) {
  const [groups, setGroups] = useState<Group[]>([]);
  const [activeGroupId, setActiveGroupId] = useState<string | null>(null);

  useEffect(() => {
    // Load groups from API
    loadGroups();
    // Restore active group from localStorage
    const saved = localStorage.getItem('activeGroupId');
    if (saved) setActiveGroupId(saved);
  }, []);

  useEffect(() => {
    // Persist active group to localStorage
    if (activeGroupId) {
      localStorage.setItem('activeGroupId', activeGroupId);
    }
  }, [activeGroupId]);

  const value = {
    groups,
    activeGroupId,
    setActiveGroupId,
    refreshGroups: loadGroups,
  };

  return (
    <GroupContext.Provider value={value}>
      {children}
    </GroupContext.Provider>
  );
}

Design-Entscheidung: Context API statt Redux

  • Weniger Boilerplate
  • Ausreichend für die App-Größe
  • Native React-Lösung
  • Einfacher zu verstehen und zu warten

API-Abstraktionsschicht

// lib/api.ts
export async function api<T = any>(
  endpoint: string,
  options?: RequestInit
): Promise<T> {
  const url = `${process.env.NEXT_PUBLIC_API_BASE}${endpoint}`;

  try {
    const response = await fetch(url, {
      ...options,
      headers: {
        'Content-Type': 'application/json',
        ...options?.headers,
      },
      credentials: 'include', // Send cookies
      cache: 'no-store', // Always fetch fresh data
    });

    if (!response.ok) {
      const error = await response.json();
      throw new Error(error.message || 'Request failed');
    }

    return await response.json();
  } catch (error) {
    // Dispatch maintenance mode event on network errors
    if (error instanceof TypeError) {
      window.dispatchEvent(new CustomEvent('maintenanceMode', {
        detail: { active: true }
      }));
    }
    throw error;
  }
}

// Helper methods
export const post = <T>(endpoint: string, data: any) =>
  api<T>(endpoint, { method: 'POST', body: JSON.stringify(data) });

export const put = <T>(endpoint: string, data: any) =>
  api<T>(endpoint, { method: 'PUT', body: JSON.stringify(data) });

export const del = <T>(endpoint: string) =>
  api<T>(endpoint, { method: 'DELETE' });

Vorteile:

  • Zentrale Fehlerbehandlung
  • Maintenance Mode Detection
  • Type-Safety durch Generics
  • Cookie-basierte Auth automatisch

Dark Mode Implementation

// app/layout.tsx - Inline script zur Flash-Vermeidung
<script dangerouslySetInnerHTML={{
  __html: `
    (function() {
      const theme = localStorage.getItem('theme') || 'system';
      if (theme === 'dark' ||
          (theme === 'system' &&
           window.matchMedia('(prefers-color-scheme: dark)').matches)) {
        document.documentElement.classList.add('dark');
      }
    })();
  `
}} />

Design-Entscheidung: Inline Script statt useEffect

  • Vermeidet Flash of Unstyled Content (FOUC)
  • Theme wird vor erstem Render angewendet
  • System-Präferenz-Unterstützung

Backend-Architektur

Middleware-Stack

// main.rs
let app = Router::new()
    // Public routes
    .route("/api/health", get(health_check))
    .route("/api/auth/register", post(register))
    .route("/api/auth/login", post(login))

    // Protected routes
    .route("/api/auth/me", get(me))
    .route("/api/groups", get(list_groups).post(create_group))
    .route_layer(middleware::from_fn_with_state(
        state.clone(),
        auth_middleware
    ))

    // Admin routes with stricter rate limiting
    .nest("/api/admin", admin_routes())
    .route_layer(middleware::from_fn_with_state(
        state.clone(),
        admin_middleware
    ))
    .layer(governor::governor_layer_with_config(
        GovernorConfigBuilder::default()
            .per_second(30)
            .burst_size(30)
            .finish()
            .unwrap(),
    ))

    // Global middleware
    .layer(
        CorsLayer::new()
            .allow_origin(allowed_origins)
            .allow_credentials(true)
            .allow_methods([Method::GET, Method::POST, Method::PUT, Method::DELETE])
            .allow_headers([CONTENT_TYPE, AUTHORIZATION]),
    )
    .layer(
        TraceLayer::new_for_http()
            .make_span_with(|_req: &Request<_>| {
                tracing::info_span!("http_request")
            }),
    )
    .with_state(state);

Layer-Hierarchie:

  1. CORS + Tracing (äußerste Schicht)
  2. Global Rate Limiting (100 req/min)
  3. Authentication Middleware (JWT-Validierung)
  4. Admin Middleware + Stricter Rate Limiting (30 req/min)

Authentifizierung-Middleware

// auth.rs
pub async fn auth_middleware(
    State(state): State<AppState>,
    mut req: Request,
    next: Next,
) -> Result<Response, StatusCode> {
    // Extract token from cookie or Authorization header
    let token = extract_token(&req)?;

    // Decode and validate JWT
    let claims = decode::<Claims>(
        &token,
        &DecodingKey::from_secret(state.jwt_secret.as_bytes()),
        &Validation::default(),
    )
    .map_err(|_| StatusCode::UNAUTHORIZED)?;

    // Parse user_id from claims
    let user_id = Uuid::parse_str(&claims.sub)
        .map_err(|_| StatusCode::UNAUTHORIZED)?;

    // Verify user exists in database
    let user_exists: bool = sqlx::query_scalar(
        "SELECT EXISTS(SELECT 1 FROM users WHERE id = $1)"
    )
    .bind(user_id)
    .fetch_one(&state.db)
    .await
    .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;

    if !user_exists {
        return Err(StatusCode::UNAUTHORIZED);
    }

    // Inject user_id into request extensions
    req.extensions_mut().insert(user_id);

    Ok(next.run(req).await)
}

Sicherheitsmaßnahmen:

  1. Token-Validierung (Signatur, Ablauf)
  2. Datenbankverifikation (User existiert)
  3. Request Extensions für nachfolgende Handler

Rate Limiting

// Globales Rate Limiting mit Governor
.layer(
    ServiceBuilder::new()
        .layer(governor::governor_layer_with_config(
            GovernorConfigBuilder::default()
                .per_second(100)
                .burst_size(100)
                .finish()
                .unwrap(),
        ))
)

// Login-spezifisches Rate Limiting mit In-Memory-Tracking
lazy_static::lazy_static! {
    static ref LOGIN_ATTEMPTS: Arc<Mutex<HashMap<String, LoginAttempt>>> =
        Arc::new(Mutex::new(HashMap::new()));
}

struct LoginAttempt {
    count: u32,
    locked_until: Option<DateTime<Utc>>,
}

pub async fn login(
    State(state): State<AppState>,
    Json(credentials): Json<LoginRequest>,
) -> Result<Json<LoginResponse>, (StatusCode, Json<ErrorResponse>)> {
    // Check if account is locked
    let mut attempts = LOGIN_ATTEMPTS.lock().await;
    if let Some(attempt) = attempts.get(&credentials.email) {
        if let Some(locked_until) = attempt.locked_until {
            if Utc::now() < locked_until {
                return Err((
                    StatusCode::TOO_MANY_REQUESTS,
                    Json(ErrorResponse {
                        error: "ACCOUNT_LOCKED".to_string(),
                        message: "Zu viele fehlgeschlagene Login-Versuche".to_string(),
                    }),
                ));
            }
        }
    }

    // ... authentication logic ...

    // Reset attempts on successful login
    attempts.remove(&credentials.email);
}

Multi-Layer Rate Limiting:

  • Global: 100 req/min pro IP (DDoS-Schutz)
  • Login: 5 Versuche / 15 Min pro E-Mail (Brute-Force-Schutz)
  • Admin: 30 req/min pro IP (Zusätzlicher Schutz für sensible Endpoints)

Audit Logging

// security.rs
pub async fn log_security_event(
    db: &Pool<Postgres>,
    user_id: Option<Uuid>,
    event_type: &str,
    action: &str,
    ip_address: Option<String>,
    metadata: Option<serde_json::Value>,
) {
    // Tracing-Log (JSON-Format für Monitoring)
    tracing::warn!(
        event_type = event_type,
        user_id = ?user_id,
        action = action,
        ip_address = ?ip_address,
        "Security event logged"
    );

    // Datenbank-Log (persistente Audit-Trail)
    let _ = sqlx::query(
        "INSERT INTO audit_logs
         (user_id, event_type, action, ip_address, metadata)
         VALUES ($1, $2, $3, $4, $5)"
    )
    .bind(user_id)
    .bind(event_type)
    .bind(action)
    .bind(ip_address)
    .bind(metadata)
    .execute(db)
    .await;
}

Logged Events:

  • auth_failure: Fehlgeschlagene Logins
  • login_success: Erfolgreiche Logins
  • admin_action: Admin-Operationen (Product/Company Create/Delete)
  • unauthorized_access: Zugriffsverletzungen
  • rate_limit_exceeded: Rate Limit Überschreitungen

Gruppenzugriffskontrolle

// groups.rs
pub async fn verify_group_member(
    db: &Pool<Postgres>,
    group_id: Uuid,
    user_id: Uuid,
) -> Result<String, StatusCode> {
    let role: Option<String> = sqlx::query_scalar(
        "SELECT role FROM group_members
         WHERE group_id = $1 AND user_id = $2"
    )
    .bind(group_id)
    .bind(user_id)
    .fetch_optional(db)
    .await
    .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;

    role.ok_or(StatusCode::UNAUTHORIZED)
}

pub async fn verify_group_admin(
    db: &Pool<Postgres>,
    group_id: Uuid,
    user_id: Uuid,
) -> Result<(), StatusCode> {
    let role = verify_group_member(db, group_id, user_id).await?;

    if role != "admin" && role != "owner" {
        return Err(StatusCode::FORBIDDEN);
    }

    Ok(())
}

Berechtigungshierarchie:

  • Owner: Volle Kontrolle (Gruppe löschen, Admins ernennen)
  • Admin: Mitglieder einladen/entfernen, Produkte verwalten
  • Member: Warenkörbe verwenden, Käufe hinzufügen

Deployment und Infrastruktur

Docker-Setup

# docker-compose.yml
services:
  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: postgres
    volumes:
      - db_data:/var/lib/postgresql/data
      - ./init-db.sql:/docker-entrypoint-initdb.d/init-db.sql
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 10s
      timeout: 5s
      retries: 5

  backend:
    build: ./storli_backend
    environment:
      DATABASE_URL: postgresql://storli:storli@db:5432/storli
      JWT_SECRET: ${JWT_SECRET:-dev-secret-change-in-production}
      RUST_LOG: ${RUST_LOG:-debug}
      FRONTEND_ORIGIN: http://localhost:3000
    ports:
      - "3001:3001"
    depends_on:
      db:
        condition: service_healthy

  frontend:
    build: ./storli_frontend
    environment:
      NEXT_PUBLIC_API_BASE: http://localhost:3001
    ports:
      - "3000:3000"
    depends_on:
      - backend

Design-Entscheidungen:

  • Health Checks: Backend startet erst nach DB-Bereitschaft
  • Environment Variables: Secrets über .env-Dateien
  • Volumes: Persistent Database Storage
  • Multi-Stage Builds: Optimierte Docker Images

CI/CD Pipeline (Woodpecker)

# .woodpecker.yml
steps:
  # 1. Code-Qualität prüfen
  lint:
    image: rust:1.83-alpine
    commands:
      - cargo fmt --check
      - cargo clippy --all-targets -- -D warnings
    when:
      - branch: main
        event: push

  # 2. Code paketieren
  package:
    image: alpine:latest
    commands:
      - tar czf code.tar.gz storli_backend/ docker-compose.nas.yml
    when:
      - branch: main

  # 3. Code auf NAS übertragen
  transfer-code:
    image: kroniak/ssh-client:latest
    commands:
      - ssh-keyscan -H 192.168.179.17 >> ~/.ssh/known_hosts
      - cat code.tar.gz | ssh user@nas 'cat > /volume1/docker/storli/code.tar.gz'
    environment:
      SSH_KEY:
        from_secret: ssh_private_key

  # 4. Deployment ausführen
  deploy:
    image: kroniak/ssh-client:latest
    commands:
      - ssh user@nas 'bash /volume1/docker/storli/deploy.sh'

Pipeline-Schritte:

  1. Lint: Code-Formatierung und Clippy-Checks
  2. Package: Tar-Archiv erstellen
  3. Transfer: Code via SSH auf NAS übertragen
  4. Deploy: Backup erstellen, Docker Image bauen, Container neustarten

Deployment-Script (deploy.sh):

#!/bin/bash
set -e

# Backup erstellen
BACKUP_PATH="/volume1/docker/storli/backups/backend-$(date +%Y%m%d-%H%M%S)"
mkdir -p "$BACKUP_PATH"
cp -r "$DEPLOY_PATH/code" "$BACKUP_PATH/"

# Code extrahieren
tar xzf code.tar.gz -C code

# Docker Image bauen
docker compose build backend

# Container neustarten
docker compose down backend
docker compose up -d backend

# Alte Backups aufräumen (5 neueste behalten)
ls -t | tail -n +6 | xargs -r rm -rf

Infrastruktur-Diagramm

┌─────────────────────────────────────────────────────────────┐
│                    Cloudflare Tunnel (HTTPS)                │
└────────────────────────────┬────────────────────────────────┘
                             │
                             ▼
┌─────────────────────────────────────────────────────────────┐
│                      Synology NAS                           │
│  ┌────────────────────────────────────────────────────┐     │
│  │              Docker Compose                        │     │
│  │  ┌──────────────┐  ┌──────────────┐  ┌──────────┐ │     │
│  │  │   Frontend   │  │   Backend    │  │   DB     │ │     │
│  │  │   Next.js    │──│     Axum     │──│ Postgres │ │     │
│  │  │   :3000      │  │    :3001     │  │  :5432   │ │     │
│  │  └──────────────┘  └──────────────┘  └──────────┘ │     │
│  └────────────────────────────────────────────────────┘     │
└─────────────────────────────────────────────────────────────┘
                             ▲
                             │
┌────────────────────────────┴────────────────────────────────┐
│                    Raspberry Pi (CI/CD)                     │
│  ┌────────────────────────────────────────────────────┐     │
│  │  Woodpecker CI + Gitea                             │     │
│  │  - Code Push Detection                             │     │
│  │  - Automated Testing                               │     │
│  │  - Build & Deploy Pipeline                         │     │
│  └────────────────────────────────────────────────────┘     │
└─────────────────────────────────────────────────────────────┘
                             ▲
                             │
┌────────────────────────────┴────────────────────────────────┐
│                    Monitoring Stack                         │
│  - Grafana (Visualisierung)                                 │
│  - Loki (Log-Aggregation)                                   │
│  - Prometheus (Metriken)                                    │
└─────────────────────────────────────────────────────────────┘

Infrastruktur-Komponenten:

  • Raspberry Pi: Gitea (Git-Server) + Woodpecker CI (Pipeline-Runner)
  • Synology NAS: Docker Host für Backend + DB
  • Cloudflare Tunnel: HTTPS-Terminierung ohne Port-Forwarding
  • Monitoring: Grafana + Loki + Prometheus für Observability

Code-Beispiele

Barcode-Scanning-Feature

// app/scan/page.tsx
'use client';

import { useEffect, useRef, useState } from 'react';
import { useRouter } from 'next/navigation';
import { api } from '@/lib/api';

export default function ScanPage() {
  const videoRef = useRef<HTMLVideoElement>(null);
  const [scanning, setScanning] = useState(false);
  const router = useRouter();

  useEffect(() => {
    let detector: BarcodeDetector | null = null;
    let animationFrame: number;

    const startScanning = async () => {
      // Request camera access
      const stream = await navigator.mediaDevices.getUserMedia({
        video: { facingMode: 'environment' }
      });

      if (videoRef.current) {
        videoRef.current.srcObject = stream;
      }

      // Initialize BarcodeDetector
      if ('BarcodeDetector' in window) {
        detector = new BarcodeDetector({
          formats: ['ean_13', 'ean_8', 'upc_a', 'upc_e', 'code_128']
        });
      }

      // Scan loop
      const scan = async () => {
        if (!detector || !videoRef.current || !scanning) return;

        try {
          const barcodes = await detector.detect(videoRef.current);

          if (barcodes.length > 0) {
            const code = barcodes[0].rawValue;

            // Lookup barcode in backend
            const product = await api(`/api/barcodes/${code}`);

            if (product) {
              router.push(`/products/${product.id}`);
            } else {
              // Unknown barcode - prompt to link
              router.push(`/products/link?barcode=${code}`);
            }
          }
        } catch (error) {
          console.error('Scan error:', error);
        }

        animationFrame = requestAnimationFrame(scan);
      };

      scan();
    };

    if (scanning) {
      startScanning();
    }

    return () => {
      if (animationFrame) {
        cancelAnimationFrame(animationFrame);
      }
      // Stop camera stream
      if (videoRef.current?.srcObject) {
        const tracks = (videoRef.current.srcObject as MediaStream).getTracks();
        tracks.forEach(track => track.stop());
      }
    };
  }, [scanning, router]);

  return (
    <div className="flex flex-col items-center gap-4">
      <video
        ref={videoRef}
        autoPlay
        playsInline
        className="w-full max-w-md rounded-lg"
      />

      <button
        onClick={() => setScanning(!scanning)}
        className="px-4 py-2 bg-blue-600 text-white rounded-lg"
      >
        {scanning ? 'Stop Scanning' : 'Start Scanning'}
      </button>
    </div>
  );
}

Features:

  • Native BarcodeDetector API (kein externes Package)
  • Echtzeit-Barcode-Erkennung
  • Automatische Produktsuche
  • Kamera-Cleanup bei Unmount

Empfehlungssystem-Backend

// routes.rs - Recommendation endpoint
pub async fn get_recommendations(
    State(state): State<AppState>,
    Extension(user_id): Extension<Uuid>,
    Query(params): Query<RecommendationQuery>,
) -> Result<Json<Vec<Recommendation>>, StatusCode> {
    let group_id = params.group_id;

    // Verify user is member of group
    verify_group_member(&state.db, group_id, user_id).await?;

    // Get purchase history for the group
    let purchases = sqlx::query_as::<_, Purchase>(
        "SELECT p.*, pr.name as product_name, pr.image_url, c.name as company_name
         FROM purchases p
         JOIN products pr ON p.product_id = pr.id
         JOIN companies c ON pr.company_id = c.id
         WHERE p.group_id = $1
         ORDER BY p.purchased_at DESC"
    )
    .bind(group_id)
    .fetch_all(&state.db)
    .await
    .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;

    // Group purchases by product
    let mut product_purchases: HashMap<Uuid, Vec<Purchase>> = HashMap::new();
    for purchase in purchases {
        product_purchases
            .entry(purchase.product_id)
            .or_insert_with(Vec::new)
            .push(purchase);
    }

    // Calculate recommendations
    let mut recommendations = Vec::new();

    for (product_id, mut purchases) in product_purchases {
        if purchases.len() < 2 {
            continue; // Need at least 2 purchases for pattern
        }

        // Sort by date
        purchases.sort_by_key(|p| p.purchased_at);

        // Calculate average days between purchases
        let intervals: Vec<i64> = purchases
            .windows(2)
            .map(|w| {
                let days = (w[1].purchased_at - w[0].purchased_at).num_days();
                days
            })
            .collect();

        let avg_interval = intervals.iter().sum::<i64>() / intervals.len() as i64;

        // Calculate next due date
        let last_purchase = purchases.last().unwrap();
        let next_due = last_purchase.purchased_at + Duration::days(avg_interval);
        let days_until = (next_due - Utc::now().date_naive()).num_days();

        recommendations.push(Recommendation {
            product_id,
            product_name: last_purchase.product_name.clone(),
            company_name: last_purchase.company_name.clone(),
            last_purchased: last_purchase.purchased_at,
            average_interval_days: avg_interval,
            next_due_date: next_due,
            days_until_due: days_until,
            urgency: if days_until <= 0 {
                "overdue"
            } else if days_until <= 7 {
                "urgent"
            } else {
                "normal"
            },
        });
    }

    // Sort by urgency (overdue first, then urgent, then by days_until)
    recommendations.sort_by(|a, b| {
        match (a.urgency, b.urgency) {
            ("overdue", "overdue") => a.days_until_due.cmp(&b.days_until_due),
            ("overdue", _) => std::cmp::Ordering::Less,
            (_, "overdue") => std::cmp::Ordering::Greater,
            ("urgent", "urgent") => a.days_until_due.cmp(&b.days_until_due),
            ("urgent", _) => std::cmp::Ordering::Less,
            (_, "urgent") => std::cmp::Ordering::Greater,
            _ => a.days_until_due.cmp(&b.days_until_due),
        }
    });

    Ok(Json(recommendations))
}

Algorithmus:

  1. Kaufhistorie der Gruppe abrufen
  2. Käufe nach Produkt gruppieren
  3. Durchschnittliches Kaufintervall berechnen
  4. Nächstes Fälligkeitsdatum prognostizieren
  5. Dringlichkeit bestimmen (überfällig/dringend/normal)
  6. Nach Dringlichkeit sortieren

Gruppen-Einladungssystem

// groups.rs
pub async fn invite_to_group(
    State(state): State<AppState>,
    Extension(user_id): Extension<Uuid>,
    Path(group_id): Path<Uuid>,
    Json(invite): Json<InviteRequest>,
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
    // Verify inviter is admin or owner
    verify_group_admin(&state.db, group_id, user_id)
        .await
        .map_err(|_| (
            StatusCode::FORBIDDEN,
            Json(ErrorResponse {
                error: "INSUFFICIENT_PERMISSIONS".to_string(),
                message: "Nur Admins können einladen".to_string(),
            }),
        ))?;

    // Check if invitee is already a member
    let already_member: bool = sqlx::query_scalar(
        "SELECT EXISTS(SELECT 1 FROM group_members
         WHERE group_id = $1 AND user_id = (SELECT id FROM users WHERE email = $2))"
    )
    .bind(group_id)
    .bind(&invite.email)
    .fetch_one(&state.db)
    .await
    .map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse::default())))?;

    if already_member {
        return Err((
            StatusCode::CONFLICT,
            Json(ErrorResponse {
                error: "ALREADY_MEMBER".to_string(),
                message: "Benutzer ist bereits Mitglied".to_string(),
            }),
        ));
    }

    // Find invitee user (may not exist yet)
    let invitee_id: Option<Uuid> = sqlx::query_scalar(
        "SELECT id FROM users WHERE email = $1"
    )
    .bind(&invite.email)
    .fetch_optional(&state.db)
    .await
    .map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse::default())))?;

    // Generate invitation token
    let token = Uuid::new_v4().to_string();
    let expires_at = Utc::now() + Duration::days(7);

    // Create invitation
    let invitation_id: Uuid = sqlx::query_scalar(
        "INSERT INTO group_invitations
         (group_id, inviter_id, invitee_email, invitee_id, token, expires_at, status)
         VALUES ($1, $2, $3, $4, $5, $6, 'pending')
         RETURNING id"
    )
    .bind(group_id)
    .bind(user_id)
    .bind(&invite.email)
    .bind(invitee_id)
    .bind(&token)
    .bind(expires_at)
    .execute(&state.db)
    .await
    .map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse::default())))?;

    // Create notification if user exists
    if let Some(invitee_id) = invitee_id {
        let group_name: String = sqlx::query_scalar(
            "SELECT name FROM groups WHERE id = $1"
        )
        .bind(group_id)
        .fetch_one(&state.db)
        .await
        .unwrap_or_default();

        let inviter_email: String = sqlx::query_scalar(
            "SELECT email FROM users WHERE id = $1"
        )
        .bind(user_id)
        .fetch_one(&state.db)
        .await
        .unwrap_or_default();

        sqlx::query(
            "INSERT INTO notifications
             (user_id, type, title, message, data, read)
             VALUES ($1, 'group_invitation', $2, $3, $4, false)"
        )
        .bind(invitee_id)
        .bind(format!("Einladung zu Gruppe '{}'", group_name))
        .bind(format!("{} hat dich eingeladen", inviter_email))
        .bind(serde_json::json!({
            "group_id": group_id,
            "invitation_id": invitation_id
        }))
        .execute(&state.db)
        .await
        .ok();
    }

    // Log admin action
    log_admin_action(
        &state.db,
        user_id,
        "group_member",
        Some(group_id),
        "invite",
        Some(serde_json::json!({ "invitee_email": invite.email })),
    )
    .await;

    Ok(Json(SuccessResponse {
        message: format!("Einladung gesendet an {}", invite.email),
    }))
}

Features:

  • Einladung per E-Mail (auch vor Registrierung)
  • 7-tägige Token-Gültigkeit
  • Benachrichtigung bei existierendem Benutzer
  • Admin-Berechtigung erforderlich
  • Audit-Logging

Lessons Learned und Best Practices

1. Type-Safety überall

Frontend:

// Typen aus API-Response ableiten
interface Product {
  id: string;
  name: string;
  price_cents: number | null;
  company_id: string;
  barcode: string | null;
}

// Strikte Typisierung in Komponenten
interface ProductCardProps {
  product: Product;
  onAddToCart: (productId: string) => Promise<void>;
}

Backend:

// SQLx compile-time checked queries
let product = sqlx::query_as!(
    Product,
    "SELECT id, name, price_cents, company_id, barcode
     FROM products WHERE id = $1",
    product_id
)
.fetch_one(&db)
.await?;

Vorteil: Compiler fängt Fehler ab, bevor sie Production erreichen

2. Granulare Fehlerbehandlung

// Spezifische Error-Typen statt generisches "Error"
#[derive(Debug, thiserror::Error)]
pub enum AppError {
    #[error("Database error: {0}")]
    Database(#[from] sqlx::Error),

    #[error("Authentication failed")]
    Unauthorized,

    #[error("Resource not found")]
    NotFound,

    #[error("Rate limit exceeded")]
    RateLimitExceeded,
}

// Conversion zu HTTP-Status
impl IntoResponse for AppError {
    fn into_response(self) -> Response {
        let (status, message) = match self {
            Self::Database(_) => (StatusCode::INTERNAL_SERVER_ERROR, "Database error"),
            Self::Unauthorized => (StatusCode::UNAUTHORIZED, "Unauthorized"),
            Self::NotFound => (StatusCode::NOT_FOUND, "Not found"),
            Self::RateLimitExceeded => (StatusCode::TOO_MANY_REQUESTS, "Rate limit exceeded"),
        };

        (status, Json(ErrorResponse { error: message.to_string() })).into_response()
    }
}

3. Defensive Programming

// Null-Checks und Fallbacks
const formatPrice = (cents: number | null): string => {
  if (cents === null) return 'N/A';

  return new Intl.NumberFormat('de-CH', {
    style: 'currency',
    currency: 'CHF'
  }).format(cents / 100);
};

// Optional Chaining
const userName = user?.profile?.name ?? 'Unbekannt';
// Result-basierte Fehlerbehandlung
pub async fn get_user(id: Uuid) -> Result<User, AppError> {
    sqlx::query_as!(User, "SELECT * FROM users WHERE id = $1", id)
        .fetch_optional(&db)
        .await?
        .ok_or(AppError::NotFound)
}

4. Performance-Optimierungen

Frontend:

  • Next.js Image Optimization
  • Code Splitting via Dynamic Imports
  • Memoization teurer Berechnungen
const expensiveCalculation = useMemo(() => {
  return products
    .filter(p => p.price_cents !== null)
    .reduce((sum, p) => sum + p.price_cents!, 0);
}, [products]);

Backend:

  • Connection Pooling (SQLx)
  • Batch Queries statt N+1
  • Indexing auf häufig abgefragte Spalten
-- Index für schnelle Gruppenmitgliedschafts-Checks
CREATE INDEX idx_group_members_user ON group_members(user_id);
CREATE INDEX idx_group_members_group ON group_members(group_id);

-- Index für Barcode-Lookups
CREATE INDEX idx_products_barcode ON products(barcode) WHERE barcode IS NOT NULL;

5. Sicherheit von Anfang an

  • Passwort-Hashing: Argon2 (nicht bcrypt/sha256)
  • Rate Limiting: Mehrere Layer (global, endpoint-spezifisch)
  • Audit Logging: Alle kritischen Operationen
  • SQL Injection: Parameterized Queries (SQLx)
  • XSS: React escaped automatisch, aber zusätzliche Validierung
  • CSRF: SameSite Cookies

Roadmap und nächste Schritte

In Arbeit (Current Sprint)

  • [ ] Mobile App mit Capacitor

    • Native iOS/Android Apps
    • Offline-Unterstützung
    • Push-Benachrichtigungen
    • Native Barcode-Scanner-Integration
  • [ ] Erweiterte Benachrichtigungen

    • WebSocket-basierte Echtzeit-Updates
    • Desktop-Benachrichtigungen
    • E-Mail-Benachrichtigungen für wichtige Events

Geplant (Next Quarter)

  • [ ] Erweiterte Analytics

    • Ausgaben-Tracking pro Kategorie
    • Preisentwicklungs-Diagramme
    • Budget-Berichte
  • [ ] Export-Funktionen

    • CSV-Export für Käufe
    • PDF-Berichte
    • API für Drittanbieter-Integrationen
  • [ ] Team-Features

    • Gruppenrollen-Berechtigungen erweitern
    • Kommentare zu Produkten
    • Aktivitäts-Feed

Backlog

  • [ ] Mehrwährungsunterstützung
  • [ ] Produktvorschläge via ML
  • [ ] Integration mit Lieferanten-APIs
  • [ ] Inventur-Assistenten
  • [ ] Rezeptverwaltung mit Auto-Einkaufslisten

Fazit

Storli ist ein umfassendes Full-Stack-Projekt, das moderne Technologien und Best Practices kombiniert, um ein robustes, skalierbares Inventar- und Einkaufsmanagementsystem zu schaffen. Die Kombination aus React/Next.js im Frontend und Rust/Axum im Backend bietet sowohl exzellente Developer Experience als auch Production-Grade Performance und Sicherheit.

Warum dieses Projekt interessant ist

  1. Modern Stack: React 19, Next.js 16, Rust, PostgreSQL
  2. Production-Ready: CI/CD, Docker, Monitoring, Logging
  3. Security-First: Multi-Layer Auth, Rate Limiting, Audit Logs
  4. Real-World Features: Barcode-Scanning, Empfehlungen, Gruppen
  5. Self-Hosted: Vollständige Datenkontrolle, keine Cloud-Abhängigkeiten

Technische Highlights

  • Type-Safety: TypeScript + Rust + SQLx = Compiler-geprüfte Korrektheit
  • Performance: Rust Backend mit async/await, Next.js Image Optimization
  • Sicherheit: Argon2, JWT, Rate Limiting, Audit Logging
  • Skalierbarkeit: Connection Pooling, Indexed Queries, Caching-ready
  • Observability: Structured Logging (JSON), Grafana/Loki/Prometheus

Code-Qualität

  • Konsistente Code-Formatierung (Prettier, rustfmt)
  • Linting (ESLint, Clippy)
  • Type-Safety (TypeScript, Rust)
  • Fehlerbehandlung (Result-Types, Try-Catch)
  • Dokumentation (Inline-Kommentare, README)

Erstellt am 2026-01-06

Status: Projekt in aktiver Entwicklung. Dieses Dokument wird regelmäßig aktualisiert.

Nächstes Update: Nach Abschluss der Capacitor Mobile App Integration