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.
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:
- CORS + Tracing (äußerste Schicht)
- Global Rate Limiting (100 req/min)
- Authentication Middleware (JWT-Validierung)
- 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:
- Token-Validierung (Signatur, Ablauf)
- Datenbankverifikation (User existiert)
- 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 Loginslogin_success: Erfolgreiche Loginsadmin_action: Admin-Operationen (Product/Company Create/Delete)unauthorized_access: Zugriffsverletzungenrate_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:
- Lint: Code-Formatierung und Clippy-Checks
- Package: Tar-Archiv erstellen
- Transfer: Code via SSH auf NAS übertragen
- 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:
- Kaufhistorie der Gruppe abrufen
- Käufe nach Produkt gruppieren
- Durchschnittliches Kaufintervall berechnen
- Nächstes Fälligkeitsdatum prognostizieren
- Dringlichkeit bestimmen (überfällig/dringend/normal)
- 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
- Modern Stack: React 19, Next.js 16, Rust, PostgreSQL
- Production-Ready: CI/CD, Docker, Monitoring, Logging
- Security-First: Multi-Layer Auth, Rate Limiting, Audit Logs
- Real-World Features: Barcode-Scanning, Empfehlungen, Gruppen
- 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