# HMS Licht & Ton – System Architecture > **Projekt:** hms-licht-ton (ID 30) > **Datum:** 2026-07-08 > **Status:** Draft – Ready for Review (Rev. 2 – Quality Gate Fixes) > **Autor:** Solution Architect (A0 Orchestrator) --- ## 1. Architektur-Ueberblick ### 1.1 System-Komponenten ``` ┌──────────────────────────────────────────────────────────────┐ │ Coolify (Docker) │ │ ┌────────────┐ ┌──────────┐ ┌──────────────────┐ │ │ │ Nuxt 3 SSR │ │ FastAPI │ │ PostgreSQL │ │ │ │ (Frontend) │ │(Backend) │ │ (equipment_cache │ │ │ │ Port 3000 │ │ Port 8000│ │ rental_requests)│ │ │ └─────┬──────┘ └────┬─────┘ └────────┬─────────┘ │ │ │ │ │ │ │ │ /api/* │ SQLAlchemy │ │ │ └───────────────►│ │ │ │ │ ┌──────────┐ │ │ │ │ │ Redis │ │ │ │ │ │ (cache, │ │ │ │ │ │ rate-lim)│ │ │ │ │ └────┬─────┘ │ │ │ │ │ │ │ │ ┌─────────────────────────────┴──┴──┐ │ │ │ │ Traefik (TLS, Routing, HSTS) │ │ │ │ │ hms.media-on.de → Nuxt 3000 │ │ │ │ │ hms.media-on.de/api → FastAPI 8000│ │ │ │ └───────────────────────────────────┘ │ │ └──────────────────────────────────────────────────────────────┘ │ │ HTTPS ▼ ┌─────────────────┐ │ Rentman API │ │ api.rentman.net│ │ (Bearer Token) │ └─────────────────┘ ``` ### 1.2 Container-Topologie | Container | Image | Port | Abhaengigkeiten | |-----------|-------|------|----------------| | `hms-frontend` | Node 20 + Nuxt 3 | 3000 | `hms-backend` | | `hms-backend` | Python 3.12 + FastAPI | 8000 | `hms-postgres`, `hms-redis` | | `hms-postgres` | PostgreSQL 16 | 5432 | – | | `hms-redis` | Redis 7 | 6379 | – | **Hinweis:** Es gibt keinen separaten Worker-Container. Der Equipment-Cron-Sync laeuft via APScheduler in-process innerhalb des `hms-backend` Containers (siehe §5.1 und ADR-008). Traefik (Coolify-eigen) routet: - `hms.media-on.de/*` → `hms-frontend:3000` - `hms.media-on.de/api/*` → `hms-backend:8000` --- ## 2. Frontend-Architektur (Nuxt 3) ### 2.1 Rendering-Strategie | Route | Modus | Begruendung | |-------|-------|------------| | `/` (Home) | SSG (`nuxt generate`) | Statischer Content, SEO/SSR nicht noetig (noindex) | | `/referenzen` | SSG | Statische Bilder, keine API noetig | | `/mietkatalog` | SSR (`server`) | Dynamische Equipment-Daten vom Backend | | `/mietkatalog/:id` | SSR | Equipment-Detail vom Backend | | `/warenkorb` | CSR | Reine Client-State (localStorage) | | `/mietanfrage` | CSR | Formular mit Client-State (Warenkorb) | | `/kontakt` | SSG | Statisches Formular, API-Call bei Submit | | `/admin` | CSR | Auth-basiert, keine Public-SSR noetig | | `/impressum` | SSG | Statischer Content | | `/datenschutz` | SSG | Statischer Content | | `/agb-vermietung` | SSG | Statischer Content | **Konfiguration:** `nuxt.config.ts` mit `routeRules`: ```typescript export default defineNuxtConfig({ routeRules: { '/': { prerender: true }, '/referenzen': { prerender: true }, '/mietkatalog': { ssr: true }, '/mietkatalog/**': { ssr: true }, '/warenkorb': { ssr: false }, '/mietanfrage': { ssr: false }, '/kontakt': { prerender: true }, '/admin': { ssr: false }, '/impressum': { prerender: true }, '/datenschutz': { prerender: true }, '/agb-vermietung': { prerender: true }, } }) ``` ### 2.2 Verzeichnisstruktur ``` frontend/ ├── nuxt.config.ts ├── tailwind.config.ts ├── package.json ├── tsconfig.json ├── app.vue ├── assets/ │ ├── css/ │ │ └── main.css (Design Tokens, Tailwind imports) │ └── img/ │ ├── logo.svg │ ├── hero-bg.jpg │ └── ref/ (Referenz-Bilder ref1-9.jpg) ├── components/ │ ├── HmsLogo.vue │ ├── SpeakerIcon.vue │ ├── AppHeader.vue │ ├── AppFooter.vue │ ├── ServiceCard.vue │ ├── EquipmentCard.vue │ ├── LoadingSkeleton.vue │ ├── EmptyState.vue │ ├── ErrorState.vue │ ├── LegalContentPage.vue │ ├── Lightbox.vue │ └── CartDrawer.vue ├── composables/ │ ├── useEquipment.ts (API fetch + caching) │ ├── useCart.ts (Pinia store wrapper) │ └── useApi.ts (Base API client) ├── layouts/ │ └── default.vue (Header + Footer + slot) ├── pages/ │ ├── index.vue (Home) │ ├── referenzen.vue │ ├── mietkatalog/ │ │ ├── index.vue │ │ └── [id].vue │ ├── warenkorb.vue │ ├── mietanfrage.vue │ ├── kontakt.vue │ ├── admin.vue │ ├── impressum.vue │ ├── datenschutz.vue │ ├── agb-vermietung.vue │ └── [...slug].vue (404 catch-all) ├── stores/ │ └── cart.ts (Pinia store, localStorage persist) ├── plugins/ │ └── pinia-persist.client.ts ├── public/ │ └── robots.txt └── server/ └── middleware/ └── headers.ts (noindex headers) ``` ### 2.3 State Management | State | Loesung | Persistenz | |-------|--------|------------| | Warenkorb (Artikel + Mengen) | Pinia store `cart.ts` | localStorage (client-side) | | UI-State (mobile menu, lightbox) | Component-local `ref()` | keine | | Equipment-Daten | Nuxt `useFetch` / `useAsyncData` | Nuxt payload cache (SSR) | | Admin-Auth | JWT in HttpOnly Cookie | Cookie (24h) | ### 2.4 Design Token System Implementiert als CSS Custom Properties in `assets/css/main.css` und Tailwind-Config: ```css :root { /* A0 Dark Theme */ --bg: #131313; --panel: #1a1a1a; --surface: #212121; --row: #272727; --card: #2d2d2d; --border: #444444a8; --secondary: #656565; --primary: #737a81; --text: #ffffff; --text-muted: #d4d4d4; /* Orange Akzent */ --color-accent: #EC6925; --color-accent-hover: #d4581a; --color-accent-light: rgba(236, 105, 37, 0.08); --color-accent-border: rgba(236, 105, 37, 0.25); /* Status */ --color-success: #4ade80; --color-error: #f87171; --color-warning: #fbbf24; --color-info: #60a5fa; /* Radius */ --radius-sm: 2px; --radius-md: 3px; --radius-lg: 4px; /* Transitions */ --transition-fast: 150ms ease; --transition-base: 250ms ease; --transition-slow: 400ms ease; } ``` Tailwind-Config extend mit denselben Werten als utility classes. --- ## 3. Backend-Architektur (FastAPI) ### 3.1 Verzeichnisstruktur ``` backend/ ├── app/ │ ├── __init__.py │ ├── main.py (FastAPI app, CORS, routers, APScheduler startup) │ ├── config.py (Pydantic Settings, env vars) │ ├── database.py (SQLAlchemy engine, session) │ ├── models/ │ │ ├── __init__.py │ │ ├── equipment.py (EquipmentCache model) │ │ ├── rental_request.py (RentalRequest + RentalRequestItem) │ │ ├── contact.py (Contact model) │ │ ├── admin_user.py (AdminUser model) │ │ └── sync_log.py (SyncLog model) │ ├── schemas/ │ │ ├── __init__.py │ │ ├── equipment.py (Pydantic schemas) │ │ ├── rental_request.py │ │ ├── contact.py │ │ ├── auth.py │ │ └── sync.py │ ├── routers/ │ │ ├── __init__.py │ │ ├── equipment.py (GET /equipment, GET /equipment/{id}) │ │ ├── rental_requests.py (POST /rental-requests) │ │ ├── contact.py (POST /contact) │ │ ├── auth.py (POST /admin/login, GET /admin/me) │ │ ├── admin.py (POST /admin/sync, GET /admin/sync-status) │ │ └── health.py (GET /health) │ ├── services/ │ │ ├── rentman_import.py (Equipment-Import pipeline) │ │ ├── rentman_request.py (Mietanfrage pipeline) │ │ ├── email_service.py (SMTP email send) │ │ └── auth_service.py (JWT create/verify) │ └── cache.py (Redis client, cache helpers) ├── tests/ (Pytest tests) ├── alembic/ (Migrations) ├── requirements.txt ├── Dockerfile └── alembic.ini ``` **Hinweis:** Die Struktur verwendet `backend/app/` als Package-Root. Imports erfolgen via `from app.main import app`, `from app.models.equipment import EquipmentCache`, etc. ### 3.2 API-Design Alle Endpunkte unter Prefix `/api`. #### Equipment Endpoints | Method | Path | Beschreibung | Query Params | Response | |--------|------|--------------|--------------|----------| | GET | `/api/equipment` | Equipment-Liste | `search`, `category`, `sort` (name_asc, name_desc), `page`, `page_size` | `200` PaginatedResponse | | GET | `/api/equipment/{id}` | Equipment-Detail | – | `200` EquipmentDetail / `404` | | GET | `/api/equipment/categories` | Alle Kategorien | – | `200` string[] | **PaginatedResponse Schema:** ```json { "items": [...], "total": 500, "page": 1, "page_size": 20, "total_pages": 25 } ``` **EquipmentItem Schema:** ```json { "id": 123, "rentman_id": "456", "name": "L-Acoustics K2", "number": "K2-001", "category": "Lautsprecher", "description": "Line Array Element", "image_url": "/img/equipment/k2-001.jpg", "rental_price": null, "available": true } ``` **EquipmentDetail Schema (erweitert):** ```json { "id": 123, "rentman_id": "456", "name": "L-Acoustics K2", "number": "K2-001", "category": "Lautsprecher", "description": "Full description...", "specifications": {"weight": 56, "power": 750, ...}, "images": ["url1", "url2"], "rental_price": null, "available": true, "brand": "L-Acoustics" } ``` #### Rental Request Endpoints | Method | Path | Beschreibung | Body | Response | |--------|------|--------------|------|----------| | POST | `/api/rental-requests` | Mietanfrage erstellen | RentalRequestCreate | `201` {reference_number} / `422` | **RentalRequestCreate Schema:** ```json { "event_name": "Sommerfest 2026", "date_start": "2026-08-15", "date_end": "2026-08-16", "location": "Muenchen", "person_count": 100, "contact_name": "Max Mustermann", "contact_company": "Firma GmbH", "contact_email": "max@example.com", "contact_phone": "+49 170 1234567", "contact_street": "Hauptstr. 1", "contact_postalcode": "80000", "contact_city": "Muenchen", "message": "Brauchen PA fuer Open-Air", "items": [ {"equipment_id": 123, "quantity": 2}, {"equipment_id": 456, "quantity": 4} ] } ``` #### Contact Endpoint | Method | Path | Beschreibung | Body | Response | |--------|------|--------------|------|----------| | POST | `/api/contact` | Kontaktformular | ContactCreate | `200` {success: true} / `422` | **ContactCreate Schema:** ```json { "name": "Max Mustermann", "email": "max@example.com", "phone": "+49 170 1234567", "message": "Anfrage bezueglich...", "privacy_consent": true } ``` #### Admin/Auth Endpoints | Method | Path | Beschreibung | Auth | Response | |--------|------|--------------|------|----------| | POST | `/api/admin/login` | Admin-Login | – | `200` {token} / `401` | | GET | `/api/admin/me` | Aktueller Admin | JWT | `200` {username} / `401` | | POST | `/api/admin/sync` | Sync triggern | JWT | `200` {sync_id} / `401` | | GET | `/api/admin/sync-status` | Sync-Status | JWT | `200` SyncStatus | | GET | `/api/admin/sync-log` | Sync-Log (paginiert) | JWT | `200` SyncLog[] | #### Health Endpoint | Method | Path | Response | |--------|------|----------| | GET | `/api/health` | `200` {status: "ok", db: "connected", redis: "connected"} | --- ## 4. Datenbank-Schema (PostgreSQL) ### 4.1 Tabellen ```sql -- Equipment Cache (aus Rentman importiert) CREATE TABLE equipment_cache ( id SERIAL PRIMARY KEY, rentman_id VARCHAR(64) UNIQUE NOT NULL, name VARCHAR(255) NOT NULL, number VARCHAR(64), category VARCHAR(128), subcategory VARCHAR(128), description TEXT, specifications JSONB, images JSONB, rental_price DECIMAL(10, 2), brand VARCHAR(128), available BOOLEAN DEFAULT true, created_at TIMESTAMP DEFAULT NOW(), updated_at TIMESTAMP DEFAULT NOW() ); CREATE INDEX idx_equipment_category ON equipment_cache(category); CREATE INDEX idx_equipment_name ON equipment_cache(name); CREATE INDEX idx_equipment_number ON equipment_cache(number); -- Rental Requests (Mietanfragen) CREATE TABLE rental_requests ( id SERIAL PRIMARY KEY, reference_number VARCHAR(32) UNIQUE NOT NULL, event_name VARCHAR(255) NOT NULL, date_start DATE NOT NULL, date_end DATE NOT NULL, location VARCHAR(255), person_count INTEGER, contact_name VARCHAR(255) NOT NULL, contact_company VARCHAR(255), contact_email VARCHAR(255) NOT NULL, contact_phone VARCHAR(64), contact_street VARCHAR(255), contact_postalcode VARCHAR(16), contact_city VARCHAR(128), message TEXT, status VARCHAR(32) DEFAULT 'pending', rentman_request_id VARCHAR(64), rentman_sync_status VARCHAR(32) DEFAULT 'pending', rentman_sync_error TEXT, created_at TIMESTAMP DEFAULT NOW() ); CREATE INDEX idx_rental_status ON rental_requests(status); -- Rental Request Items CREATE TABLE rental_request_items ( id SERIAL PRIMARY KEY, rental_request_id INTEGER REFERENCES rental_requests(id) ON DELETE CASCADE, equipment_id INTEGER REFERENCES equipment_cache(id), equipment_name VARCHAR(255), rentman_equipment_id VARCHAR(64), quantity INTEGER NOT NULL DEFAULT 1, unit_price DECIMAL(10, 2), rentman_sync_status VARCHAR(32) DEFAULT 'pending' ); CREATE INDEX idx_rental_items_request ON rental_request_items(rental_request_id); -- Contacts (Kontaktformular) CREATE TABLE contacts ( id SERIAL PRIMARY KEY, name VARCHAR(255) NOT NULL, email VARCHAR(255) NOT NULL, phone VARCHAR(64), message TEXT NOT NULL, privacy_consent BOOLEAN NOT NULL, email_sent BOOLEAN DEFAULT false, created_at TIMESTAMP DEFAULT NOW() ); -- Admin Users CREATE TABLE admin_users ( id SERIAL PRIMARY KEY, username VARCHAR(64) UNIQUE NOT NULL, password_hash VARCHAR(255) NOT NULL, created_at TIMESTAMP DEFAULT NOW() ); -- Sync Log CREATE TABLE sync_log ( id SERIAL PRIMARY KEY, sync_type VARCHAR(32) NOT NULL DEFAULT 'equipment', status VARCHAR(32) NOT NULL, items_processed INTEGER DEFAULT 0, items_failed INTEGER DEFAULT 0, error_message TEXT, started_at TIMESTAMP DEFAULT NOW(), completed_at TIMESTAMP ); CREATE INDEX idx_sync_log_started ON sync_log(started_at DESC); ``` ### 4.2 Redis Usage | Key Pattern | TTL | Zweck | |-------------|-----|-------| | `equipment:list:{hash}` | 3600s (1h) | Cached equipment list responses | | `equipment:detail:{id}` | 3600s (1h) | Cached equipment detail | | `equipment:categories` | 3600s (1h) | Cached category list | | `rate:contact:{ip}` | 60s | Rate limit contact form (5/min) | | `rate:rental:{ip}` | 60s | Rate limit rental requests (5/min) | | `rate:login:{ip}` | 60s | Rate limit admin login (5/min) | --- ## 5. Rentman-Integration ### 5.1 Equipment-Import Pipeline **Trigger-Mechanismus:** APScheduler laeuft in-process im Backend-Container (`app/main.py` startup). Ein Cron-Job mit Intervall `0 */6 * * *` (alle 6 Stunden) triggert den Equipment-Import. Zusaetzlich kann der Admin via `POST /api/admin/sync` einen manuellen Sync ausloesen. ```python # app/main.py (Auszug) from apscheduler.schedulers.asyncio import AsyncIOScheduler scheduler = AsyncIOScheduler() @app.on_event("startup") async def start_scheduler(): scheduler.add_job( run_equipment_sync, trigger="cron", hour="*/6", # Alle 6 Stunden id="equipment_sync", replace_existing=True, ) scheduler.start() @app.on_event("shutdown") async def stop_scheduler(): scheduler.shutdown() ``` **Pipeline-Flow:** ``` [Trigger: APScheduler Cron 6h ODER Admin POST /api/admin/sync] │ ▼ [1] sync_log Eintrag: status='running', started_at=NOW() │ ▼ [2] RentmanAPI GET /equipment?limit=100&offset=0 │ ┌──────────────────────────────────────────┐ │ │ Loop: solange data nicht leer │ │ │ - Parse response │ │ │ - Upsert in equipment_cache │ │ │ - offset += 100 │ │ │ - Naechste Seite │ │ └──────────────────────────────────────────┘ │ ▼ [3] Redis Cache invalidieren (equipment:*) │ ▼ [4] sync_log Update: status='completed', items_processed=N, completed_at=NOW() │ ▼ [5] Fertig. Frontend liest aus PostgreSQL + Redis Cache. ``` **Fehlerbehandlung:** - API nicht erreichbar → sync_log status='failed', error_message, bestehende Daten bleiben - Partial failure (Seite 3/10) → bereits importierte Seiten bleiben, Fehler geloggt, Retry nur fuer fehlgeschlagene - Retry-Mechanismus: exponential backoff, max 3 retries pro Seite ### 5.2 Mietanfrage Pipeline ``` [POST /api/rental-requests] │ ▼ [1] Validierung (Pydantic): Event-Daten, Kontakt-Daten, Items │ ▼ [2] rental_requests Eintrag in DB (status='pending', reference_number generiert) │ ▼ [3] rental_request_items in DB speichern │ ▼ [4] RentmanAPI POST /projectrequests │ → rentman_project_request_id erhalten │ → rental_requests.rentman_request_id aktualisieren │ ▼ [5] Fuer jedes Item: RentmanAPI POST /projectrequests/{id}/projectrequestequipment │ → rental_request_items.rentman_sync_status='success' / 'failed' │ ▼ [6] Bestaetigungs-E-Mail an Kunde (reference_number + Item-Liste) │ ▼ [7] Response 201 an Frontend: {reference_number: "HMS-2026-00123"} ``` **Fehlerbehandlung:** - POST /projectrequests schlaegt fehl → status='failed', Rentman-Sync-Retry-Queue, User sieht trotzdem Success ('Anfrage eingegangen, wird verarbeitet') - Equipment-POST schlaegt fehl → item-level retry, Admin-Benachrichtigung - SMTP nicht erreichbar → E-Mail in Retry-Queue, Admin-Benachrichtigung ### 5.3 Rentman API Mapping **Import (GET /equipment → equipment_cache):** | Rentman Feld | DB Feld | Anmerkung | |--------------|---------|----------| | `id` | `rentman_id` | String, unique | | `name` | `name` | – | | `number` / `code` | `number` | Artikelnummer | | `equipment_group.name` | `category` | Kategorie | | `description` | `description` | – | | `specifications` | `specifications` | JSONB | | `images` / `files` | `images` | JSONB array of URLs | | `rental_price` | `rental_price` | Decimal, nullable | | `brand` | `brand` | – | **Anfrage (POST /projectrequests):** | Frontend Feld | Rentman Feld | Anmerkung | |---------------|-------------|----------| | `event_name` | `name` | – | | `date_start` + `T08:00:00+02:00` | `planperiod_start` | – | | `date_end` + `T02:00:00+02:00` | `planperiod_end` | – | | `date_start` + `T18:00:00+02:00` | `usageperiod_start` | – | | `date_end` + `T23:59:00+02:00` | `usageperiod_end` | – | | `contact_company` oder `contact_name` | `contact_name` | – | | `contact_name` (split) | `contact_person_first_name` / `contact_person_lastname` | Split bei Leerzeichen | | `contact_email` | `contact_person_email` | – | | `location` | `location_name` | – | | `contact_street` | `location_mailing_street` | – | | `contact_street` (Hausnummer extrahiert) | `location_mailing_number` | Regex-Extraktion der Hausnummer aus `contact_street` | | `contact_postalcode` | `location_mailing_postalcode` | – | | `contact_city` | `location_mailing_city` | – | | `message` | `remark` | – | **Equipment POST (POST /projectrequests/{id}/projectrequestequipment):** | Frontend Feld | Rentman Feld | |---------------|-------------| | `equipment_name` | `name` | | `quantity` | `quantity` / `quantity_total` | | `unit_price` (oder 0) | `unit_price` | | `/equipment/{rentman_id}` | `linked_equipment` | --- ## 6. Security-Konzept ### 6.1 Authentifizierung | Bereich | Methode | |---------|---------| | Admin-Login | JWT (python-jose), HttpOnly Cookie, 24h Expiry | | Public API | Keine Auth noetig (Equipment GET, Contact POST, Rental POST) | | Admin API | JWT required (sync, sync-status, sync-log) | **JWT Implementation:** - Algorithm: HS256 - Secret: `JWT_SECRET` env var - Payload: `{sub: username, exp: timestamp}` - Cookie: `hms_admin_token`, HttpOnly, Secure, SameSite=Strict ### 6.2 Rate Limiting | Endpoint | Limit | Window | |----------|-------|--------| | `POST /api/contact` | 5 requests | 60s per IP | | `POST /api/rental-requests` | 5 requests | 60s per IP | | `POST /api/admin/login` | 5 requests | 60s per IP | Implementiert via Redis (INCR + EXPIRE). ### 6.3 Input Validation - **Backend:** Pydantic schemas (stricter than DB constraints) - Email: EmailStr validator - Strings: min/max length, strip whitespace - Dates: date format, date_start ≤ date_end - Numbers: positive integers - Boolean: privacy_consent must be True - **Frontend:** Client-side validation (Vue reactive) vor Submit - Gleiche Regeln wie Backend - Inline Fehler-Meldungen (aria-invalid, aria-describedby) ### 6.4 CORS ```python # FastAPI CORS app.add_middleware( CORSMiddleware, allow_origins=["https://hms.media-on.de"], allow_methods=["GET", "POST"], allow_headers=["*"], allow_credentials=True, # fuer Admin-Cookie ) ``` ### 6.5 Secrets Management | Secret | Environment Variable | Usage | |--------|---------------------|-------| | Rentman API Token | `RENTMAN_API_TOKEN` | Backend Service | | JWT Secret | `JWT_SECRET` | Auth Service | | Database URL | `DATABASE_URL` | SQLAlchemy | | Redis URL | `REDIS_URL` | Redis client | | SMTP Host | `SMTP_HOST` | Email Service | | SMTP Port | `SMTP_PORT` | Email Service | | SMTP User | `SMTP_USER` | Email Service | | SMTP Password | `SMTP_PASSWORD` | Email Service | | SMTP From | `SMTP_FROM` | Email Service | Alle via Coolify Environment Variables. Nie im Frontend, nie im Git. ### 6.6 CSRF-Schutz Die Anwendung verwendet einen mehrschichtigen CSRF-Schutz-Ansatz: 1. **SameSite=Strict Cookie:** Das `hms_admin_token` Cookie wird mit `SameSite=Strict` gesetzt. Dies verhindert, dass der Browser das Cookie bei Cross-Site-Requests (Form-Submissions, Image-Tags, etc.) von anderen Domains sendet. 2. **Stateless Public Endpoints:** Alle oeffentlichen Endpunkte (`GET /api/equipment`, `POST /api/contact`, `POST /api/rental-requests`) sind stateless und verwenden keine Cookies oder Sessions. Sie validieren ausschliesslich via Pydantic Body-Validation und Rate-Limiting. CSRF ist bei stateless Endpoints ohne Cookie-Auth irrelevant. 3. **Admin-Endpoints mit JWT:** Admin-Endpoints (`POST /api/admin/sync`, etc.) erfordern das `hms_admin_token` Cookie. Da `SameSite=Strict` gesetzt ist, kann ein Angreifer von einer anderen Domain aus das Cookie nicht mitsenden. Der CORS-Header ist auf `https://hms.media-on.de` beschraenkt, sodass Cross-Origin-Requests mit Credentials von anderen Domains blockiert werden. 4. **Kein CSRF-Token erforderlich:** Aufgrund der Kombination aus SameSite=Strict und stateless Public-Endpoints ist ein zusaetzliches CSRF-Token nicht noetig. Die Angriffsflaeche ist minimiert. --- ## 7. Deployment-Architektur ### 7.1 Docker Compose ```yaml version: '3.8' services: frontend: build: ./frontend ports: - "3000:3000" environment: - NUXT_PUBLIC_API_BASE=https://hms.media-on.de/api depends_on: - backend restart: unless-stopped backend: build: ./backend ports: - "8000:8000" environment: - DATABASE_URL=postgresql+asyncpg://hms:${DB_PASSWORD}@postgres:5432/hms - REDIS_URL=redis://redis:6379/0 - RENTMAN_API_TOKEN=${RENTMAN_API_TOKEN} - JWT_SECRET=${JWT_SECRET} - SMTP_HOST=${SMTP_HOST} - SMTP_PORT=${SMTP_PORT} - SMTP_USER=${SMTP_USER} - SMTP_PASSWORD=${SMTP_PASSWORD} - SMTP_FROM=${SMTP_FROM} depends_on: - postgres - redis restart: unless-stopped postgres: image: postgres:16-alpine volumes: - postgres_data:/var/lib/postgresql/data environment: - POSTGRES_DB=hms - POSTGRES_USER=hms - POSTGRES_PASSWORD=${DB_PASSWORD} restart: unless-stopped redis: image: redis:7-alpine volumes: - redis_data:/data restart: unless-stopped volumes: postgres_data: redis_data: ``` **Hinweis:** Es gibt bewusst keinen separaten Worker-Container. Der Equipment-Cron-Sync wird via APScheduler in-process innerhalb des `hms-backend` Containers ausgefuehrt (siehe §5.1 und ADR-008). Dadurch bleibt die Container-Anzahl fuer eine Single-Tenant-Anwendung minimal (4 Container) und die Betriebskomplexitaet gering. ### 7.2 Coolify Deployment - **Platform:** Coolify auf coolify-01 (46.225.91.159) - **Domain:** hms.media-on.de - **TLS:** Let's Encrypt via Traefik (Coolify automatisch) - **HSTS (HTTP Strict Transport Security):** Traefik wird mit HSTS konfiguriert, um Browser anzuweisen, nur via HTTPS zu verbinden: ```yaml # Traefik labels in Coolify service config traefik.http.middlewares.hsts.headers.stsSeconds: 31536000 traefik.http.middlewares.hsts.headers.stsIncludeSubdomains: true traefik.http.middlewares.hsts.headers.stsPreload: true traefik.http.routers.hms.middlewares: hsts ``` Dies setzt den Header `Strict-Transport-Security: max-age=31536000; includeSubDomains; preload` auf alle Responses. - **Resource Limits:** - Frontend: 512MB RAM, 0.5 CPU - Backend: 512MB RAM, 0.5 CPU - PostgreSQL: 1GB RAM, 1 CPU - Redis: 256MB RAM ### 7.3 Environments | Environment | Domain | Zweck | |-----------|-------|------| | Dev | dev.hms.media-on.de | Entwicklung, Testing | | Staging | staging.hms.media-on.de | Pre-Production Verification | | Prod | hms.media-on.de | Live | ### 7.4 CI/CD Pipeline (Forgejo Actions) ``` [Push to main] → [Build Frontend: npm ci && npm run build] → [Build Backend: pip install && pytest] → [Build Docker Images] → [Deploy to Coolify via API] ``` --- ## 8. Test-Strategie ### 8.1 Frontend Tests | Typ | Tool | Scope | Coverage Target | |-----|------|-------|----------------| | Unit | Vitest | Composables, Stores, Utils | 80% | | Component | Vitest + Vue Test Utils | Component rendering, props, emits | 80% | | E2E | Playwright | Page flows, forms, navigation | Critical paths | **Critical E2E Paths:** 1. Home → Mietkatalog → Equipment Detail → Add to Cart → Warenkorb → Mietanfrage → Submit 2. Home → Kontakt → Form submit → Success 3. Admin Login → Sync trigger → Status update 4. Referenzen → Filter → Lightbox 5. 404 page on invalid route ### 8.2 Backend Tests | Typ | Tool | Scope | Coverage Target | |-----|------|-------|----------------| | Unit | Pytest | Services, Validators, Helpers | 80% | | Integration | Pytest + TestClient | API endpoints, DB | 80% | | E2E | Pytest | Rentman mock → full pipeline | Critical paths | **Critical Backend Tests:** 1. Equipment import (mocked Rentman API, paginated) 2. Rental request submission (mocked Rentman, email mock) 3. Contact form submission (email mock) 4. Admin login (valid/invalid credentials) 5. Rate limiting (6th request blocked) 6. Equipment search/filter/pagination 7. APScheduler cron job registration and execution (mocked) ### 8.3 Test Infrastructure - **Frontend:** Vitest config in `frontend/vitest.config.ts`, Playwright config in `frontend/playwright.config.ts` - **Backend:** Pytest config in `backend/pytest.ini`, conftest.py with fixtures - **Mocking:** Rentman API mocked via `unittest.mock` / `responses` library - **Test DB:** SQLite in-memory oder test PostgreSQL instance --- ## 9. E-Mail-Service ### 9.1 Templates - **Kontaktformular:** HTML + Text template, Reply-To = sender email, To = info@hms-licht-ton.de - **Mietanfrage-Bestaetigung:** HTML + Text template, To = customer email, enthaelt reference_number + item list ### 9.2 Implementierung ```python # services/email_service.py import aiosmtplib from email.mime.text import MIMEText from email.mime.multipart import MIMEMultipart class EmailService: async def send_contact_email(self, contact: ContactCreate) -> bool: # Build MIME message # Send via aiosmtplib (async) # On failure: enqueue in retry queue ... async def send_rental_confirmation(self, request: RentalRequest, items: list) -> bool: # Build confirmation email with reference_number # Send via aiosmtplib ... ``` --- ## 10. Architecture Decision Records (ADRs) ### ADR-001: Nuxt 3 als Frontend Framework - **Status:** Accepted - **Entscheidung:** Nuxt 3 (Vue 3 + Tailwind CSS) fuer Frontend - **Begruendung:** SSR/SSG Support fuer SEO (trotz noindex, fuer KI-Crawler), Vue 3 Composition API, Mobile-First, grosse Community - **Alternativen:** Next.js (React), Astro, plain Vue 3 SPA - **Nachteile:** Build-Komplexitaet durch SSR/SSG-Mischung ### ADR-002: FastAPI als Backend Framework - **Status:** Accepted - **Entscheidung:** FastAPI (Python, async) fuer Backend API - **Begruendung:** Rentman Python-Client bereits vorhanden, async fuer API-Calls, automatische OpenAPI-Doku, Pydantic Validation - **Alternativen:** Django REST, Express.js, Go - **Nachteile:** Python ecosystem Abhaengigkeiten (SQLAlchemy, etc.) ### ADR-003: PostgreSQL + Redis - **Status:** Accepted - **Entscheidung:** PostgreSQL als Primary DB, Redis als Cache + Rate Limiter - **Begruendung:** PostgreSQL fuer ACID (rental_requests), JSONB fuer flexible specs; Redis fuer Caching + Rate Limiting - **Alternativen:** SQLite (zu einfach), MongoDB (overkill) ### ADR-004: localStorage fuer Warenkorb (keine Session-Backend) - **Status:** Accepted - **Entscheidung:** Client-side localStorage fuer Warenkorb, kein Backend-Session - **Begruendung:** Keine User-Auth noetig, keine Server-Session-Storage, persistiert ueber Reloads, einfach zu implementieren - **Alternativen:** Server-side session (Redis), Cookie-based cart - **Risiko:** localStorage limit ~5MB, aber Warenkorb-Items sind klein (IDs + quantities) ### ADR-005: noindex/nofollow + JSON-LD - **Status:** Accepted - **Entscheidung:** Alle Seiten noindex/nofollow/noarchive/nosnippet, aber JSON-LD LocalBusiness auf Home - **Begruendung:** User moechte keine oeffentliche Indexierung, aber KI-Crawler sollen strukturierte Daten erkennen - **Alternativen:** Vollstaendige Indexierung (abgelehnt), keine Meta-Tags (zu wenig Info fuer KI) ### ADR-006: SSG fuer statische Seiten, SSR fuer Mietkatalog - **Status:** Accepted - **Entscheidung:** Statische Seiten (Home, Referenzen, Legal) als SSG, Mietkatalog als SSR - **Begruendung:** Statische Seiten schnell + cached, Mietkatalog braucht aktuelle Equipment-Daten vom Backend - **Alternativen:** Alles SSR (zu langsam fuer statische Seiten), Alles SSG (Mietkatalog nicht aktuell) ### ADR-007: JWT HttpOnly Cookie fuer Admin-Auth - **Status:** Accepted - **Entscheidung:** JWT in HttpOnly Cookie (nicht localStorage), 24h Expiry - **Begruendung:** HttpOnly schuetzt vor XSS, SameSite=Strict vor CSRF, 24h Balance zwischen Security und Usability - **Alternativen:** Session in Redis (mehr Complexity), Basic Auth (zu simpel) ### ADR-008: APScheduler in-process fuer Equipment-Cron-Sync (6h) + manueller Trigger - **Status:** Accepted - **Entscheidung:** Equipment-Sync alle 6h via APScheduler (in-process im Backend-Container) + manuelle Admin-Trigger - **Begruendung:** Fuer eine Single-Tenant-Anwendung ist ein separater Worker-Container unnoetige Betriebskomplexitaet. APScheduler laeuft im FastAPI-Prozess, startet beim Application-Startup und nutzt AsyncIOScheduler fuer non-blocking Ausfuehrung. Cron-Intervall `0 */6 * * *` (alle 6 Stunden) ist ausreichend, da Equipment sich nicht staendig aendert. Manueller Trigger via Admin-API fuer Ad-hoc Updates. - **Alternativen:** - Separater Celery/RQ Worker-Container: Mehr Complexity, mehr Container, Dependencies (Broker) – overkill fuer Single-Tenant - FastAPI BackgroundTasks alleine: Keen Cron-Scheduling, nur ad-hoc - Externer Cron-Daemon im Container: Fragil bei Container-Restarts - Webhook von Rentman: Nicht verfuegbar - **Nachteile:** Bei Backend-Container-Restart geht der naechste geplante Sync-Zeitpunkt verloren (APScheduler re-synced beim Startup). Dies ist fuer 6h-Intervall akzeptabel. --- ## 11. Non-Functional Requirements ### 11.1 Performance - Page Load < 2s auf 3G (Lighthouse Mobile Score >= 90) - API Response < 500ms fuer Equipment-Liste (Redis cached) - Images: WebP/AVIF, lazy-loading, responsive sizes - Frontend Bundle < 200KB gzipped ### 11.2 Availability - Uptime target: 99.5% (kleines Unternehmen, nicht kritisch) - Health check: GET /api/health (Docker restart if unhealthy) - Graceful degradation: Equipment aus Cache anzeigen wenn Backend down ### 11.3 Scalability - Single-tenant, moderate traffic expected - Redis Cache handles read load - PostgreSQL handles write load (rental requests) - Horizontal scaling: post-MVP (mehrere Frontend Container) --- ## 12. Risiken | Risiko | Wahrscheinlichkeit | Impact | Mitigation | |--------|-------------------|--------|------------| | Rentman API Felder unklar | Hoch | Mittel | GET /equipment/{id} testen, flexible JSONB-Spalten | | Rentman API Rate Limits | Niedrig | Mittel | Paginated import, 6h interval, caching | | SMTP nicht erreichbar | Niedrig | Niedrig | Retry-Queue, Admin-Notification | | Logo SVG nicht verfuegbar | Mittel | Niedrig | Fallback-Text 'HMS Licht & Ton' | | Referenz-Bilder fehlen | Mittel | Niedrig | Placeholder images, Unsplash fallback | | localStorage Limit | Niedrig | Niedrig | Nur IDs + quantities, max ~1000 items | | APScheduler Job verloren bei Restart | Niedrig | Niedrig | Job wird beim Startup neu registriert, 6h-Intervall toleriert kurze Ausfaelle | --- ## 13. Offene Fragen 1. Rentman Equipment-Felder: Welche Felder sind im `equipment`-Objekt verfuegbar? → GET /equipment/{id} testen 2. Equipment-Kategorien: Wie strukturiert? Flaches Feld oder nested object? → API testen 3. AGB-Texte: Vom User als Text/Markdown bereitstellen? 4. Referenz-Bilder: 9 Dateien vom User bereitstellen? 5. SMTP-Zugangsdaten: Welcher Server? 6. USt-IdNr: Fuer Impressum benoetigt? 7. Mietpreise: Anzeigen oder 'auf Anfrage'? --- ## 14. Handoff Summary - **architecture status:** COMPLETE – Alle Komponenten, API-Endpunkte, DB-Schema, Security, Deployment dokumentiert - **8 ADRs** mit Begruendung und Alternativen - **8 Tasks** im task_graph.json mit Test-Specs und Abhaengigkeiten - **ready for review:** YES – Qualitaetssicherung durch quality_reviewer empfohlen - **Rev. 2 Aenderungen (Quality Gate Fixes):** - MAJOR 1: §3.1 Backend-Struktur auf `backend/app/` korrigiert (konsistent mit AGENTS.md) - MAJOR 2: ADR-008 aktualisiert auf APScheduler in-process statt separatem Worker-Container - MINOR 3: §6.6 CSRF-Schutz dokumentiert (SameSite=Strict + stateless public endpoints) - MINOR 4: §5.3 `location_mailing_number` Mapping hinzugefuegt (Hausnummer-Extraktion) - MINOR 5: T01 acceptance_criteria ergaenzt (phone link, social icons) - MINOR 6: §7.2 HSTS Traefik Konfiguration hinzugefuegt - MINOR 7: §5.1 APScheduler als Cron-Implementierung spezifiziert (Code-Beispiel)