Files
hms-licht-ton/docs/architecture.md
T

943 lines
32 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# HMS Licht & Ton System Architecture
> **Projekt:** hms-licht-ton (ID 30)
> **Datum:** 2026-07-08
> **Status:** Draft Ready for Review
> **Autor:** Solution Architect (A0 Orchestrator)
---
## 1. Architektur-Überblick
### 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) │ │
│ │ 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 | Abhängigkeiten |
|-----------|-------|------|----------------|
| `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 | |
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 | Begründung |
|-------|-------|------------|
| `/` (Home) | SSG (`nuxt generate`) | Statischer Content, SEO/SSR nicht nötig (noindex) |
| `/referenzen` | SSG | Statische Bilder, keine API nötig |
| `/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 nötig |
| `/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 | Lösung | 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/
├── main.py (FastAPI app, CORS, routers)
├── 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)
├── requirements.txt
├── Dockerfile
├── alembic.ini
└── migrations/
└── versions/
```
### 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<EquipmentItem> |
| 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": "München",
"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": "München",
"message": "Brauchen PA für 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 bezüglich...",
"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: 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 │
│ │ - Nächste 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 für 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] Für jedes Item: RentmanAPI POST /projectrequests/{id}/projectrequestequipment
│ → rental_request_items.rentman_sync_status='success' / 'failed'
[6] Bestätigungs-E-Mail an Kunde (reference_number + Item-Liste)
[7] Response 201 an Frontend: {reference_number: "HMS-2026-00123"}
```
**Fehlerbehandlung:**
- POST /projectrequests schlägt fehl → status='failed', Rentman-Sync-Retry-Queue, User sieht trotzdem Success ('Anfrage eingegangen, wird verarbeitet')
- Equipment-POST schlägt 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 |
|---------------|-------------|
| `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` |
| `contact_email` | `contact_person_email` |
| `location` | `location_name` |
| `contact_street` | `location_mailing_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 nötig (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, # für 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.
---
## 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:
```
### 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)
- **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
### 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-Bestätigung:** HTML + Text template, To = customer email, enthält 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) für Frontend
- **Begründung:** SSR/SSG Support für SEO (trotz noindex, für KI-Crawler), Vue 3 Composition API, Mobile-First, große Community
- **Alternativen:** Next.js (React), Astro, plain Vue 3 SPA
- **Nachteile:** Build-Komplexität durch SSR/SSG-Mischung
### ADR-002: FastAPI als Backend Framework
- **Status:** Accepted
- **Entscheidung:** FastAPI (Python, async) für Backend API
- **Begründung:** Rentman Python-Client bereits vorhanden, async für API-Calls, automatische OpenAPI-Doku, Pydantic Validation
- **Alternativen:** Django REST, Express.js, Go
- **Nachteile:** Python ecosystem Abhängigkeiten (SQLAlchemy, etc.)
### ADR-003: PostgreSQL + Redis
- **Status:** Accepted
- **Entscheidung:** PostgreSQL als Primary DB, Redis als Cache + Rate Limiter
- **Begründung:** PostgreSQL für ACID (rental_requests), JSONB für flexible specs; Redis für Caching + Rate Limiting
- **Alternativen:** SQLite (zu einfach), MongoDB (overkill)
### ADR-004: localStorage für Warenkorb (keine Session-Backend)
- **Status:** Accepted
- **Entscheidung:** Client-side localStorage für Warenkorb, kein Backend-Session
- **Begründung:** Keine User-Auth nötig, keine Server-Session-Storage, persistiert über 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
- **Begründung:** User möchte keine öffentliche Indexierung, aber KI-Crawler sollen strukturierte Daten erkennen
- **Alternativen:** Vollständige Indexierung (abgelehnt), keine Meta-Tags (zu wenig Info für KI)
### ADR-006: SSG für statische Seiten, SSR für Mietkatalog
- **Status:** Accepted
- **Entscheidung:** Statische Seiten (Home, Referenzen, Legal) als SSG, Mietkatalog als SSR
- **Begründung:** Statische Seiten schnell + cached, Mietkatalog braucht aktuelle Equipment-Daten vom Backend
- **Alternativen:** Alles SSR (zu langsam für statische Seiten), Alles SSG (Mietkatalog nicht aktuell)
### ADR-007: JWT HttpOnly Cookie für Admin-Auth
- **Status:** Accepted
- **Entscheidung:** JWT in HttpOnly Cookie (nicht localStorage), 24h Expiry
- **Begründung:** HttpOnly schützt vor XSS, SameSite=Strict vor CSRF, 24h Balance zwischen Security und Usability
- **Alternativen:** Session in Redis (mehr Complexity), Basic Auth (zu simpel)
### ADR-008: Cron-basierter Equipment-Sync (6h) + manueller Trigger
- **Status:** Accepted
- **Entscheidung:** Equipment-Sync alle 6h via Cron + manuelle Admin-Trigger
- **Begründung:** Equipment ändert sich nicht stündlich, 6h ist aktuell genug, manueller Trigger für Ad-hoc Updates
- **Alternativen:** Webhook von Rentman (nicht verfügbar), Real-time (overkill)
---
## 11. Non-Functional Requirements
### 11.1 Performance
- Page Load < 2s auf 3G (Lighthouse Mobile Score >= 90)
- API Response < 500ms für 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 verfügbar | 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 |
---
## 13. Offene Fragen
1. Rentman Equipment-Felder: Welche Felder sind im `equipment`-Objekt verfügbar? → 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: Für Impressum benötigt?
7. Mietpreise: Anzeigen oder 'auf Anfrage'?
---
## 14. Handoff Summary
- **architecture status:** COMPLETE Alle Komponenten, API-Endpunkte, DB-Schema, Security, Deployment dokumentiert
- **8 ADRs** mit Begründung und Alternativen
- **6-8 Tasks** im task_graph.json mit Test-Specs und Abhängigkeiten
- **ready for review:** YES Qualitätssicherung durch quality_reviewer empfohlen