# System-Architektur ## my.experimenta.science **Version:** 1.0 **Datum:** 28. Oktober 2025 --- ## 1. Architektur-Übersicht Die my.experimenta.science App folgt einer **modernen Full-Stack-Architektur** mit: - **Nuxt 4** als Full-Stack-Framework (Frontend + Backend) - **PostgreSQL** als primäre Datenbank - **Cidaas** für Authentifizierung - **Externe Integrationen** (NAV ERP, X-API, PayPal) - **Docker** für Containerisierung --- ## 2. High-Level Architektur ``` ┌─────────────────────────────────────────────────────────────────┐ │ Client Layer │ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │ │ Browser │ │ Mobile │ │ Tablet │ │ Desktop │ │ │ │ (PWA) │ │ Safari │ │ iPad │ │ Chrome │ │ │ └─────┬────┘ └─────┬────┘ └─────┬────┘ └─────┬────┘ │ └────────┼─────────────┼─────────────┼─────────────┼─────────────┘ │ │ │ │ └─────────────┴─────────────┴─────────────┘ │ HTTPS ┌────────────────▼────────────────┐ │ Reverse Proxy (Nginx) │ │ - SSL Termination │ │ - Load Balancing │ │ - Rate Limiting │ └────────────────┬────────────────┘ │ ┌────────────────▼────────────────────────────────────┐ │ Nuxt 4 Application │ │ ┌──────────────────┐ ┌──────────────────┐ │ │ │ Frontend │ │ Server API │ │ │ │ - Vue 3 │ │ - Nitro Engine │ │ │ │ - SSR/SSG │ │ - API Routes │ │ │ │ - shadcn-nuxt │ │ - Business │ │ │ │ - Pinia Store │ │ Logic │ │ │ └──────────────────┘ └─────────┬────────┘ │ └────────────────────────────────────┼─────────────────┘ │ ┌─────────────────────────┼─────────────────────────┐ │ │ │ ┌──────────▼──────────┐ ┌──────────▼──────────┐ ┌──────────▼──────────┐ │ Drizzle ORM │ │ BullMQ Queues │ │ Redis │ │ │ │ - Orders │ │ - Queue Storage │ │ │ │ - Product Sync │ │ - Sessions │ │ │ │ - Emails │ │ - Caching │ └──────────┬──────────┘ └──────────┬──────────┘ └─────────────────────┘ │ │ ┌──────────▼──────────┐ ┌──────────▼──────────┐ │ PostgreSQL │ │ BullMQ Workers │ │ - Users │ │ - Order Worker │ │ - Products │ │ - Product Worker │ │ - Cart / Items │ │ - Email Worker │ │ - Orders / Items │ └─────────────────────┘ └─────────────────────┘ ┌─────────────────────────────────────────────────────────────────┐ │ External Services │ ├─────────────────────────────────────────────────────────────────┤ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │ │ Cidaas │ │ NAV ERP │ │ X-API │ │ │ │ (Auth) │ │ (Products) │ │ (Content) │ │ │ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │ │ │ │ │ │ │ ┌──────▼──────┐ ┌──────▼──────┐ ┌──────▼──────┐ │ │ │ PayPal │ │ Email │ │ Sentry │ │ │ │ (Payment) │ │ (SMTP) │ │ (Errors) │ │ │ └─────────────┘ └─────────────┘ └─────────────┘ │ └─────────────────────────────────────────────────────────────────┘ ``` --- ## 3. Datenfluss-Diagramme ### 3.1 Benutzer-Registrierung & Login ``` ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │ │ │ │ │ │ Client │ │ Nuxt │ │ Cidaas │ │ │ │ │ │ │ └────┬─────┘ └────┬─────┘ └────┬─────┘ │ │ │ │ 1. Click "Login" │ │ ├──────────────────────────>│ │ │ │ │ │ │ 2. Redirect to Cidaas │ │ │ with Client ID │ │ ├──────────────────────────>│ │ │ │ │ │ │ 3. User authenticates │ │ │ (email/password) │ │ │ │ │ 4. Auth Code │ │<──────────────────────────┴───────────────────────────┤ │ │ │ │ 5. Send Auth Code │ │ ├──────────────────────────>│ │ │ │ │ │ │ 6. Exchange Code for Token│ │ ├──────────────────────────>│ │ │ │ │ │ 7. Access Token + User Info│ │ │<──────────────────────────┤ │ │ │ │ │ 8. Create/Update User in DB │ │ (PostgreSQL) │ │ │ │ │ 9. Set Session Cookie │ │ │<──────────────────────────┤ │ │ │ │ │ 10. Redirect to Dashboard │ │ │<──────────────────────────┤ │ │ │ │ ``` **Schritte:** 1. User klickt auf "Login" 2. Nuxt leitet zu Cidaas weiter (OAuth2 Authorization) 3. User authentifiziert sich bei Cidaas 4. Cidaas redirected zurück mit Authorization Code 5. Client sendet Code an Nuxt Server 6. Nuxt tauscht Code gegen Access Token 7. Nuxt erhält User-Info von Cidaas 8. Nuxt erstellt/aktualisiert User-Profil in lokaler DB 9. Nuxt setzt Session-Cookie 10. User wird zur Homepage weitergeleitet --- ### 3.2 Produkt-Synchronisation (NAV ERP → App) ``` ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │ │ │ │ │ │ NAV ERP │ │ Nuxt │ │PostgreSQL│ │ │ │ │ │ │ └────┬─────┘ └────┬─────┘ └────┬─────┘ │ │ │ │ 1. Product Update Event │ │ │ (e.g., new JK created) │ │ │ │ │ │ 2. HTTP POST │ │ │ /api/erp/products │ │ ├──────────────────────────>│ │ │ Body: │ │ │ { │ │ │ nav_product_id, │ │ │ name, price, │ │ │ stock, ... │ │ │ } │ │ │ │ │ │ │ 3. Validate API Key │ │ │ │ │ │ 4. Validate Payload │ │ │ (Zod Schema) │ │ │ │ │ │ 5. Check if Product exists│ │ ├──────────────────────────>│ │ │ │ │ │ 6. Product exists? (Y/N) │ │ │<──────────────────────────┤ │ │ │ │ │ 7. INSERT or UPDATE │ │ ├──────────────────────────>│ │ │ │ │ │ 8. Success │ │ │<──────────────────────────┤ │ │ │ │ 9. HTTP 200 OK │ │ │<──────────────────────────┤ │ │ { │ │ │ success: true, │ │ │ product_id: "..." │ │ │ } │ │ │ │ │ ``` **Schritte:** 1. NAV ERP erkennt Produktänderung 2. NAV sendet POST-Request an `/api/erp/products` 3. Nuxt validiert API-Key 4. Nuxt validiert Payload (Zod) 5. **Nuxt fügt Job zu BullMQ Queue hinzu (async!)** 6. **Nuxt antwortet sofort mit 202 Accepted** (NAV wartet nicht!) 7. **[Background] Worker holt Job aus Queue** 8. **[Background] Worker prüft ob Produkt bereits existiert** 9. **[Background] Worker führt INSERT oder UPDATE aus (Drizzle)** 10. **[Background] DB bestätigt Operation** **Vorteile:** - ✅ NAV ERP bekommt sofortige Response (nicht blockierend) - ✅ Bulk-Import möglich (1000+ Produkte in Queue) - ✅ Automatische Retries bei DB-Fehlern - ✅ Rate Limiting verhindert DB-Überlastung --- ### 3.3 Checkout & Bestellung ``` ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ Client │ │ Nuxt │ │ PayPal │ │ X-API │ │ NAV ERP │ └────┬─────┘ └────┬─────┘ └────┬─────┘ └────┬─────┘ └────┬─────┘ │ │ │ │ │ │ 1. Checkout │ │ │ │ ├───────────────>│ │ │ │ │ │ │ │ │ │ │ 2. Create Order│ │ │ │ │ in DB (pending) │ │ │ │ │ │ │ │ │ 3. Create PayPal Order │ │ │ ├───────────────>│ │ │ │ │ │ │ │ │ │ 4. Order ID │ │ │ │ │<───────────────┤ │ │ │ │ │ │ │ │ 5. PayPal ID │ │ │ │ │<───────────────┤ │ │ │ │ │ │ │ │ │ 6. User redirected to PayPal │ │ │ ├────────────────────────────────>│ │ │ │ │ │ │ │ │ │ │ 7. User pays │ │ │ │ │ │ │ │ 8. Redirect back to App │ │ │ │<────────────────────────────────┤ │ │ │ │ │ │ │ │ 9. Capture Payment │ │ │ ├───────────────>│ │ │ │ │ │ 10. Capture │ │ │ │ ├───────────────>│ │ │ │ │ │ │ │ │ │ 11. Success │ │ │ │ │<───────────────┤ │ │ │ │ │ │ │ │ │ 12. Update Order Status (paid) │ │ │ │ │ │ │ │ │ 13. Transform Order to X-API Format │ │ │ │ │ │ │ │ 14. POST /shopware/order │ │ │ ├────────────────────────────────>│ │ │ │ │ │ │ │ │ │ │ 15. Forward to NAV (SOAP) │ │ │ ├───────────────>│ │ │ │ │ │ │ │ │ │ 16. Process Order │ │ │ │ │ │ │ │ │ 17. Confirmation │ │ │ │<───────────────┤ │ │ │ │ │ │ │ 18. HTTP 200 OK│ │ │ │ │<────────────────────────────────┤ │ │ │ │ │ │ │ │ 19. Update Order Status (completed) │ │ │ │ │ │ │ │ 20. Send Confirmation Email │ │ │ │ │ │ │ │ 21. Show Success Page │ │ │ │<───────────────┤ │ │ │ │ │ │ │ │ ``` **Schritte:** 1. User klickt "Jetzt kaufen" - **If user has saved address:** Form is pre-filled with saved data - **Else:** Empty form is shown 2. Nuxt erstellt Order in DB (Status: pending) - **If user checked "Save address":** Update user profile with billing address 3. Nuxt erstellt PayPal Order 4. PayPal gibt Order-ID zurück 5. Nuxt sendet Order-ID an Client 6. Client redirected zu PayPal 7. User zahlt bei PayPal 8. PayPal redirected zurück zur App 9. Client sendet "Capture Payment" Request 10. Nuxt captured Payment bei PayPal 11. PayPal bestätigt erfolgreiche Zahlung 12. Nuxt updated Order-Status auf "paid" 13. **Nuxt fügt Job zu BullMQ Queue hinzu (async!)** 14. **User sieht sofort Erfolgsseite** mit Bestellnummer 15. **[Background] Worker holt Job aus Queue** 16. **[Background] Worker transformiert Order in X-API Format (siehe 3.4)** 17. **[Background] Worker sendet POST zu X-API `/shopware/order`** 18. **[Background] X-API leitet Order an NAV ERP weiter (SOAP)** 19. **[Background] NAV ERP verarbeitet Bestellung** 20. **[Background] NAV sendet Bestätigung zurück an X-API** 21. **[Background] X-API gibt HTTP 200 OK an Worker zurück** 22. **[Background] Worker updated Order-Status auf "completed"** 23. **[Background] Worker fügt Email-Job zu Queue hinzu** 24. **[Background] Email-Worker sendet Bestätigungs-E-Mail** **Vorteile:** - ✅ User wartet nicht auf X-API/NAV (sofortige Bestätigung) - ✅ Automatische Retries bei X-API-Fehlern - ✅ Keine verlorenen Bestellungen bei Server-Restart - ✅ Entkoppelte Verarbeitung = robuster --- ### 3.4 X-API Order Transformation **Endpoint:** `POST https://x-api.experimenta.science/shopware/order` **Environments:** - **DEV:** `https://x-api-dev.experimenta.science/shopware/order` - **STAGE:** `https://x-api-stage.experimenta.science/shopware/order` - **LIVE:** `https://x-api.experimenta.science/shopware/order` **Authentication:** Die X-API ist mit **HTTP Basic Authentication** geschützt. ```http POST /shopware/order HTTP/1.1 Host: x-api.experimenta.science Content-Type: application/json Authorization: Basic { "shopPOSOrder": { ... } } ``` **Credentials:** - Separate Credentials pro Environment (DEV, STAGE, LIVE) - Gespeichert in Environment Variables: - `X_API_USERNAME` - Username für X-API Basic Auth - `X_API_PASSWORD` - Password für X-API Basic Auth - `X_API_BASE_URL` - Base URL des X-API Endpoints **Security:** - ⚠️ **Credentials niemals im Code hardcoden** - ✅ Nur über HTTPS-Verbindungen - ✅ In Production: Docker Secrets verwenden (nicht plain ENV vars) - ✅ Credentials regelmäßig rotieren - ✅ Getrennte Service-Accounts pro Environment **Order Mapping (our DB → X-API):** ```typescript // Transform local order to X-API format { shopPOSOrder: { // Document metadata documentType: "Order", // Fixed for MVP externalDocumentNo: order.orderNumber, // e.g., "EXP-2025-00001" externalPOSReceiptNo: "", // Empty for web shop salesChannel: "Shop", // Fixed shoppingCartCompletion: order.createdAt.toISOString(), // ISO 8601 UTC visitType: "Private", // Fixed for MVP (no institutions yet) // Pricing (in cents!) invoiceDiscountValue: 0, // No discounts in MVP invoiceDiscPct: 0, amountIncludingVAT: Math.round(order.totalAmount * 100), // EUR → Cents language: "DEU", // Fixed for German MVP // Line items (one per cart item) salesLine: order.items.map((item, index) => ({ type: "Item", lineNo: String((index + 1) * 10000), // 10000, 20000, 30000, ... no: item.product.navProductId, // Article number from NAV variantCode: item.product.variantCode || "", description: item.product.name.substring(0, 50), description2: item.product.description?.substring(0, 50) || "", quantity: item.quantity, unitPrice: Math.round(item.priceSnapshot * 100), // EUR → Cents vatPct: 7, // Standard 7% VAT for Jahreskarten lineAmountIncludingVAT: Math.round(item.quantity * item.priceSnapshot * 100), lineDiscountPct: 0, lineDiscountAmount: 0, seasonTicketCode: "", // Empty for MVP couponCode: "", // Empty for MVP visitorCategory: "120", // Code for "Jahreskarten-Inhabende" ticketPriceType: "Jahreskarte Makerspace", ticketCode: generateUniqueTicketCode(), // Generate unique code // Annual pass details (required for Makerspace-JK!) annualPass: { salutationCode: mapSalutation(user.salutation), // "HERR", "FRAU", "K_ANGABE" firstName: user.firstName, lastName: user.lastName, streetAndHouseNumber: user.address.street, postCode: user.address.postCode, city: user.address.city, countryCode: user.address.countryCode || "DE", dateOfBirth: user.dateOfBirth, // YYYY-MM-DD format eMail: user.email, validFrom: calculateValidFromDate(), // Today or next day } })), // Payment information payment: [{ paymentEntryNo: 10000, amount: Math.round(order.totalAmount * 100), // EUR → Cents paymentType: "Shop PayPal", // Fixed for MVP createdOn: order.paymentCompletedAt.toISOString(), // ISO 8601 UTC reference: order.paymentId, // PayPal transaction ID (GUID format) paymentId: order.paymentId, // PayPal order ID }], // Customer contact personContact: { experimentaAccountID: user.experimentaId, // Our experimenta_id from Cidaas! eMail: user.email, salutationCode: mapSalutation(user.salutation), jobTitle: user.jobTitle || "", firstName: user.firstName, lastName: user.lastName, streetAndHouseNumber: order.billingAddress.street, postCode: order.billingAddress.postCode, city: order.billingAddress.city, countryCode: order.billingAddress.countryCode || "DE", phoneNumber: user.phone || "", guest: false, // false for registered users, true for guest checkout } } } ``` **Important Data Transformations:** 1. **Prices:** EUR (Decimal) → Cents (Integer) - `99.00` EUR → `9900` Cents 2. **Dates:** JavaScript Date → ISO 8601 UTC String - `new Date()` → `"2025-10-28T14:30:00.000Z"` 3. **Line Numbers:** Sequential multiples of 10000 - Item 1: `"10000"`, Item 2: `"20000"`, etc. 4. **Salutation Mapping:** - `"male"` → `"HERR"` - `"female"` → `"FRAU"` - `"other"` / `null` → `"K_ANGABE"` **Error Handling:** - Retry logic with exponential backoff (3 attempts) - If X-API fails: Keep order as "paid" status, alert admin - Manual retry option in admin panel (post-MVP) - Log all requests/responses for debugging **Validation:** - Zod schema validation before sending - Check all required fields present - Validate formats (email, dates, UUIDs) - Ensure amounts match (checksum validation) --- ### 3.5 Queue-Architektur (BullMQ + Redis) Asynchrone Verarbeitung mit Job Queues für robuste und skalierbare Order- und Produkt-Synchronisation. #### Übersicht ``` ┌─────────────────────────────────────────────────────────────┐ │ Queue-basierte Architektur │ ├─────────────────────────────────────────────────────────────┤ │ │ │ Producer (Nuxt API) Redis Queue Worker │ │ ┌────────────────┐ ┌──────────┐ ┌─────────┐│ │ │ POST /checkout │ │ │ │ Order ││ │ │ → add job ├────────>│ Orders ├──────>│ Worker ││ │ └────────────────┘ │ │ └─────────┘│ │ └──────────┘ │ │ ┌────────────────┐ ┌──────────┐ ┌─────────┐│ │ │ POST /erp/ │ │ Product │ │ Product ││ │ │ products ├────────>│ Sync ├──────>│ Worker ││ │ └────────────────┘ └──────────┘ └─────────┘│ │ ┌──────────┐ ┌─────────┐│ │ ┌────────────────┐ │ │ │ Email ││ │ │ Order complete ├────────>│ Emails ├──────>│ Worker ││ │ └────────────────┘ └──────────┘ └─────────┘│ │ │ └─────────────────────────────────────────────────────────────┘ ``` #### Queue-Definitionen (MVP) ##### 1. x-api-orders Queue - **Zweck:** Bestellungen asynchron an X-API/NAV senden - **Producer:** POST /api/payment/capture (nach PayPal Success) - **Worker:** Order Worker - **Retry:** 5 Versuche mit exponential backoff (2s, 4s, 8s, 16s, 32s) - **Priority:** Hoch - **Persistenz:** Redis AOF ##### 2. product-sync Queue - **Zweck:** Produkte von NAV ERP synchronisieren - **Producer:** POST /api/erp/products - **Worker:** Product Sync Worker - **Retry:** 3 Versuche mit exponential backoff (1s, 3s, 9s) - **Priority:** Mittel - **Rate Limiting:** Max 10 Jobs/Sekunde (DB schonen) ##### 3. emails Queue (Post-MVP) - **Zweck:** E-Mails asynchron versenden - **Producer:** Order Worker (nach X-API Success) - **Worker:** Email Worker - **Retry:** 3 Versuche mit fixed delay (5s) - **Priority:** Niedrig #### Redis-Konfiguration **Persistenz-Setup:** ```bash # Redis mit AOF + RDB für maximale Datensicherheit redis-server \ --appendonly yes \ --appendfsync everysec \ --save 60 1000 \ --save 300 100 \ --save 900 1 ``` **Was bedeutet das:** - `--appendonly yes`: Append-Only File aktiviert - `--appendfsync everysec`: Jede Sekunde auf Disk schreiben - `--save`: RDB Snapshots als Backup **Garantie:** Max. 1 Sekunde Datenverlust bei Server-Crash #### Worker-Implementierung **Order Worker (server/workers/order-worker.ts):** ```typescript import { Worker } from 'bullmq' const worker = new Worker( 'x-api-orders', async (job) => { const { orderId } = job.data // 1. Fetch order from DB const order = await db.query.orders.findFirst({ where: eq(orders.id, orderId), with: { items: true, user: true }, }) if (!order) { throw new Error(`Order ${orderId} not found`) } // 2. Transform to X-API format const payload = transformOrderToXAPI(order) // 3. Prepare Basic Auth header const config = useRuntimeConfig() const authString = Buffer.from(`${config.xApiUsername}:${config.xApiPassword}`).toString( 'base64' ) // 4. Submit to X-API (with timeout and Basic Auth) const response = await fetch(`${config.xApiBaseUrl}/shopware/order`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Basic ${authString}`, }, body: JSON.stringify(payload), signal: AbortSignal.timeout(30000), // 30s timeout }) if (!response.ok) { const errorText = await response.text() throw new Error(`X-API error ${response.status}: ${errorText}`) } // 5. Update order status await db.update(orders).set({ status: 'completed' }).where(eq(orders.id, orderId)) // 6. Queue confirmation email await emailQueue.add('order-confirmation', { orderId, email: order.user.email, }) return { success: true, orderId } }, { connection: { host: 'redis', port: 6379 }, concurrency: 5, // Max 5 parallel jobs limiter: { max: 10, // Max 10 jobs duration: 1000, // per second }, } ) // Error handling worker.on('failed', (job, error) => { logger.error(`Order job ${job.id} failed:`, error) if (job.attemptsMade >= 5) { // Alert admin after max retries alertAdmin(`Order ${job.data.orderId} failed permanently`) } }) worker.on('completed', (job) => { logger.info(`Order ${job.data.orderId} submitted successfully`) }) ``` **Product Sync Worker (server/workers/product-worker.ts):** ```typescript import { Worker } from 'bullmq' const worker = new Worker( 'product-sync', async (job) => { const product = job.data // Upsert product await db .insert(products) .values({ navProductId: product.nav_product_id, name: product.name, price: product.price, stock: product.stock, description: product.description, imageUrl: product.image_url, status: product.status, }) .onConflictDoUpdate({ target: products.navProductId, set: { name: product.name, price: product.price, stock: product.stock, description: product.description, imageUrl: product.image_url, status: product.status, updatedAt: new Date(), }, }) return { success: true, navProductId: product.nav_product_id } }, { connection: { host: 'redis', port: 6379 }, limiter: { max: 10, // Max 10 products duration: 1000, // per second (DB schonen!) }, } ) ``` #### Monitoring: BullBoard **Dashboard-Setup (server/api/admin/queues.ts):** ```typescript import { createBullBoard } from '@bull-board/api' import { BullMQAdapter } from '@bull-board/api/bullMQAdapter' import { NuxtAdapter } from '@bull-board/nuxt' const serverAdapter = new NuxtAdapter() createBullBoard({ queues: [ new BullMQAdapter(orderQueue), new BullMQAdapter(productSyncQueue), new BullMQAdapter(emailQueue), ], serverAdapter, options: { uiConfig: { boardTitle: 'experimenta Queue Monitor', }, }, }) // Protected with auth middleware // Access: http://localhost:3000/admin/queues ``` **Features:** - Real-time Job-Status - Manual Retry - Job Details & Logs - Queue-Metriken (Waiting, Active, Completed, Failed) - Fehler-Analyse #### Fehlerbehandlung & Retry-Strategien **Exponential Backoff:** ```typescript // Order Queue: 5 Versuche await orderQueue.add('submit', data, { attempts: 5, backoff: { type: 'exponential', delay: 2000, // 2s, 4s, 8s, 16s, 32s }, removeOnComplete: 1000, // Keep last 1000 completed removeOnFail: false, // Keep all failed (manual review) }) ``` **Dead Letter Queue:** - Jobs die 5x fehlschlagen bleiben in Redis - Admin-Alert bei permanenten Fehlern - Manuelle Retry-Option in BullBoard #### Skalierung **Mehrere Worker-Instanzen:** ```yaml # docker-compose.yml services: worker-orders: build: . command: node server/workers/order-worker.js deploy: replicas: 2 # 2 Instanzen für höheren Durchsatz environment: - REDIS_URL=redis://redis:6379 ``` **Load Distribution:** - BullMQ verteilt Jobs automatisch über Worker - Jeder Job wird nur 1x verarbeitet (Lock-Mechanismus) #### Vorteile der Queue-Architektur **Robustheit:** - ✅ Keine verlorenen Jobs bei Server-Restart (Redis Persistenz) - ✅ Automatische Retries bei temporären Fehlern - ✅ Graceful Degradation (User sieht Erfolg, auch wenn X-API langsam) **Performance:** - ✅ Non-blocking API Endpoints (sofortige Response) - ✅ Rate Limiting schützt DB vor Überlastung - ✅ Parallelverarbeitung durch multiple Worker **Monitoring:** - ✅ BullBoard Dashboard für Ops-Team - ✅ Fehler-Tracking pro Job - ✅ Metriken für Durchsatz-Analyse **Skalierbarkeit:** - ✅ Horizontal skalierbar (mehr Worker = höherer Durchsatz) - ✅ Priority Queues für wichtige Jobs - ✅ Delayed Jobs für zeitgesteuerte Tasks --- ### 3.6 Authentication & Authorization (Cidaas OAuth2/OIDC) Vollständige Authentifizierungsarchitektur mit OAuth2 Authorization Code Flow + PKCE. #### Übersicht ``` ┌──────────────────────────────────────────────────────────────────────┐ │ Authentication Architecture (Cidaas + Local DB) │ ├──────────────────────────────────────────────────────────────────────┤ │ │ │ ┌──────────┐ ┌──────────────┐ ┌──────────┐ │ │ │ Client │ HTTPS │ Nuxt 4 App │ OIDC │ Cidaas │ │ │ │ (Browser)│◄─────►│ (Server) │◄────►│ (OAuth2) │ │ │ └──────────┘ └───────┬──────┘ └──────────┘ │ │ │ │ │ │ Link via experimenta_id │ │ │ │ │ ┌──────▼────────┐ │ │ │ PostgreSQL │ │ │ │ - users │ │ │ │ - orders │ │ │ │ - ... │ │ │ └───────────────┘ │ │ │ │ Cidaas Rolle: ✅ Authentifizierung (Login/Registration) │ │ ❌ NICHT für Nutzerdaten-Speicherung │ │ │ │ Local DB Rolle: ✅ Nutzerprofile (Name, Adresse, Präferenzen) │ │ ✅ Bestellhistorie, Warenkorb │ │ ✅ Rollen & Berechtigungen (Post-MVP) │ │ │ └──────────────────────────────────────────────────────────────────────┘ ``` #### Technologie-Stack | Komponente | Technologie | Version | | ------------------- | --------------------------- | ------------ | | **Auth Module** | `nuxt-auth-utils` | Latest | | **OAuth2 Provider** | Cidaas (Widas) | OIDC-konform | | **OAuth2 Flow** | Authorization Code + PKCE | RFC 7636 | | **Session Storage** | Encrypted HTTP-only Cookies | AES-256-GCM | | **JWT Validation** | `jose` | Latest | | **Session Dauer** | 30 Tage | Configurable | #### Detailed Login Flow (OAuth2 + PKCE) ``` ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌──────────────┐ │ Browser │ HTTPS │ Nuxt 4 App │ OIDC │ Cidaas │ Link │ PostgreSQL │ │ (Client) │◄───────►│ (Server) │◄───────►│ (Identity) │ │ (User Store) │ └─────────────┘ └─────────────┘ └─────────────┘ └──────────────┘ │ │ │ │ │ 1. Navigate /auth │ │ │ │ (Login Tab) │ │ │ ├──────────────────────►│ │ │ │ │ │ │ │ 2. Render Login Form │ │ │ │◄──────────────────────┤ │ │ │ │ │ │ │ 3. Submit email │ │ │ │ POST /api/auth/ │ │ │ │ login │ │ │ ├──────────────────────►│ │ │ │ │ │ │ │ │ 4. Generate PKCE │ │ │ │ verifier (64chars)│ │ │ │ + challenge │ │ │ │ (SHA256 hash) │ │ │ │ │ │ │ │ 5. Generate state │ │ │ │ (CSRF token) │ │ │ │ │ │ │ │ 6. Store verifier + │ │ │ │ state in cookies │ │ │ │ (5min TTL) │ │ │ │ │ │ │ │ 7. Build OAuth2 URL: │ │ │ │ - client_id │ │ │ │ - redirect_uri │ │ │ │ - code_challenge │ │ │ │ - state │ │ │ │ - scope: openid │ │ │ │ profile email │ │ │ │ │ │ │ 8. Redirect to │ │ │ │ Cidaas AuthZ URL │ │ │ │◄──────────────────────┤ │ │ │ │ │ │ │ 9. Navigate to │ │ │ │ Cidaas login page │ │ │ ├──────────────────────────────────────────────►│ │ │ │ │ │ │ 10. User enters │ │ │ │ email + password │ │ │ ├──────────────────────────────────────────────►│ │ │ │ │ │ │ │ 11. Cidaas validates │ │ │ │ credentials │ │ │ │ │ │ │ 12. Redirect back │ │ │ │ to callback with │ │ │ │ auth code + state│ │ │ │◄──────────────────────────────────────────────┤ │ │ │ │ │ │ 13. GET /api/auth/ │ │ │ │ callback? │ │ │ │ code=xxx& │ │ │ │ state=yyy │ │ │ ├──────────────────────►│ │ │ │ │ │ │ │ │ 14. Validate state │ │ │ │ (CSRF check) │ │ │ │ │ │ │ │ 15. Retrieve PKCE │ │ │ │ verifier from │ │ │ │ cookie │ │ │ │ │ │ │ │ 16. Exchange code │ │ │ │ + verifier for │ │ │ │ tokens │ │ │ │ POST /token-srv │ │ │ ├──────────────────────►│ │ │ │ │ │ │ │ 17. Validate code + │ │ │ │ verifier: │ │ │ │ SHA256(verifier) │ │ │ │ == challenge? │ │ │ │ │ │ │ │ 18. Return tokens: │ │ │ │ - access_token │ │ │ │ - id_token (JWT) │ │ │ │ - refresh_token │ │ │ │◄──────────────────────┤ │ │ │ │ │ │ │ 19. Validate ID token│ │ │ │ (JWT signature, │ │ │ │ exp, iss, aud) │ │ │ │ │ │ │ │ 20. Fetch UserInfo │ │ │ │ GET /users-srv │ │ │ ├──────────────────────►│ │ │ │ │ │ │ │ 21. User profile: │ │ │ │ { sub, email, │ │ │ │ given_name, │ │ │ │ family_name } │ │ │ │◄──────────────────────┤ │ │ │ │ │ │ │ 22. Query user by experimenta_id = sub │ │ ├─────────────────────────────────────────────────►│ │ │ │ │ │ │ 23. User exists? │ │ │◄─────────────────────────────────────────────────┤ │ │ │ │ │ │ 24. If new: INSERT user │ │ │ If exists: UPDATE updated_at │ │ ├─────────────────────────────────────────────────►│ │ │ │ │ │ │ 25. User record │ │ │◄─────────────────────────────────────────────────┤ │ │ │ │ │ │ 26. Create encrypted │ │ │ │ session cookie: │ │ │ │ { user: { │ │ │ │ id, email, │ │ │ │ firstName, │ │ │ │ lastName │ │ │ │ }} │ │ │ │ (AES-256-GCM) │ │ │ │ │ │ │ 27. Set-Cookie: │ │ │ │ experimenta- │ │ │ │ session=; │ │ │ │ HttpOnly; Secure;│ │ │ │ SameSite=Lax; │ │ │ │ Max-Age=2592000 │ │ │ │◄──────────────────────┤ │ │ │ │ │ │ │ 28. Redirect to / │ │ │ │ (Homepage) │ │ │ │◄──────────────────────┤ │ │ │ │ │ │ │ 29. Navigate Home │ │ │ │ (logged in!) │ │ │ ├──────────────────────►│ │ │ │ │ │ │ ``` **Key Security Features:** 1. **PKCE (Proof Key for Code Exchange):** - Prevents authorization code interception attacks - Verifier stored temporarily (5min), never transmitted - Challenge sent to Cidaas, verified on token exchange - Required even for confidential clients (defense in depth) 2. **State Parameter:** - CSRF protection for OAuth2 callback - Random 32-byte string generated per request - Validated on callback before code exchange 3. **Short-lived Temporary Cookies:** - PKCE verifier: 5min TTL (enough for auth flow) - OAuth state: 5min TTL - Prevents replay attacks 4. **Encrypted Session Cookie:** - AES-256-GCM encryption via `nuxt-auth-utils` - HTTP-only: Not accessible via JavaScript (XSS protection) - Secure flag: Only transmitted over HTTPS - SameSite=Lax: CSRF protection - 30-day expiration (configurable) #### Registration Flow ``` ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ Browser │ │ Nuxt 4 App │ │ Cidaas │ └─────────────┘ └─────────────┘ └─────────────┘ │ │ │ │ 1. Navigate /auth │ │ │ (Register Tab) │ │ ├──────────────────────►│ │ │ │ │ │ 2. Render Register │ │ │ Form │ │ │◄──────────────────────┤ │ │ │ │ │ 3. Submit: │ │ │ - email │ │ │ - password │ │ │ - firstName │ │ │ - lastName │ │ │ POST /api/auth/ │ │ │ register │ │ ├──────────────────────►│ │ │ │ │ │ │ 4. Validate input │ │ │ (Zod schema) │ │ │ │ │ │ 5. Call Cidaas │ │ │ Registration API: │ │ │ POST /users-srv/ │ │ │ register │ │ ├──────────────────────►│ │ │ │ │ │ 6. Create user │ │ │ Send verification │ │ │ email │ │ │ │ │ │ 7. Success response │ │ │◄──────────────────────┤ │ │ │ │ 8. Success message: │ │ │ "Please verify │ │ │ your email" │ │ │◄──────────────────────┤ │ │ │ │ │ 9. User clicks email │ │ │ verification link │ │ ├──────────────────────────────────────────────►│ │ │ │ │ │ 10. Verify email │ │ │ Mark as verified │ │ │ │ │ 11. Confirmation │ │ │◄──────────────────────────────────────────────┤ │ │ │ │ 12. User can now │ │ │ login (follow │ │ │ login flow) │ │ │ │ │ ``` **Important Notes:** - User is created in Cidaas, **NOT** in local DB - Local DB user record created on **first login** (via callback) - Email verification required before login - Password requirements enforced by Cidaas - Registration can fail if email already exists #### Session Management **Session Lifecycle:** ```typescript // Session created on login (server/api/auth/callback.get.ts) await setUserSession(event, { user: { id: user.id, // Local DB UUID experimentaId: user.experimentaId, // Cidaas sub claim email: user.email, firstName: user.firstName, lastName: user.lastName, }, loggedInAt: new Date().toISOString(), }) // Session cookie: // - Name: experimenta-session // - Value: // - Max-Age: 2592000 (30 days) // - HttpOnly, Secure, SameSite=Lax ``` **Client-Side Session Access:** ```typescript // In Vue components (composables/useAuth.ts) const { user, loggedIn } = useUserSession() // user.value: { id, email, firstName, ... } or null // loggedIn.value: boolean ``` **Server-Side Session Validation:** ```typescript // Protected API routes export default defineEventHandler(async (event) => { // Require authentication (throws 401 if not logged in) const { user } = await requireUserSession(event) // Access user data const userId = user.id // ... }) ``` **Session Expiration:** - **Default:** 30 days (2592000 seconds) - **Configurable** via `nuxt.config.ts` - **Auto-refresh:** Session cookie updated on each request (sliding expiration) - **Logout:** `clearUserSession()` deletes cookie immediately #### Protected Routes **Middleware Pattern:** ```typescript // middleware/auth.ts export default defineNuxtRouteMiddleware(async (to, from) => { const { loggedIn } = useUserSession() if (!loggedIn.value) { // Store intended destination for post-login redirect useCookie('redirect_after_login', { maxAge: 600, // 10 minutes path: '/', }).value = to.fullPath return navigateTo('/auth') } }) ``` **Usage in Pages:** ```vue ``` #### JWT Token Validation **ID Token Verification (jose):** ```typescript // server/utils/jwt.ts import { jwtVerify, createRemoteJWKSet } from 'jose' const config = useRuntimeConfig() // JWKS (JSON Web Key Set) - Cidaas public keys const JWKS = createRemoteJWKSet( new URL(config.cidaas.jwksUrl) // /.well-known/jwks.json ) export async function verifyIdToken(idToken: string) { const { payload } = await jwtVerify(idToken, JWKS, { issuer: config.cidaas.issuer, // Must match Cidaas URL audience: config.cidaas.clientId, // Must match our client ID }) return payload // { sub, email, exp, iat, ... } } ``` **When Tokens Are Validated:** 1. ✅ **On OAuth2 Callback** - ID token validated before creating session 2. ✅ **Token Refresh** (if implemented) - New tokens validated 3. ❌ **Every Request** - NOT needed! Session cookie already validated by `nuxt-auth-utils` **Token Storage:** - **Access Token:** Used once (UserInfo fetch), then discarded - **ID Token:** Validated once, then discarded - **Refresh Token:** NOT stored (stateless sessions preferred) - **Session Data:** Stored in encrypted cookie (no tokens needed) #### User Data Synchronization **Cidaas → PostgreSQL Mapping:** ```typescript // server/api/auth/callback.get.ts // Cidaas UserInfo response const cidaasUser = { sub: 'cidaas-user-123', // Unique Cidaas ID email: 'user@example.com', email_verified: true, given_name: 'Max', family_name: 'Mustermann', updated_at: 1698765432, } // Map to local user record await db.insert(users).values({ experimentaId: cidaasUser.sub, // Link to Cidaas! email: cidaasUser.email, firstName: cidaasUser.given_name, lastName: cidaasUser.family_name, }) ``` **Upsert Strategy:** - **First Login:** INSERT new user record - **Subsequent Logins:** UPDATE `updated_at` timestamp - **No automatic sync:** User profile changes in Cidaas not synced (by design) - **Manual sync:** User can re-login to refresh profile data (if needed) #### Security Architecture **Multi-Layer Security Model:** ``` ┌────────────────────────────────────────────────────────────┐ │ Security Layers │ ├────────────────────────────────────────────────────────────┤ │ │ │ Layer 1: Transport (HTTPS) │ │ ├─ TLS 1.3 │ │ ├─ HSTS Headers (Strict-Transport-Security) │ │ └─ Certificate: Let's Encrypt │ │ │ │ Layer 2: OAuth2 Security (PKCE + State) │ │ ├─ PKCE: Authorization code interception prevention │ │ ├─ State: CSRF protection │ │ └─ Short-lived temporary cookies (5min) │ │ │ │ Layer 3: Session Security │ │ ├─ Encrypted cookies (AES-256-GCM) │ │ ├─ HTTP-only flag (XSS protection) │ │ ├─ Secure flag (HTTPS only) │ │ ├─ SameSite=Lax (CSRF protection) │ │ └─ 30-day expiration │ │ │ │ Layer 4: JWT Validation │ │ ├─ Signature verification (JWKS) │ │ ├─ Expiration check (exp claim) │ │ ├─ Issuer validation (iss claim) │ │ └─ Audience validation (aud claim) │ │ │ │ Layer 5: Input Validation │ │ ├─ Zod schemas (all endpoints) │ │ ├─ SQL injection prevention (Drizzle ORM) │ │ └─ XSS prevention (Vue auto-escaping) │ │ │ │ Layer 6: Rate Limiting │ │ ├─ Login: 5 attempts / 15min per IP │ │ ├─ Register: 3 attempts / hour per IP │ │ └─ Global: 100 req/min per IP (Nginx) │ │ │ └────────────────────────────────────────────────────────────┘ ``` #### API Endpoints **Authentication Endpoints:** ``` POST /api/auth/login - Initiate OAuth2 flow GET /api/auth/callback - OAuth2 callback handler POST /api/auth/register - Register new user POST /api/auth/logout - Clear session GET /api/auth/me - Get current user ``` **Implementation Details:** See [`docs/CIDAAS_INTEGRATION.md`](./CIDAAS_INTEGRATION.md) for: - Complete endpoint implementations - Server utilities (PKCE, Cidaas API client, JWT validation) - Client components (Login/Register forms) - Middleware (auth, rate limiting) - i18n translations - Testing strategies #### Environment Configuration **Required Environment Variables:** ```bash # Cidaas OAuth2 Configuration CIDAAS_CLIENT_ID= CIDAAS_CLIENT_SECRET= CIDAAS_ISSUER=https://experimenta.cidaas.de CIDAAS_AUTHORIZE_URL=https://experimenta.cidaas.de/authz-srv/authz CIDAAS_TOKEN_URL=https://experimenta.cidaas.de/token-srv/token CIDAAS_USERINFO_URL=https://experimenta.cidaas.de/users-srv/userinfo CIDAAS_JWKS_URL=https://experimenta.cidaas.de/.well-known/jwks.json CIDAAS_REDIRECT_URI=https://my.experimenta.science/api/auth/callback # Session Encryption Secret (generate with: openssl rand -hex 32) NUXT_SESSION_PASSWORD=<64-char-hex-secret> ``` **Cidaas Admin Panel Setup:** 1. Create OAuth2 application in Cidaas 2. Configure Grant Types: `authorization_code`, `refresh_token` 3. Enable PKCE (code challenge method: `S256`) 4. Set Redirect URIs (exact match required!) 5. Configure Scopes: `openid`, `profile`, `email` 6. Note Client ID & Secret See [`docs/CIDAAS_INTEGRATION.md#setup-anleitung`](./CIDAAS_INTEGRATION.md#setup-anleitung) for detailed setup checklist. #### Monitoring & Debugging **Logging:** ```typescript // Auth-related logs (automatically logged) logger.info({ userId, action: 'login' }, 'User logged in') logger.error({ userId, error }, 'Login failed') logger.info({ userId }, 'User logged out') ``` **Debug Tips:** 1. **Inspect session cookie:** ```javascript // Browser console document.cookie.split(';').find((c) => c.includes('experimenta-session')) ``` 2. **Decode JWT (unverified):** ```javascript const idToken = 'eyJ...' const payload = JSON.parse(atob(idToken.split('.')[1])) console.log(payload) ``` 3. **Enable debug logging:** ```typescript // nuxt.config.ts export default defineNuxtConfig({ nitro: { logLevel: 'debug' }, }) ``` #### Fehlerbehandlung **Common Errors:** | Error | Cause | Solution | | --------------------------- | ----------------------------------- | ------------------------------------------------ | | **Invalid state parameter** | State cookie expired or CSRF attack | Ensure cookies enabled, check cookie domain | | **PKCE verifier not found** | Verifier cookie expired | Complete auth flow within 5 minutes | | **JWT verification failed** | Invalid signature or expired token | Verify JWKS URL, check system clock | | **Token exchange failed** | Invalid code or client credentials | Check client ID/secret, redirect URI exact match | | **Too many requests (429)** | Rate limit exceeded | Wait for limit window to expire | **Error Handling Pattern:** ```typescript // server/api/auth/callback.get.ts try { // OAuth2 flow const tokens = await exchangeCodeForToken(code, verifier) // ... create session return sendRedirect(event, '/') } catch (error) { console.error('OAuth callback error:', error) // Clean up cookies deleteCookie(event, 'oauth_state') deleteCookie(event, 'pkce_verifier') // Redirect to login with error return sendRedirect(event, '/auth?error=login_failed') } ``` #### Performance Considerations **JWKS Caching:** - Cidaas JWKS endpoint cached for 1 hour (jose default) - Reduces latency on token validation - Auto-refreshes when keys rotated **Session Cookie Size:** - Typical size: ~500 bytes (encrypted) - Transmitted on every request (overhead minimal) - Consider Redis sessions for very large user objects **Database Queries:** - User lookup by `experimenta_id` (indexed) - Single query on login (no joins needed) - No additional queries per request (session-based) --- ## 4. Datenbankschema ### 4.1 ER-Diagramm (Textual) ``` ┌─────────────────────┐ │ User │ ├─────────────────────┤ │ id (PK) │ │ experimenta_id (UQ) │ │ email │ │ first_name │ │ last_name │ │ phone │ │ salutation │ │ date_of_birth │ │ street │ │ post_code │ │ city │ │ country_code │ │ created_at │ │ updated_at │ └──────────┬──────────┘ │ │ 1:N │ ┌──────────▼──────────┐ ┌─────────────────────┐ │ Cart │ │ Product │ ├─────────────────────┤ ├─────────────────────┤ │ id (PK) │ │ id (PK) │ │ user_id (FK) │ │ nav_product_id (UQ) │ │ session_id │ │ name │ │ created_at │ │ description │ │ updated_at │ │ price │ └──────────┬──────────┘ │ image_url │ │ │ stock │ │ 1:N │ status │ │ │ created_at │ ┌──────────▼──────────┐ │ updated_at │ │ CartItem │ └──────────┬──────────┘ ├─────────────────────┤ │ │ id (PK) │ │ │ cart_id (FK) │ │ │ product_id (FK) ────┼────────────────────┘ │ quantity │ │ price_snapshot │ │ created_at │ └─────────────────────┘ ┌─────────────────────┐ │ Order │ ├─────────────────────┤ │ id (PK) │ │ order_number (UQ) │ │ user_id (FK) ───────┼────> User │ status │ │ total_amount │ │ payment_method │ │ payment_id │ │ billing_address │ │ created_at │ │ updated_at │ └──────────┬──────────┘ │ │ 1:N │ ┌──────────▼──────────┐ │ OrderItem │ ├─────────────────────┤ │ id (PK) │ │ order_id (FK) │ │ product_id (FK) ────┼────> Product │ quantity │ │ price │ │ created_at │ └─────────────────────┘ ┌─────────────────────┐ │ Role │ ├─────────────────────┤ │ id (PK) │ │ code (UQ) │ ('private', 'educator', 'company') │ display_name │ │ description │ │ requires_approval │ │ sort_order │ │ active │ │ created_at │ │ updated_at │ └──────────┬──────────┘ │ │ M:N │ ┌──────────▼──────────┐ ┌─────────────────────┐ │ UserRole │ │ ProductRoleVis... │ ├─────────────────────┤ ├─────────────────────┤ │ id (PK) │ │ id (PK) │ │ user_id (FK) ───────┼────> │ product_id (FK) ────┼────> Product │ role_id (FK) ───────┼────> │ role_id (FK) ───────┼────> Role │ status │ │ created_at │ │ organization_name │ └─────────────────────┘ │ admin_notes │ │ status_history │ (JSONB) │ created_at │ │ updated_at │ └─────────────────────┘ ``` **Rollen-System (MVP - Datenbankstruktur):** - **roles**: Rollen-Definitionen (private, educator, company) - **user_roles**: Many-to-Many User ↔ Rollen mit Antrags-Workflow (vorbereitet für Phase 2/3) - **product_role_visibility**: Many-to-Many Produkt ↔ Rollen (Sichtbarkeitssteuerung) **Opt-in Sichtbarkeit:** - Produkte OHNE `product_role_visibility` Einträge sind für NIEMANDEN sichtbar - Produkte MIT Einträgen sind nur für User mit passender `approved` Rolle sichtbar ### 4.2 Drizzle Schema Definition ```typescript // server/database/schema.ts import { pgTable, uuid, varchar, timestamp, decimal, integer, text, jsonb, pgEnum, } from 'drizzle-orm/pg-core' // Enums export const orderStatusEnum = pgEnum('order_status', [ 'pending', 'paid', 'processing', 'completed', 'cancelled', ]) export const productStatusEnum = pgEnum('product_status', ['active', 'inactive']) export const paymentMethodEnum = pgEnum('payment_method', ['paypal']) // Users export const users = pgTable('users', { id: uuid('id').defaultRandom().primaryKey(), experimentaId: varchar('experimenta_id', { length: 255 }).notNull().unique(), email: varchar('email', { length: 255 }).notNull().unique(), firstName: varchar('first_name', { length: 100 }), lastName: varchar('last_name', { length: 100 }), phone: varchar('phone', { length: 20 }), // Billing address fields (optional - filled during checkout or profile edit) salutation: varchar('salutation', { length: 20 }), // 'male', 'female', 'other' dateOfBirth: date('date_of_birth'), // Required for annual passes street: varchar('street', { length: 100 }), postCode: varchar('post_code', { length: 20 }), city: varchar('city', { length: 100 }), countryCode: varchar('country_code', { length: 10 }).default('DE'), createdAt: timestamp('created_at').defaultNow().notNull(), updatedAt: timestamp('updated_at').defaultNow().notNull(), }) // Products export const products = pgTable('products', { id: uuid('id').defaultRandom().primaryKey(), navProductId: varchar('nav_product_id', { length: 100 }).notNull().unique(), name: varchar('name', { length: 255 }).notNull(), description: text('description'), price: decimal('price', { precision: 10, scale: 2 }).notNull(), imageUrl: varchar('image_url', { length: 500 }), stock: integer('stock').default(0).notNull(), status: productStatusEnum('status').default('active').notNull(), createdAt: timestamp('created_at').defaultNow().notNull(), updatedAt: timestamp('updated_at').defaultNow().notNull(), }) // Carts export const carts = pgTable('carts', { id: uuid('id').defaultRandom().primaryKey(), userId: uuid('user_id').references(() => users.id, { onDelete: 'cascade' }), sessionId: varchar('session_id', { length: 255 }), createdAt: timestamp('created_at').defaultNow().notNull(), updatedAt: timestamp('updated_at').defaultNow().notNull(), }) // Cart Items export const cartItems = pgTable('cart_items', { id: uuid('id').defaultRandom().primaryKey(), cartId: uuid('cart_id') .references(() => carts.id, { onDelete: 'cascade' }) .notNull(), productId: uuid('product_id') .references(() => products.id) .notNull(), quantity: integer('quantity').default(1).notNull(), priceSnapshot: decimal('price_snapshot', { precision: 10, scale: 2 }).notNull(), createdAt: timestamp('created_at').defaultNow().notNull(), }) // Orders export const orders = pgTable('orders', { id: uuid('id').defaultRandom().primaryKey(), orderNumber: varchar('order_number', { length: 50 }).notNull().unique(), userId: uuid('user_id') .references(() => users.id) .notNull(), status: orderStatusEnum('status').default('pending').notNull(), totalAmount: decimal('total_amount', { precision: 10, scale: 2 }).notNull(), paymentMethod: paymentMethodEnum('payment_method').notNull(), paymentId: varchar('payment_id', { length: 255 }), billingAddress: jsonb('billing_address'), createdAt: timestamp('created_at').defaultNow().notNull(), updatedAt: timestamp('updated_at').defaultNow().notNull(), }) // Order Items export const orderItems = pgTable('order_items', { id: uuid('id').defaultRandom().primaryKey(), orderId: uuid('order_id') .references(() => orders.id, { onDelete: 'cascade' }) .notNull(), productId: uuid('product_id') .references(() => products.id) .notNull(), quantity: integer('quantity').default(1).notNull(), price: decimal('price', { precision: 10, scale: 2 }).notNull(), createdAt: timestamp('created_at').defaultNow().notNull(), }) ``` --- ## 5. API-Architektur ### 5.1 REST API Struktur ``` /api /auth /login POST - Initiate Cidaas OAuth /callback GET - OAuth callback handler /logout POST - End session /me GET - Get current user /products / GET - List all products /:id GET - Get product details /cart / GET - Get current cart /items POST - Add item to cart /items/:id PATCH - Update item quantity /items/:id DELETE - Remove item from cart /orders / GET - List user's orders / POST - Create new order /:id GET - Get order details /payment /paypal /create POST - Create PayPal order /capture POST - Capture PayPal payment /webhook POST - PayPal webhook handler /erp /products POST - Receive product updates from NAV /stock POST - Update stock levels ``` ### 5.2 API Authentication **Client-to-Server:** - Session-based (HTTP-only Cookie) - Cookie: `__session` (encrypted) - Nuxt `useSession()` composable **ERP-to-Server:** - API Key Authentication - Header: `X-API-Key: ` - Stored in Environment Variables **Cidaas OAuth:** - OAuth 2.0 Authorization Code Flow - PKCE for additional security --- ## 6. Sicherheitsarchitektur ### 6.1 Authentifizierung & Autorisierung ``` ┌─────────────────────────────────────────────────────────────┐ │ Security Layers │ ├─────────────────────────────────────────────────────────────┤ │ │ │ Layer 1: Transport Security (HTTPS/TLS 1.3) │ │ ├─ Let's Encrypt SSL Certificate │ │ ├─ HSTS Headers │ │ └─ Secure Cookies (httpOnly, secure, sameSite) │ │ │ │ Layer 2: Authentication (Cidaas OAuth2) │ │ ├─ Authorization Code Flow + PKCE │ │ ├─ JWT Token Validation (jose) │ │ └─ Session Management (encrypted cookies) │ │ │ │ Layer 3: Authorization (Route Guards) │ │ ├─ Middleware: requireAuth() │ │ ├─ API Middleware: validateApiKey() │ │ └─ Resource Ownership Checks │ │ │ │ Layer 4: Input Validation (Zod) │ │ ├─ Request Body Validation │ │ ├─ Query Params Validation │ │ └─ SQL Injection Prevention (Drizzle) │ │ │ │ Layer 5: Rate Limiting │ │ ├─ Nginx: 100 req/min per IP │ │ ├─ API Endpoints: Custom limits │ │ └─ Redis-based tracking (optional) │ │ │ │ Layer 6: CORS & CSP │ │ ├─ CORS: Only experimenta.science │ │ ├─ CSP Headers │ │ └─ X-Frame-Options, X-Content-Type-Options │ │ │ └─────────────────────────────────────────────────────────────┘ ``` ### 6.2 Secrets Management **Environment Variables:** ```bash # .env (NEVER commit!) DATABASE_URL=postgresql://user:pass@db:5432/experimenta # Cidaas Authentication CIDAAS_CLIENT_ID=xxx CIDAAS_CLIENT_SECRET=xxx # PayPal Payment PAYPAL_CLIENT_ID=xxx PAYPAL_CLIENT_SECRET=xxx # X-API Integration (Basic Auth) X_API_BASE_URL=https://x-api-dev.experimenta.science X_API_USERNAME=shop_user_dev X_API_PASSWORD=xxx # Internal API Security ERP_API_KEY=xxx SESSION_SECRET=xxx # Email (Optional) SMTP_USER=xxx SMTP_PASS=xxx # Monitoring (Optional) SENTRY_DSN=xxx ``` **Docker Secrets (Production):** ```yaml # docker-compose.yml services: app: environment: - DATABASE_URL_FILE=/run/secrets/db_url secrets: - db_url - cidaas_secret - paypal_secret - xapi_username - xapi_password worker: # Workers need X-API credentials for order submission secrets: - xapi_username - xapi_password secrets: db_url: file: ./secrets/db_url.txt cidaas_secret: file: ./secrets/cidaas.txt paypal_secret: file: ./secrets/paypal.txt xapi_username: file: ./secrets/xapi_username.txt xapi_password: file: ./secrets/xapi_password.txt ``` --- ## 7. Deployment-Architektur ### 7.1 Production Setup (Hetzner) ``` ┌─────────────────────────────────────────────────────────────┐ │ Hetzner Dedicated Server / VPS │ │ │ │ ┌─────────────────────────────────────────────────────┐ │ │ │ Proxmox Virtualization │ │ │ │ │ │ │ │ ┌───────────────────────────────────────────────┐ │ │ │ │ │ Docker Container (Production) │ │ │ │ │ │ │ │ │ │ │ │ ┌─────────────────────────────────────────┐ │ │ │ │ │ │ │ Nginx (Reverse Proxy) │ │ │ │ │ │ │ │ - SSL Termination (Let's Encrypt) │ │ │ │ │ │ │ │ - Load Balancing │ │ │ │ │ │ │ │ - Rate Limiting │ │ │ │ │ │ │ │ - Static File Serving │ │ │ │ │ │ │ └─────────────┬───────────────────────────┘ │ │ │ │ │ │ │ │ │ │ │ │ │ ┌─────────────▼───────────────────────────┐ │ │ │ │ │ │ │ Nuxt App (Node.js) │ │ │ │ │ │ │ │ - Port 3000 │ │ │ │ │ │ │ │ - PM2 Process Manager (optional) │ │ │ │ │ │ │ │ - Multiple Instances (Clustering) │ │ │ │ │ │ │ └─────────────┬───────────────────────────┘ │ │ │ │ │ │ │ │ │ │ │ │ │ ┌─────────────▼───────────────────────────┐ │ │ │ │ │ │ │ PostgreSQL 16 │ │ │ │ │ │ │ │ - Port 5432 (internal only) │ │ │ │ │ │ │ │ - Persistent Volume │ │ │ │ │ │ │ │ - Daily Backups │ │ │ │ │ │ │ └─────────────────────────────────────────┘ │ │ │ │ │ │ │ │ │ │ │ │ Optional: │ │ │ │ │ │ ┌─────────────────────────────────────────┐ │ │ │ │ │ │ │ Redis (Caching/Sessions) │ │ │ │ │ │ │ └─────────────────────────────────────────┘ │ │ │ │ │ │ │ │ │ │ │ └───────────────────────────────────────────────┘ │ │ │ │ │ │ │ └─────────────────────────────────────────────────────┘ │ │ │ └─────────────────────────────────────────────────────────────┘ ``` ### 7.2 Docker Compose (Production) ```yaml # docker-compose.prod.yml version: '3.9' services: nginx: image: nginx:alpine ports: - '80:80' - '443:443' volumes: - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro - ./nginx/ssl:/etc/nginx/ssl:ro - ./public:/usr/share/nginx/html:ro depends_on: - app restart: always app: build: context: . dockerfile: Dockerfile environment: - NODE_ENV=production - DATABASE_URL=${DATABASE_URL} - REDIS_URL=redis://redis:6379 - CIDAAS_CLIENT_ID=${CIDAAS_CLIENT_ID} - CIDAAS_CLIENT_SECRET=${CIDAAS_CLIENT_SECRET} - PAYPAL_CLIENT_ID=${PAYPAL_CLIENT_ID} - PAYPAL_CLIENT_SECRET=${PAYPAL_CLIENT_SECRET} depends_on: - db - redis restart: always deploy: replicas: 2 # For load balancing worker: build: context: . dockerfile: Dockerfile command: node server/workers/index.js environment: - NODE_ENV=production - DATABASE_URL=${DATABASE_URL} - REDIS_URL=redis://redis:6379 - X_API_BASE_URL=${X_API_BASE_URL} depends_on: - db - redis restart: always deploy: replicas: 2 # 2 Worker-Instanzen für Parallelverarbeitung redis: image: redis:7-alpine command: > redis-server --appendonly yes --appendfsync everysec --save 60 1000 --save 300 100 --save 900 1 volumes: - redis_data:/data restart: always healthcheck: test: ['CMD', 'redis-cli', 'ping'] interval: 10s timeout: 3s retries: 3 db: image: postgres:16-alpine environment: - POSTGRES_DB=experimenta - POSTGRES_USER=${DB_USER} - POSTGRES_PASSWORD=${DB_PASSWORD} volumes: - postgres_data:/var/lib/postgresql/data - ./backups:/backups restart: always healthcheck: test: ['CMD-SHELL', 'pg_isready -U ${DB_USER}'] interval: 10s timeout: 5s retries: 5 volumes: postgres_data: driver: local redis_data: driver: local ``` --- ## 8. CI/CD Pipeline ### 8.1 GitLab CI/CD Flow ``` ┌──────────────────────────────────────────────────────────────┐ │ GitLab CI/CD Pipeline │ ├──────────────────────────────────────────────────────────────┤ │ │ │ Stage 1: Build │ │ ├─ Install Dependencies (pnpm install) │ │ ├─ Build Nuxt App (npm run build) │ │ ├─ Build Docker Image │ │ └─ Push to Container Registry │ │ │ │ Stage 2: Test │ │ ├─ Lint (ESLint + Prettier) │ │ ├─ Unit Tests (Vitest) │ │ ├─ Integration Tests │ │ └─ E2E Tests (Playwright) - on Staging │ │ │ │ Stage 3: Deploy Staging (Auto) │ │ ├─ Pull Docker Image │ │ ├─ Run Database Migrations │ │ ├─ Deploy to Staging Environment │ │ └─ Smoke Tests │ │ │ │ Stage 4: Deploy Production (Manual) │ │ ├─ Manual Approval Required │ │ ├─ Pull Docker Image │ │ ├─ Run Database Migrations │ │ ├─ Blue-Green Deployment │ │ │ ├─ Start new container (green) │ │ │ ├─ Health check │ │ │ ├─ Switch traffic to green │ │ │ └─ Stop old container (blue) │ │ └─ Post-deployment Tests │ │ │ └──────────────────────────────────────────────────────────────┘ ``` ### 8.2 GitLab CI Configuration ```yaml # .gitlab-ci.yml stages: - build - test - deploy_staging - deploy_production variables: DOCKER_IMAGE: $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA DOCKER_IMAGE_LATEST: $CI_REGISTRY_IMAGE:latest build: stage: build image: node:20-alpine script: - pnpm install --frozen-lockfile - pnpm run build - docker build -t $DOCKER_IMAGE . - docker tag $DOCKER_IMAGE $DOCKER_IMAGE_LATEST - docker push $DOCKER_IMAGE - docker push $DOCKER_IMAGE_LATEST only: - main - develop test: stage: test image: node:20-alpine script: - pnpm install --frozen-lockfile - pnpm run lint - pnpm run test coverage: '/All files[^|]*\|[^|]*\s+([\d\.]+)/' artifacts: reports: coverage_report: coverage_format: cobertura path: coverage/cobertura-coverage.xml deploy_staging: stage: deploy_staging script: - ssh deployer@staging.experimenta.science " docker pull $DOCKER_IMAGE && cd /opt/experimenta && docker-compose -f docker-compose.staging.yml down && docker-compose -f docker-compose.staging.yml up -d " environment: name: staging url: https://staging.my.experimenta.science only: - develop deploy_production: stage: deploy_production script: - ssh deployer@my.experimenta.science " docker pull $DOCKER_IMAGE && cd /opt/experimenta && ./scripts/blue-green-deploy.sh $DOCKER_IMAGE " environment: name: production url: https://my.experimenta.science when: manual only: - main ``` --- ## 9. Monitoring & Observability ### 9.1 Logging-Strategie **Strukturiertes Logging:** ```typescript // server/utils/logger.ts import pino from 'pino' export const logger = pino({ level: process.env.LOG_LEVEL || 'info', formatters: { level: (label) => { return { level: label } }, }, timestamp: pino.stdTimeFunctions.isoTime, }) // Usage logger.info({ userId: '123', action: 'checkout' }, 'User completed checkout') logger.error({ err, userId: '123' }, 'Payment failed') ``` **Log-Aggregation (Optional):** - Loki + Grafana - Elasticsearch + Kibana - CloudWatch Logs (wenn auf AWS) --- ### 9.2 Error Tracking (Sentry) ```typescript // nuxt.config.ts export default defineNuxtConfig({ modules: ['@nuxtjs/sentry'], sentry: { dsn: process.env.SENTRY_DSN, environment: process.env.NODE_ENV, tracing: { tracesSampleRate: 0.1, // 10% of transactions }, }, }) ``` --- ### 9.3 Performance Monitoring **Metrics to Track:** - Response Time (p50, p95, p99) - Error Rate - Request Rate - Database Query Time - Cache Hit Rate (if Redis) - Active Users **Tools:** - Sentry Performance Monitoring - Grafana + Prometheus (self-hosted) - New Relic (commercial) --- ## 10. Backup & Disaster Recovery ### 10.1 Backup-Strategie **Datenbank Backups:** ```bash # Cron Job: Täglich um 2 Uhr 0 2 * * * docker exec experimenta-db pg_dump -U user experimenta > /backups/db-$(date +\%Y\%m\%d).sql ``` **Retention Policy:** - Täglich: 7 Tage - Wöchentlich: 4 Wochen - Monatlich: 12 Monate **Backup-Speicherort:** - Lokal: `/backups` (auf Host) - Remote: Hetzner Storage Box (SFTP) - Optional: AWS S3 / Backblaze B2 --- ### 10.2 Disaster Recovery Plan **RTO (Recovery Time Objective):** 4 Stunden **RPO (Recovery Point Objective):** 24 Stunden (tägliche Backups) **Recovery Steps:** 1. Neuen Server bereitstellen (Proxmox oder Hetzner Cloud) 2. Docker & Docker Compose installieren 3. PostgreSQL Container starten 4. Letztes Backup wiederherstellen 5. App Container starten 6. DNS auf neuen Server umstellen 7. SSL-Zertifikat neu ausstellen (Let's Encrypt) --- ## 11. Skalierungsstrategie ### 11.1 Vertikale Skalierung (Short-term) - Mehr CPU/RAM für Server - Upgrade: von 4 Cores / 8GB auf 8 Cores / 16GB - PostgreSQL Tuning (shared_buffers, work_mem) --- ### 11.2 Horizontale Skalierung (Long-term) **Load Balancer:** ``` ┌────────────────┐ │ Load Balancer │ │ (Nginx) │ └───────┬────────┘ │ ┌───────────────┼───────────────┐ │ │ │ ┌──────▼─────┐ ┌──────▼─────┐ ┌──────▼─────┐ │ App │ │ App │ │ App │ │ Instance 1│ │ Instance 2│ │ Instance 3│ └──────┬─────┘ └──────┬─────┘ └──────┬─────┘ │ │ │ └───────────────┼───────────────┘ │ ┌──────▼─────┐ │ PostgreSQL │ │ (Primary) │ └──────┬─────┘ │ ┌──────▼─────┐ │ PostgreSQL │ │ (Replica) │ └────────────┘ ``` **Schritte:** 1. Mehrere App-Instanzen (Docker Replicas) 2. PostgreSQL Read Replicas 3. Redis für Session-Storage (shared state) 4. CDN für Static Assets (Cloudflare) --- ## 12. Zusammenfassung **Key Architectural Decisions:** | Aspekt | Entscheidung | Begründung | | -------------- | ----------------- | ------------------------------------ | | **Framework** | Nuxt 4 | Full-Stack, SSR, TypeScript | | **UI** | shadcn-nuxt | Maximale Flexibilität | | **Database** | PostgreSQL | ACID, bewährt für E-Commerce | | **ORM** | Drizzle | Performance, TypeScript-first | | **Auth** | Cidaas + Local DB | Unternehmens-Standard + Flexibilität | | **Payment** | PayPal | Weit verbreitet, einfach | | **i18n** | @nuxtjs/i18n | Deutsch + English, SEO-optimiert | | **Deployment** | Docker | Portabilität, Konsistenz | | **Hosting** | Hetzner | Kostengünstig, EU-basiert | | **CI/CD** | GitLab | Bereits intern vorhanden | --- **Ende des Dokuments**