37 KiB
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:3000hms.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:
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:
: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:
{
"items": [...],
"total": 500,
"page": 1,
"page_size": 20,
"total_pages": 25
}
EquipmentItem Schema:
{
"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):
{
"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:
{
"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:
{
"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
-- 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.
# 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_SECRETenv 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
# 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:
-
SameSite=Strict Cookie: Das
hms_admin_tokenCookie wird mitSameSite=Strictgesetzt. Dies verhindert, dass der Browser das Cookie bei Cross-Site-Requests (Form-Submissions, Image-Tags, etc.) von anderen Domains sendet. -
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. -
Admin-Endpoints mit JWT: Admin-Endpoints (
POST /api/admin/sync, etc.) erfordern dashms_admin_tokenCookie. DaSameSite=Strictgesetzt ist, kann ein Angreifer von einer anderen Domain aus das Cookie nicht mitsenden. Der CORS-Header ist aufhttps://hms.media-on.debeschraenkt, sodass Cross-Origin-Requests mit Credentials von anderen Domains blockiert werden. -
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
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:
Dies setzt den Header
# 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: hstsStrict-Transport-Security: max-age=31536000; includeSubDomains; preloadauf 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:
- Home → Mietkatalog → Equipment Detail → Add to Cart → Warenkorb → Mietanfrage → Submit
- Home → Kontakt → Form submit → Success
- Admin Login → Sync trigger → Status update
- Referenzen → Filter → Lightbox
- 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:
- Equipment import (mocked Rentman API, paginated)
- Rental request submission (mocked Rentman, email mock)
- Contact form submission (email mock)
- Admin login (valid/invalid credentials)
- Rate limiting (6th request blocked)
- Equipment search/filter/pagination
- APScheduler cron job registration and execution (mocked)
8.3 Test Infrastructure
- Frontend: Vitest config in
frontend/vitest.config.ts, Playwright config infrontend/playwright.config.ts - Backend: Pytest config in
backend/pytest.ini, conftest.py with fixtures - Mocking: Rentman API mocked via
unittest.mock/responseslibrary - 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
# 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
- Rentman Equipment-Felder: Welche Felder sind im
equipment-Objekt verfuegbar? → GET /equipment/{id} testen - Equipment-Kategorien: Wie strukturiert? Flaches Feld oder nested object? → API testen
- AGB-Texte: Vom User als Text/Markdown bereitstellen?
- Referenz-Bilder: 9 Dateien vom User bereitstellen?
- SMTP-Zugangsdaten: Welcher Server?
- USt-IdNr: Fuer Impressum benoetigt?
- 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_numberMapping 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)
- MAJOR 1: §3.1 Backend-Struktur auf