2026-07-08 21:47:26 +00:00
|
|
|
|
# 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)
|