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

1043 lines
37 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 (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<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": "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)