project setup with core files including configuration, package management, and basic structure. Added .gitignore, README, and various TypeScript types for CMS components. Implemented initial components and layouts for the application.

This commit is contained in:
Peter Meier
2025-12-13 23:26:13 +01:00
parent ea288a5bbc
commit b1a556dc6d
167 changed files with 19057 additions and 131 deletions

View File

174
.cursor/context.md Normal file
View File

@@ -0,0 +1,174 @@
# Projekt-Kontext: GraphQL Middlelayer + Astro Frontend
## Projekt-Übersicht
Ein GraphQL-basierter Middlelayer mit Astro-Frontend für einen Onlineshop. Der Middlelayer fungiert als Abstraktionsschicht zwischen Frontend und verschiedenen Datenquellen (aktuell Mock-Daten, später Headless CMS, etc.).
## Architektur
### Middlelayer (`middlelayer/`)
- **GraphQL Server** (Apollo Server v5) auf Port 4000
- **Adapter Pattern** für Datenquellen-Abstraktion
- **DataService** als Singleton-Aggregator
- **Monitoring & Observability**:
- Structured Logging (Winston)
- Prometheus Metrics (Port 9090)
- Distributed Tracing
- **Performance-Optimierungen**:
- Dataloader für Batch-Loading (verhindert N+1 Queries)
- Response Caching
- Query Complexity Limits (mit Workaround für Schema-Realm-Konflikt)
### Frontend (`src/`)
- **Astro Framework** mit SSR
- **Tailwind CSS** für Styling
- **Alpine.js** für Client-Side Interaktivität
- **GraphQL Client** für Datenabfragen
## Datenstrukturen
### Produkte
- `Product` mit `originalPrice` (Streichpreis) und `promotion` (Objekt mit `category` und `text`)
- Promotion-Typen: `"sale"` oder `"topseller"`
- Promotion-Text: z.B. `"-30%"` oder `"top"`
### CMS-Daten
- `Page` - Seiten mit SEO, Headlines, Bannern (mit Locale-Support)
- `PageSeo` - SEO-Metadaten (mit Locale-Support)
- `Navigation` - Navigationsstruktur mit Links (mit Locale-Support)
### Content-System
- **CMS-Typen** (`middlelayer/types/cms/`) - Struktur wie Daten vom CMS kommen
- `*Skeleton` - Wrapper mit `contentTypeId` und `fields`
- Verwendet `ComponentLayout` (Alias für `contentLayout`)
- Nur für Mapper und Mock-Daten
- **Domain-Typen** (`middlelayer/types/c_*.ts`) - Struktur wie Daten in der App verwendet werden
- `c_*` - Content-Item-Typen mit `type`-Feld für Discriminated Union
- Verwendet `contentLayout` direkt
- Für GraphQL Schema, Astro Components, etc.
- **Content-Komponenten**: HTML, Markdown, Iframe, ImageGallery, Image, Quote, YoutubeVideo, Headline
- **Mapper** (`middlelayer/mappers/pageMapper.ts`) - Konvertiert CMS-Typen zu Domain-Typen
### Internationalization (i18n)
- **URL-basierte Locales**: `/de` und `/en` URLs
- **Übersetzungen**: Defaults + Middlelayer-Überschreibungen
- **React**: `useI18n()` Hook für Komponenten
- **Alpine.js**: `window.i18n.t()` für Navigation
- **CMS**: Locale-Parameter in allen CMS-Queries
- **Contentful-Ansatz**: Vorbereitet für Contentful Locale-System (jedes Feld lokalisiert)
## Wichtige Dateien
### Middlelayer
- `middlelayer/index.ts` - Server-Entry-Point
- `middlelayer/schema.ts` - GraphQL Schema
- `middlelayer/resolvers.ts` - GraphQL Resolvers
- `middlelayer/dataService.ts` - DataService (Singleton)
- `middlelayer/adapters/interface.ts` - DataAdapter Interface
- `middlelayer/adapters/mockdata.ts` - MockdataAdapter
- `middlelayer/adapters/Mock/_cms/` - Mock-Daten für CMS
- `middlelayer/mappers/pageMapper.ts` - Konvertiert CMS-Typen zu Domain-Typen
- `middlelayer/types/cms/` - CMS-spezifische Typen (für Mapper/Mock)
- `middlelayer/types/c_*.ts` - Domain-Typen (für App)
- `middlelayer/types/contentLayout.ts` - Einheitlicher Layout-Typ
- `middlelayer/utils/dataloaders.ts` - Dataloader für Batch-Loading + GraphQLContext
- `middlelayer/utils/cache.ts` - In-Memory Cache mit Metrics
- `middlelayer/monitoring/` - Logging, Metrics, Tracing
- `middlelayer/plugins/` - Apollo Server Plugins
### Frontend
- `src/components/Product.astro` - Produkt-Komponente mit Promotion & Streichpreis
- `src/components/LoginModal.tsx` - React Login-Modal mit i18n
- `src/components/RegisterModal.tsx` - React Register-Modal mit i18n
- `src/lib/graphql/client.ts` - GraphQL Client
- `src/lib/graphql/queries.ts` - Produkt-Queries
- `src/lib/graphql/cmsQueries.ts` - CMS-Queries (mit Locale-Support)
- `src/lib/i18n/` - i18n-System (defaults, i18n.ts, useI18n.tsx, alpine.ts)
- `src/layouts/Layout.astro` - Layout mit Navigation & SEO
- `src/middleware.ts` - URL-basierte Locale-Routing
- `src/pages/[locale]/` - Locale-basierte Routen
## Konfiguration
### Environment Variables
```bash
PORT=4000 # GraphQL Server Port
METRICS_PORT=9090 # Metrics Server Port
LOG_LEVEL=info # Log-Level (debug, info, warn, error)
MAX_QUERY_COMPLEXITY=1000 # Query Complexity Limit
NODE_ENV=development # Environment
```
### Cache-TTLs (konfigurierbar via Environment oder `middlelayer/config/cache.ts`)
- Pages: 60 Sekunden
- SEO/Navigation: 5 Minuten
- Products: 30 Sekunden
## Bekannte Probleme & Workarounds
1. **Query Complexity Schema-Realm-Konflikt**
- Problem: `graphql-query-complexity` hat Schema-Realm-Konflikt mit Apollo Server
- Workaround: Plugin überspringt Check bei Realm-Konflikten (siehe `middlelayer/plugins/queryComplexity.ts`)
- Status: Funktioniert, aber Complexity-Check wird manchmal übersprungen
2. **GraphQL Version**
- Alle Pakete verwenden `graphql@16.12.0`
- `overrides` in `package.json` stellt sicher, dass nur eine Version verwendet wird
## NPM Scripts
```bash
npm run mock:server # Startet nur GraphQL Server
npm start # Startet GraphQL Server + Astro (concurrently)
npm run dev # Startet nur Astro
```
## Endpoints
- **GraphQL**: `http://localhost:4000`
- **GraphQL Playground**: `http://localhost:4000`
- **Metrics**: `http://localhost:9090/metrics`
- **Health Check**: `http://localhost:9090/health`
- **Astro**: `http://localhost:4321`
## Implementierte Features
✅ GraphQL Server mit Apollo Server v5
✅ Adapter Pattern für Datenquellen
✅ Mock-Daten für Produkte und CMS
✅ Product-Komponente mit Promotion & Streichpreis
✅ Structured Logging (Winston)
✅ Prometheus Metrics
✅ Distributed Tracing
✅ Dataloader für Batch-Loading
✅ Response Caching
✅ Query Complexity Limits (mit Workaround)
✅ Cache mit Metrics-Tracking
✅ DataService mit Metrics-Tracking
## Nächste Schritte / Offene Punkte
- [ ] Query Complexity Schema-Realm-Problem dauerhaft lösen
- [ ] Redis Cache Integration (statt In-Memory)
- [ ] Database Adapter (für echte Datenbank)
- [ ] Headless CMS Adapter (Contentful, Strapi, etc.)
- [ ] Rate Limiting
- [ ] Authentication/Authorization
- [ ] GraphQL Subscriptions (falls benötigt)
## Wichtige Design-Entscheidungen
1. **Adapter Pattern**: Ermöglicht einfaches Wechseln zwischen Datenquellen
2. **Singleton DataService**: Zentrale Stelle für alle Datenoperationen
3. **Monitoring First**: Von Anfang an Monitoring integriert
4. **Type Safety**: TypeScript durchgängig verwendet
5. **Structured Logging**: JSON-Logs für bessere Analyse
6. **Metrics**: Prometheus-kompatible Metriken für Monitoring
## Code-Stil
- TypeScript mit ES Modules
- Doppelte Anführungszeichen für Strings (Prettier)
- Deutsche Kommentare und Fehlermeldungen
- Englische Code-Namen und API-Namen

150
.gitignore vendored
View File

@@ -1,138 +1,28 @@
# ---> Node # build output
# Logs dist/
logs
*.log # generated types
.astro/
# dependencies
node_modules/
# logs
npm-debug.log* npm-debug.log*
yarn-debug.log* yarn-debug.log*
yarn-error.log* yarn-error.log*
lerna-debug.log* pnpm-debug.log*
.pnpm-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html) # environment variables
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env .env
.env.development.local .env.production
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/) # macOS-specific files
.cache .DS_Store
.parcel-cache
# Next.js build output # jetbrains setting folder
.next .idea/
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
.cache
# vitepress build output
**/.vitepress/dist
# vitepress cache directory
**/.vitepress/cache
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
# vscode settings folder
.vscode/
.history/

443
README.md
View File

@@ -1,2 +1,443 @@
# sell # GraphQL Middlelayer + Astro Frontend
Ein moderner, skalierbarer Onlineshop mit GraphQL-basiertem Middlelayer und Astro-Frontend. Der Middlelayer fungiert als flexible Abstraktionsschicht zwischen Frontend und verschiedenen Datenquellen.
## 🚀 Features
- **GraphQL API** mit Apollo Server v5
- **Astro Frontend** mit SSR und Client-Side Interaktivität
- **Adapter Pattern** für flexible Datenquellen (Mock, Headless CMS, Database)
- **Performance-Optimierungen**:
- Dataloader für Batch-Loading (verhindert N+1 Queries)
- Redis/In-Memory Caching
- Response Caching
- Query Complexity Limits
- **Monitoring & Observability**:
- Structured Logging (Winston)
- Prometheus Metrics
- Distributed Tracing
- **Type-Safe** mit TypeScript durchgängig
- **Modern UI** mit Tailwind CSS und Alpine.js
- **Internationalization (i18n)**:
- URL-basierte Locales (`/de`, `/en`)
- Übersetzungen aus Middlelayer (mit Default-Fallback)
- CMS-Inhalte mehrsprachig (vorbereitet für Contentful)
## 📋 Voraussetzungen
- Node.js 18+
- npm oder yarn
- (Optional) Redis für verteiltes Caching
## 🛠️ Installation
```bash
# Dependencies installieren
npm install
# (Optional) Redis installieren (macOS)
brew install redis
brew services start redis
```
## 🏃 Quick Start
```bash
# GraphQL Server + Astro Frontend starten
npm start
# Oder einzeln:
npm run mock:server # Nur GraphQL Server (Port 4000)
npm run dev # Nur Astro Frontend (Port 4321)
```
## 📁 Projektstruktur
```
/
├── middlelayer/ # GraphQL Middlelayer
│ ├── adapters/ # Datenquellen-Adapter (Mock, CMS, etc.)
│ ├── config/ # Konfiguration
│ ├── monitoring/ # Logging, Metrics, Tracing
│ ├── plugins/ # Apollo Server Plugins
│ ├── types/ # TypeScript Typen
│ ├── utils/ # Utilities (Cache, Dataloader, etc.)
│ ├── index.ts # Server Entry Point
│ ├── schema.ts # GraphQL Schema
│ └── resolvers.ts # GraphQL Resolvers
├── src/ # Astro Frontend
│ ├── components/ # Astro Komponenten
│ ├── layouts/ # Layout Templates
│ ├── lib/ # Utilities & GraphQL Client
│ ├── pages/ # Astro Pages
│ └── styles/ # Global Styles
└── docs/ # Dokumentation
```
## ⚙️ Konfiguration
### Environment Variables
Erstelle eine `.env` Datei im Root-Verzeichnis:
```env
# Server Ports
PORT=4000 # GraphQL Server Port
METRICS_PORT=9090 # Metrics Server Port
# Logging
LOG_LEVEL=info # debug, info, warn, error
NODE_ENV=development # development, production
# GraphQL
MAX_QUERY_COMPLEXITY=1000 # Query Complexity Limit
# Redis (Optional)
REDIS_ENABLED=true # Redis Cache aktivieren
REDIS_HOST=localhost # Redis Host
REDIS_PORT=6379 # Redis Port
REDIS_PASSWORD= # Optional: Redis Password
# Cache TTLs (in Millisekunden)
CACHE_PAGES_TTL=60000 # 60 Sekunden
CACHE_PAGE_SEO_TTL=300000 # 5 Minuten
CACHE_NAVIGATION_TTL=300000 # 5 Minuten
CACHE_PRODUCTS_TTL=30000 # 30 Sekunden
```
### Cache-Konfiguration
Cache-TTLs können über Environment Variables oder direkt in `middlelayer/config/cache.ts` konfiguriert werden.
## 🌐 Endpoints
| Service | URL | Beschreibung |
|---------|-----|--------------|
| GraphQL API | `http://localhost:4000` | GraphQL Endpoint & Playground |
| Metrics | `http://localhost:9090/metrics` | Prometheus Metrics |
| Health Check | `http://localhost:9090/health` | Service Health Status |
| Astro Frontend | `http://localhost:4321` | Frontend Application |
## 📊 GraphQL Schema
### Queries
```graphql
# Produkte
query {
products(limit: 4) {
id
name
price
originalPrice
promotion {
category
text
}
}
product(id: "prod-123") {
id
name
description
price
}
}
# CMS (mit Locale-Support)
query {
pageSeo(locale: "de") {
title
description
}
page(slug: "/about", locale: "de") {
headline
subheadline
}
pages(locale: "en") {
slug
name
}
navigation(locale: "de") {
links {
name
slug
}
}
}
# Übersetzungen
query {
translations(locale: "de", namespace: "auth") {
locale
translations {
key
value
}
}
}
}
page(slug: "/") {
slug
name
headline
}
navigation {
name
links {
name
url
}
}
}
```
## 🏗️ Architektur
### Middlelayer
Der Middlelayer verwendet das **Adapter Pattern** für flexible Datenquellen:
```
Frontend (Astro)
GraphQL API (Apollo Server)
DataService (Singleton)
DataAdapter (Interface)
┌─────────────┬──────────────┬─────────────┐
│ Mock Adapter│ CMS Adapter │ DB Adapter │
└─────────────┴──────────────┴─────────────┘
```
**Vorteile:**
- Einfaches Wechseln zwischen Datenquellen
- Testbarkeit durch Mock-Adapter
- Zentrale Logik im DataService
- Caching auf Service-Ebene
### Frontend
- **Astro** für Server-Side Rendering
- **Alpine.js** für Client-Side Interaktivität
- **Tailwind CSS** für Styling
- **GraphQL Client** für Datenabfragen
## 🔧 Entwicklung
### NPM Scripts
```bash
npm run mock:server # Startet GraphQL Server
npm run dev # Startet Astro Dev Server
npm start # Startet beide (concurrently)
npm run build # Production Build
npm run preview # Preview Production Build
```
### Neuen Adapter hinzufügen
1. Implementiere `DataAdapter` Interface:
```typescript
// middlelayer/adapters/myAdapter.ts
import type { DataAdapter } from './interface.js';
export class MyAdapter implements DataAdapter {
async getProducts(limit?: number): Promise<Product[]> {
// Implementierung
}
// ... weitere Methoden
}
```
2. In `middlelayer/adapters/config.ts` registrieren:
```typescript
export function createAdapter(): DataAdapter {
const adapterType = process.env.ADAPTER_TYPE || 'mock';
if (adapterType === 'myAdapter') {
return new MyAdapter();
}
// ...
}
```
## 📈 Monitoring
### Prometheus Metrics
Metriken sind verfügbar unter `http://localhost:9090/metrics`:
- `graphql_queries_total` - Anzahl der Queries
- `graphql_query_duration_seconds` - Query-Dauer
- `cache_hits_total` / `cache_misses_total` - Cache-Statistiken
- `dataservice_calls_total` - DataService-Aufrufe
- `errors_total` - Fehler-Anzahl
### Logging
Structured Logging mit Winston:
- Console Output (Development)
- JSON Logs (Production)
- Log-Level konfigurierbar
### Tracing
Automatische Trace-ID-Generierung für Request-Tracking.
## 🚀 Deployment
### Production Build
```bash
npm run build
```
### Docker (Beispiel)
```dockerfile
FROM node:18-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --production
COPY . .
RUN npm run build
CMD ["npm", "start"]
```
## 🧪 Testing
```bash
# GraphQL Query testen
curl -X POST http://localhost:4000 \
-H "Content-Type: application/json" \
-d '{"query":"{ products(limit: 4) { id name price } }"}'
# Health Check
curl http://localhost:9090/health
# Metrics
curl http://localhost:9090/metrics
```
## 📚 Weitere Dokumentation
- [Architektur & Skalierung](./docs/ARCHITECTURE_SCALING.md) - Detaillierte Architektur-Analyse
- [Redis Setup](./middlelayer/utils/REDIS_SETUP.md) - Redis Cache Konfiguration
- [Monitoring](./middlelayer/monitoring/README.md) - Monitoring & Observability
## 🐛 Bekannte Probleme
### Query Complexity Schema-Realm-Konflikt
**Problem:** `graphql-query-complexity` hat einen Schema-Realm-Konflikt mit Apollo Server.
**Workaround:** Das Plugin überspringt den Check bei Realm-Konflikten automatisch. Funktioniert, aber Complexity-Check wird manchmal übersprungen.
**Status:** Funktioniert, permanente Lösung in Arbeit.
## 🌍 Internationalization (i18n)
### URL-basierte Locales
Das System unterstützt URL-basierte Locales:
- `/de` - Deutsche Version
- `/en` - Englische Version
- `/` - Auto-Redirect zu Browser-Locale oder Cookie
### Übersetzungen
**Architektur:**
- **Defaults**: Fallback-Übersetzungen in `src/lib/i18n/defaults.ts`
- **Middlelayer**: Übersetzungen können vom GraphQL-Server geladen werden
- **Überschreibungen**: Middlelayer-Übersetzungen überschreiben Defaults
**Verwendung in React:**
```tsx
import { useI18n } from "../lib/i18n/useI18n.js";
function MyComponent() {
const { t } = useI18n("auth");
return <h1>{t("login.title")}</h1>;
}
```
**Verwendung in Alpine.js:**
```html
<div x-data="{ t: window.i18n?.t || ((key) => key) }">
<button x-text="t('nav.login')"></button>
</div>
```
### CMS-Locale-Support
**Contentful-Ansatz:**
Contentful verwendet ein Locale-System, bei dem:
- Jedes Content-Feld lokalisiert werden kann
- Standard-Locale wird definiert (z.B. `en-US`)
- Fallback-Locales können konfiguriert werden
- API-Aufrufe enthalten `locale`-Parameter: `fields.headline['en-US']`
**Unser System:**
- GraphQL-Schema unterstützt `locale`-Parameter für alle CMS-Queries
- Adapter-Interface ist vorbereitet für Locale-Parameter
- Mock-Adapter gibt aktuell alle Locales gleich zurück (TODO: Locale-spezifische Mock-Daten)
- Bei Contentful-Integration: Adapter würde `locale`-Parameter an Contentful API weitergeben
**Beispiel GraphQL Query:**
```graphql
query {
page(slug: "/about", locale: "de") {
headline # Deutsche Übersetzung
subheadline
}
page(slug: "/about", locale: "en") {
headline # Englische Übersetzung
subheadline
}
}
```
## 🔮 Roadmap
- [ ] Query Complexity Schema-Realm-Problem dauerhaft lösen
- [ ] Database Adapter (PostgreSQL, MySQL)
- [ ] Headless CMS Adapter (Contentful, Strapi)
- [ ] Locale-spezifische Inhalte implementieren
- [ ] Fallback-Locales konfigurieren
- [ ] Rate Limiting
- [ ] Authentication/Authorization ✅ (implementiert)
- [ ] GraphQL Subscriptions
- [ ] i18n: Weitere Sprachen hinzufügen
- [ ] i18n: Locale-spezifische Mock-Daten für CMS
- [ ] E2E Tests
## 🤝 Beitragen
1. Fork das Repository
2. Erstelle einen Feature Branch (`git checkout -b feature/AmazingFeature`)
3. Committe deine Änderungen (`git commit -m 'Add some AmazingFeature'`)
4. Push zum Branch (`git push origin feature/AmazingFeature`)
5. Öffne einen Pull Request
## 📝 Lizenz
Dieses Projekt ist privat.
## 👥 Autoren
- Entwickelt mit ❤️ für moderne E-Commerce-Lösungen
---
**Hinweis:** Dieses Projekt ist in aktiver Entwicklung. Für Fragen oder Probleme öffne bitte ein Issue.

29
astro.config.mjs Normal file
View File

@@ -0,0 +1,29 @@
// @ts-check
import { defineConfig } from "astro/config";
import tailwind from "@tailwindcss/vite";
import react from "@astrojs/react";
import alpinejs from "@astrojs/alpinejs";
// https://astro.build/config
import { fileURLToPath } from "url";
import { resolve } from "path";
const __dirname = fileURLToPath(new URL(".", import.meta.url));
export default defineConfig({
vite: {
plugins: [tailwind()],
resolve: {
alias: {
"@middlelayer-types": resolve(__dirname, "./middlelayer/types"),
"@middlelayer": resolve(__dirname, "./middlelayer"),
},
},
},
integrations: [react(), alpinejs()],
// URL-basierte Locale-Routing
output: "server",
adapter: undefined, // Für SSR (später kann ein Adapter hinzugefügt werden)
});

View File

@@ -0,0 +1,395 @@
# Architektur-Analyse: Skalierung für großen Onlineshop
## Aktuelle Architektur - Stärken ✅
1. **Adapter Pattern** - Gute Abstraktion für Datenquellen
2. **Separation of Concerns** - Klare Trennung zwischen GraphQL, DataService und Adaptern
3. **Type Safety** - TypeScript durchgängig verwendet
4. **Caching-Layer** - Grundlegende Caching-Strategie vorhanden
5. **Error Handling** - Strukturierte Fehlerbehandlung
## Kritische Verbesserungen für hohen Traffic 🚨
### 1. **Caching-Strategie**
**Problem:**
- In-Memory Cache ist pro Server-Instanz isoliert
- Cache geht bei Neustart verloren
- Keine Cache-Invalidierung bei Updates
- Keine Cache-Warming-Strategie
**Lösung:**
```typescript
// Redis-basierter Cache mit Clustering
import Redis from 'ioredis';
class RedisCache<T> {
private client: Redis;
private cluster: Redis.Cluster;
// Cache-Tags für gezielte Invalidierung
async invalidateByTag(tag: string) { ... }
// Cache-Warming beim Start
async warmCache() { ... }
}
```
**Empfehlungen:**
- ✅ Redis Cluster für verteilten Cache
- ✅ Cache-Tags für gezielte Invalidierung (z.B. `product:123`, `category:electronics`)
- ✅ Cache-Warming beim Deployment
- ✅ Stale-While-Revalidate Pattern
- ✅ CDN für statische Assets (Bilder, CSS, JS)
### 2. **Database Connection Pooling**
**Problem:**
- Keine Connection Pooling sichtbar
- Risiko von Connection Exhaustion bei hohem Traffic
**Lösung:**
```typescript
// Connection Pool für Datenbank-Adapter
class DatabaseAdapter implements DataAdapter {
private pool: Pool;
constructor() {
this.pool = new Pool({
max: 20, // Max Connections
min: 5, // Min Connections
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 2000,
});
}
}
```
**Empfehlungen:**
- ✅ Connection Pooling (PostgreSQL, MySQL)
- ✅ Read Replicas für Read-Heavy Operations
- ✅ Database Query Optimization (Indizes, Query-Analyse)
- ✅ Connection Monitoring & Alerting
### 3. **GraphQL Performance**
**Problem:**
- Keine Query Complexity Limits
- Keine Dataloader für N+1 Queries
- Keine Query Caching
- Keine Rate Limiting
**Lösung:**
```typescript
// Apollo Server mit Performance-Features
const server = new ApolloServer({
typeDefs,
resolvers,
plugins: [
// Query Complexity
{
requestDidStart() {
return {
didResolveOperation({ request, operation }) {
const complexity = calculateComplexity(operation);
if (complexity > 1000) {
throw new Error('Query too complex');
}
},
};
},
},
// Response Caching
responseCachePlugin({
sessionId: (requestContext) =>
requestContext.request.http?.headers.get('session-id') ?? null,
}),
// Rate Limiting
rateLimitPlugin({
identifyContext: (ctx) => ctx.request.http?.headers.get('x-user-id'),
}),
],
});
```
**Empfehlungen:**
- ✅ Query Complexity Limits
- ✅ Dataloader für Batch-Loading
- ✅ Response Caching (Apollo Server)
- ✅ Rate Limiting (pro User/IP)
- ✅ Query Persisted Queries
- ✅ GraphQL Query Analysis & Monitoring
### 4. **Load Balancing & Horizontal Scaling**
**Problem:**
- Single Server Instance
- Keine Load Balancing
- Keine Health Checks
**Lösung:**
```yaml
# Docker Compose / Kubernetes
services:
graphql:
replicas: 5
healthcheck:
path: /health
interval: 10s
redis:
cluster: true
database:
read-replicas: 3
```
**Empfehlungen:**
- ✅ Kubernetes / Docker Swarm für Orchestrierung
- ✅ Load Balancer (NGINX, HAProxy, AWS ALB)
- ✅ Health Check Endpoints
- ✅ Auto-Scaling basierend auf CPU/Memory
- ✅ Blue-Green Deployments
### 5. **Monitoring & Observability**
**Problem:**
- Nur Console-Logging
- Keine Metriken
- Keine Distributed Tracing
**Lösung:**
```typescript
// Structured Logging + Metrics
import { createLogger } from 'winston';
import { PrometheusMetrics } from './metrics';
const logger = createLogger({
format: winston.format.json(),
transports: [
new winston.transports.Console(),
new winston.transports.File({ filename: 'error.log' }),
],
});
const metrics = new PrometheusMetrics();
// In Resolvers
async getProducts(limit: number) {
const start = Date.now();
try {
const products = await dataService.getProducts(limit);
metrics.recordQueryDuration('getProducts', Date.now() - start);
metrics.incrementQueryCount('getProducts', 'success');
return products;
} catch (error) {
metrics.incrementQueryCount('getProducts', 'error');
logger.error('Failed to get products', { error, limit });
throw error;
}
}
```
**Empfehlungen:**
- ✅ Structured Logging (Winston, Pino)
- ✅ Metrics (Prometheus + Grafana)
- ✅ Distributed Tracing (Jaeger, Zipkin)
- ✅ APM (Application Performance Monitoring)
- ✅ Error Tracking (Sentry, Rollbar)
- ✅ Real-time Dashboards
### 6. **Security**
**Problem:**
- Keine Authentication/Authorization
- Keine Input Validation
- Keine CORS-Konfiguration
- Keine Rate Limiting
**Lösung:**
```typescript
// Security Middleware
import { rateLimit } from 'express-rate-limit';
import helmet from 'helmet';
import { validate } from 'graphql-validate';
const limiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // Limit each IP to 100 requests per windowMs
});
// GraphQL Input Validation
const validateInput = (schema, input) => {
const errors = validate(schema, input);
if (errors.length > 0) {
throw new ValidationError(errors);
}
};
```
**Empfehlungen:**
- ✅ Authentication (JWT, OAuth)
- ✅ Authorization (Role-Based Access Control)
- ✅ Input Validation (Zod, Yup)
- ✅ Rate Limiting (pro Endpoint/User)
- ✅ CORS-Konfiguration
- ✅ SQL Injection Prevention (Parameterized Queries)
- ✅ XSS Protection
- ✅ CSRF Protection
- ✅ Security Headers (Helmet.js)
### 7. **Database Optimierungen**
**Problem:**
- Keine Indizes sichtbar
- Keine Query-Optimierung
- Keine Pagination für große Datensätze
**Lösung:**
```typescript
// Optimierte Queries mit Pagination
async getProducts(limit: number, offset: number, filters?: ProductFilters) {
// Indexed Query
const query = `
SELECT * FROM products
WHERE category = $1
ORDER BY created_at DESC
LIMIT $2 OFFSET $3
`;
// Mit Indizes:
// CREATE INDEX idx_products_category ON products(category);
// CREATE INDEX idx_products_created_at ON products(created_at);
}
```
**Empfehlungen:**
- ✅ Database Indizes für häufige Queries
- ✅ Pagination (Cursor-based für große Datensätze)
- ✅ Query Optimization (EXPLAIN ANALYZE)
- ✅ Database Sharding für sehr große Datenmengen
- ✅ Read Replicas für Read-Heavy Workloads
- ✅ Materialized Views für komplexe Aggregationen
### 8. **Error Handling & Resilience**
**Problem:**
- Keine Retry-Logik
- Keine Circuit Breaker
- Keine Fallback-Strategien
**Lösung:**
```typescript
// Circuit Breaker Pattern
import { CircuitBreaker } from 'opossum';
const breaker = new CircuitBreaker(dataService.getProducts, {
timeout: 3000,
errorThresholdPercentage: 50,
resetTimeout: 30000,
});
// Retry mit Exponential Backoff
async function withRetry<T>(
fn: () => Promise<T>,
maxRetries = 3
): Promise<T> {
for (let i = 0; i < maxRetries; i++) {
try {
return await fn();
} catch (error) {
if (i === maxRetries - 1) throw error;
await sleep(2 ** i * 1000); // Exponential backoff
}
}
}
```
**Empfehlungen:**
- ✅ Circuit Breaker Pattern
- ✅ Retry mit Exponential Backoff
- ✅ Fallback zu Cache bei DB-Fehlern
- ✅ Graceful Degradation
- ✅ Bulkhead Pattern (Isolation von Ressourcen)
### 9. **API Versioning & Backward Compatibility**
**Problem:**
- Keine API-Versionierung
- Breaking Changes könnten Frontend brechen
**Lösung:**
```typescript
// GraphQL Schema Versioning
const typeDefsV1 = `...`;
const typeDefsV2 = `...`;
const server = new ApolloServer({
typeDefs: [typeDefsV1, typeDefsV2],
resolvers: {
Query: {
productsV1: resolvers.products,
productsV2: resolvers.productsV2,
},
},
});
```
**Empfehlungen:**
- ✅ GraphQL Schema Versioning
- ✅ Deprecation Warnings
- ✅ Feature Flags für neue Features
- ✅ Backward Compatibility Tests
### 10. **Deployment & CI/CD**
**Empfehlungen:**
- ✅ Automated Testing (Unit, Integration, E2E)
- ✅ CI/CD Pipeline (GitHub Actions, GitLab CI)
- ✅ Blue-Green Deployments
- ✅ Canary Releases
- ✅ Database Migrations (automatisiert)
- ✅ Rollback-Strategien
## Priorisierte Roadmap 🗺️
### Phase 1: Foundation (Woche 1-2)
1. ✅ Redis Cache Integration
2. ✅ Database Connection Pooling
3. ✅ Structured Logging
4. ✅ Basic Monitoring (Prometheus)
### Phase 2: Performance (Woche 3-4)
1. ✅ Dataloader für N+1 Queries
2. ✅ Query Complexity Limits
3. ✅ Response Caching
4. ✅ Database Indizes
### Phase 3: Resilience (Woche 5-6)
1. ✅ Circuit Breaker
2. ✅ Retry Logic
3. ✅ Health Checks
4. ✅ Rate Limiting
### Phase 4: Scale (Woche 7-8)
1. ✅ Load Balancing
2. ✅ Horizontal Scaling (Kubernetes)
3. ✅ Read Replicas
4. ✅ CDN Integration
### Phase 5: Advanced (Woche 9+)
1. ✅ Distributed Tracing
2. ✅ Advanced Monitoring
3. ✅ Auto-Scaling
4. ✅ Database Sharding (falls nötig)
## Fazit
Die aktuelle Architektur ist **gut strukturiert** und bietet eine **solide Basis**. Für einen **großen Onlineshop mit hohem Traffic** müssen jedoch folgende Bereiche priorisiert werden:
1. **Caching** (Redis) - Höchste Priorität
2. **Database Optimierung** - Kritisch für Performance
3. **Monitoring** - Essentiell für Operations
4. **Horizontal Scaling** - Notwendig für Wachstum
5. **Resilience Patterns** - Wichtig für Verfügbarkeit
Mit diesen Verbesserungen kann die Architektur **tausende von Requests pro Sekunde** handhaben.

133
middlelayer/IMPROVEMENTS.md Normal file
View File

@@ -0,0 +1,133 @@
# Middlelayer Verbesserungsvorschläge
## 🔴 Kritisch / Wichtig
### 1. **Redundante `mapLayout` Methode entfernen**
**Problem:** `mapLayout` gibt nur den Parameter zurück - komplett redundant
**Lösung:** Direkt `layout` verwenden statt `this.mapLayout(layout)`
**Datei:** `mappers/pageMapper.ts:32-36`
### 2. **console.error durch logger ersetzen**
**Problem:** Inkonsistente Logging - manche Stellen nutzen `console.error` statt `logger`
**Lösung:** Alle `console.error/warn` durch `logger.error/warn` ersetzen
**Dateien:**
- `resolvers.ts:16,19`
- `adapters/config.ts:18`
- `plugins/queryComplexity.ts:56`
### 3. **Error Handling in Resolvers vereinfachen**
**Problem:** Wiederholende try-catch Blöcke in jedem Resolver
**Lösung:** Wrapper-Funktion für Resolver erstellen
## 🟡 Wichtig / Code-Qualität
### 4. **PageMapper: Strategy Pattern statt if-Statements**
**Problem:** 8 if-Statements in `mapContentItem` - schwer wartbar
**Lösung:** Map-basiertes Strategy Pattern
```typescript
private static contentMappers = new Map<ContentType, (entry: ContentEntry) => ContentItem>([
[ContentType.html, this.mapHtml],
[ContentType.markdown, this.mapMarkdown],
// ...
]);
```
### 5. **DataService: Code-Duplikation reduzieren**
**Problem:** Wiederholende Cache + Metrics Logik
**Lösung:** Helper-Methode `withCacheAndMetrics` erstellen
### 6. **Cache Keys zentralisieren**
**Problem:** Cache Keys werden überall manuell erstellt
**Lösung:** `CacheKeyBuilder` Utility-Klasse
```typescript
class CacheKeyBuilder {
static page(slug: string, locale?: string) {
return `page:${slug}:${locale || "default"}`;
}
// ...
}
```
### 7. **Type Safety verbessern**
**Problem:** Einige `any` Types (z.B. `page` Loader)
**Lösung:** ✅ Bereits behoben in `dataloaders.ts`
## 🟢 Nice-to-Have / Refactoring
### 8. **__cms Verzeichnis dokumentieren/archivieren**
**Problem:** Alte Contentful-Typen mit `Contentful_` Präfix - werden nicht mehr verwendet
**Lösung:**
- README.md hinzufügen: "Legacy - nicht mehr verwendet"
- Oder in `_legacy/` verschieben
### 9. **Resolver Wrapper für Error Handling**
**Lösung:**
```typescript
function withErrorHandling<T>(
resolver: () => Promise<T>
): Promise<T> {
try {
return await resolver();
} catch (error) {
handleError(error);
}
}
```
### 10. **DataService: Metrics-Tracking vereinheitlichen**
**Problem:** Nur `getPage` und `getProducts` haben Metrics, andere nicht
**Lösung:** Alle Methoden mit Metrics versehen oder Helper-Methode
### 11. **ContentEntry Union Type verbessern**
**Problem:** Type Guards könnten besser sein
**Lösung:** Type Guard Functions für jeden Content-Type
### 12. **Dokumentation erweitern**
- JSDoc Kommentare für alle öffentlichen Methoden
- Beispiele für Adapter-Implementierung
- Performance-Best-Practices
## 📊 Priorisierung
1. **Sofort:** #1, #2 (Redundanz entfernen, Logging konsistent) ✅
2. **Bald:** #3, #4 (Code-Qualität verbessern) ✅
3. **Später:** #5, #6, #7 (Refactoring für Wartbarkeit) ✅
4. **Optional:** #8-12 (Nice-to-Have)
## ✅ Umgesetzte Verbesserungen
### ✅ 1. Redundante `mapLayout` Methode entfernt
- Entfernt und direkt `layout` verwendet
### ✅ 2. console.error durch logger ersetzt
- Alle `console.error` durch `logger.error` ersetzt
### ✅ 3. Error Handling in Resolvers vereinfacht
- `withErrorHandling` Wrapper erstellt
- Alle Query- und Mutation-Resolvers verwenden den Wrapper
### ✅ 4. PageMapper: Strategy Pattern
- Map-basiertes Strategy Pattern implementiert
- 8 if-Statements durch wartbare Map ersetzt
### ✅ 5. DataService: Code-Duplikation reduziert
- `DataServiceHelpers` Klasse erstellt
- `withCacheAndMetrics` und `withCache` Methoden
- Alle DataService-Methoden vereinfacht
### ✅ 6. Cache Keys zentralisiert
- `CacheKeyBuilder` Utility-Klasse erstellt
- Alle Cache-Keys an einem Ort
### ✅ 7. Type Safety verbessert
- `ContentItem.__resolveType` verwendet jetzt `ContentItem` statt `any`
- Map-basiertes Type-Resolution statt if-Statements
### ✅ 8. Mutation Resolver vereinfacht
- `register` und `login` verwenden jetzt auch `withErrorHandling`
- Konsistentes Error Handling überall

View File

@@ -0,0 +1,14 @@
import type { CF_ContentType } from "src/@types/Contentful_ContentType.enum";
import type { CF_ComponentListSkeleton } from "src/@types/Contentful_List";
export interface CF_ComponentBadges {
internal: string;
badges: CF_ComponentListSkeleton;
variants: "light" | "dark";
layout?: any;
}
export interface CF_ComponentBadgesSkeleton {
contentTypeId: CF_ContentType.badges
fields: CF_ComponentBadges
}

View File

@@ -0,0 +1,24 @@
import type { CF_ContentType } from "./Contentful_ContentType.enum";
import type { Asset } from "contentful";
export interface CF_Campaign {
campaignName: string;
urlPatter: string;
selector: string;
insertHtml:
| "afterbegin"
| "beforeend"
| "afterend"
| "beforebegin"
| "replace";
timeUntil?: string;
javascript?: string;
medias?: Asset[];
html?: string;
css?: string;
}
export interface CF_CampaignSkeleton {
contentTypeId: CF_ContentType.campaign;
fields: CF_Campaign;
}

View File

@@ -0,0 +1,13 @@
import type { CF_ContentType } from "./Contentful_ContentType.enum";
import type { CF_CampaignSkeleton } from "./Contentful_Campaign";
export interface CF_Campaigns {
id: string;
campaings: CF_CampaignSkeleton[];
enable: boolean;
}
export interface CF_CampaignsSkeleton {
contentTypeId: CF_ContentType.campaigns;
fields: CF_Campaigns;
}

View File

@@ -0,0 +1,15 @@
export interface CF_CloudinaryImage {
bytes: number;
created_at: string;
format: string;
height: number;
original_secure_url: string;
original_url: string;
public_id: string;
resource_type: string;
secure_url: string;
type: string;
url: string;
version: number;
width: number;
}

View File

@@ -0,0 +1,24 @@
import type { EntrySkeletonType } from "contentful";
export type rowJutify =
| "start"
| "end"
| "center"
| "between"
| "around"
| "evenly";
export type rowAlignItems = "start" | "end" | "center" | "baseline" | "stretch";
export interface CF_Content {
row1JustifyContent: rowJutify;
row1AlignItems: rowAlignItems;
row1Content: EntrySkeletonType<any>[];
row2JustifyContent: rowJutify;
row2AlignItems: rowAlignItems;
row2Content: EntrySkeletonType<any>[];
row3JustifyContent: rowJutify;
row3AlignItems: rowAlignItems;
row3Content: EntrySkeletonType<any>[];
}

View File

@@ -0,0 +1,31 @@
export enum CF_ContentType {
"componentLinkList" = "componentLinkList",
"badges" = "badges",
"componentPostOverview" = "componentPostOverview",
"footer" = "footer",
"fullwidthBanner" = "fullwidthBanner",
"headline" = "headline",
"html" = "html",
"image" = "image",
"img" = "img",
"iframe" = "iframe",
"imgGallery" = "imageGallery",
"internalReference" = "internalComponent",
"link" = "link",
"list" = "list",
"markdown" = "markdown",
"navigation" = "navigation",
"page" = "page",
"pageConfig" = "pageConfig",
"picture" = "picture",
"post" = "post",
"postComponent" = "postComponent",
"quote" = "quoteComponent",
"richtext" = "richtext",
"row" = "row",
"rowLayout" = "rowLayout",
"tag" = "tag",
"youtubeVideo" = "youtubeVideo",
"campaign" = "campaign",
"campaigns" = "campaigns",
}

View File

@@ -0,0 +1,10 @@
import type { CF_ContentType } from "src/@types/Contentful_ContentType.enum";
import type { CF_Content } from "./Contentful_Content";
export interface CF_Footer extends CF_Content {
id : string;
}
export type CF_FooterSkeleton = {
contentTypeId: CF_ContentType.footer
fields: CF_Footer
}

View File

@@ -0,0 +1,24 @@
import type { CF_ComponentImgSkeleton } from "./Contentful_Img";
import type { CF_CloudinaryImage } from "./Contentful_CloudinaryImage";
import type { CF_ContentType } from "./Contentful_ContentType.enum";
export enum CF_FullwidthBannerVariant {
"dark"= "dark",
"light" = "light"
}
export interface CF_FullwidthBanner {
name: string,
variant : CF_FullwidthBannerVariant,
headline : string,
subheadline: string,
text : string,
image: CF_CloudinaryImage[];
img: CF_ComponentImgSkeleton;
}
export type CF_FullwidthBannerSkeleton = {
contentTypeId: CF_ContentType.fullwidthBanner
fields: CF_FullwidthBanner
}

View File

@@ -0,0 +1,29 @@
import type { EntrySkeletonType } from "contentful";
import type { CF_ContentType } from "src/@types/Contentful_ContentType.enum";
export type CF_justfyContent = "start" | "end" | "center" | "between" | "around" | "evenly";
export type CF_alignItems = "start" | "end" | "center" | "baseline" | "stretch"
export interface CF_Column_Alignment {
justifyContent: CF_justfyContent,
alignItems: CF_alignItems
}
export interface CF_Column_Layout<T> {
layoutMobile: T
layoutTablet: T
layoutDesktop: T
}
export type CF_Row_1_Column_Layout = "auto" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" | "10" | "11" | "12";
export interface CF_Row {
alignment: EntrySkeletonType<CF_Column_Alignment>
layout: EntrySkeletonType<CF_Column_Layout<CF_Row_1_Column_Layout>>
content: EntrySkeletonType<any>[]
}
export interface CF_RowSkeleton {
contentTypeId: CF_ContentType.row
fields: CF_Row
}

View File

@@ -0,0 +1,21 @@
import type { CF_ContentType } from "./Contentful_ContentType.enum.js";
import type { CF_ComponentLayout } from "./Contentful_Layout.js";
export type CF_Component_Headline_Align = "left" | "center" | "right";
export type CF_Component_Headline_Tag = "h1" | "h2" | "h3" | "h4" | "h5" | "h6";
export type CF_alignTextClasses = "text-left" | "text-center" | "text-right";
export interface CF_ComponentHeadline {
internal: string;
text: string;
tag: CF_Component_Headline_Tag;
layout: CF_ComponentLayout;
align?: CF_Component_Headline_Align;
}
export interface CF_ComponentHeadlineSkeleton {
contentTypeId: CF_ContentType.headline;
fields: CF_ComponentHeadline;
}

View File

@@ -0,0 +1,13 @@
import type { CF_ContentType } from "./Contentful_ContentType.enum";
import type { CF_ComponentLayout } from "./Contentful_Layout";
export interface CF_HTML {
id: string;
html: string;
layout: CF_ComponentLayout;
}
export type CF_HTMLSkeleton = {
contentTypeId: CF_ContentType.html;
fields: CF_HTML;
};

View File

@@ -0,0 +1,16 @@
import type { CF_ContentType } from "./Contentful_ContentType.enum";
import type { CF_ComponentImgSkeleton } from "./Contentful_Img";
import type { CF_ComponentLayout } from "./Contentful_Layout";
export interface CF_ComponentIframe {
name: string;
content: string;
iframe: string;
overlayImage?: CF_ComponentImgSkeleton;
layout: CF_ComponentLayout;
}
export interface CF_ComponentIframeSkeleton {
contentTypeId: CF_ContentType.iframe;
fields: CF_ComponentIframe;
}

View File

@@ -0,0 +1,17 @@
import type { CF_ContentType } from "./Contentful_ContentType.enum";
import type { CF_ComponentImgSkeleton } from "./Contentful_Img";
import type { CF_ComponentLayout } from "./Contentful_Layout";
export interface CF_ComponentImage {
name: string;
image: CF_ComponentImgSkeleton;
caption: string;
layout: CF_ComponentLayout;
maxWidth?: number;
aspectRatio?: number;
}
export interface CF_ComponentImageSkeleton {
contentTypeId: CF_ContentType.image;
fields: CF_ComponentImage;
}

View File

@@ -0,0 +1,15 @@
import type { CF_ContentType } from "./Contentful_ContentType.enum.js";
import type { CF_ComponentImgSkeleton } from "./Contentful_Img.js";
import type { CF_ComponentLayout } from "./Contentful_Layout.js";
export interface CF_ImageGallery {
name: string;
images: CF_ComponentImgSkeleton[];
layout: CF_ComponentLayout;
description?: string;
}
export interface CF_ImageGallerySkeleton {
contentTypeId: CF_ContentType.imgGallery;
fields: CF_ImageGallery;
}

View File

@@ -0,0 +1,26 @@
import type { CF_ContentType } from "./Contentful_ContentType.enum";
export interface CF_ComponentImgDetails {
size: number,
image: {
width: number,
height: number
}
}
export interface CF_ComponentImg {
title: string;
description: string;
file: {
url: string;
details: CF_ComponentImgDetails;
fileName: string;
contentType: string;
}
}
export interface CF_ComponentImgSkeleton {
contentTypeId: CF_ContentType.img
fields: CF_ComponentImg
}

View File

@@ -0,0 +1,14 @@
import type { CF_ContentType } from "src/@types/Contentful_ContentType.enum";
import type { CF_ComponentLayout } from "src/@types/Contentful_Layout";
import type { EntryFieldTypes } from "contentful";
export interface CF_internalReference {
data: EntryFieldTypes.Object,
reference: string,
layout: CF_ComponentLayout
}
export type CF_internalReferenceSkeleton = {
contentTypeId: CF_ContentType.internalReference
fields: CF_internalReference
}

View File

@@ -0,0 +1,100 @@
import type { CF_ContentType } from "src/@types/Contentful_ContentType.enum";
export type CF_Component_Layout_Width =
| "1"
| "2"
| "3"
| "4"
| "5"
| "6"
| "7"
| "8"
| "9"
| "10"
| "11"
| "12";
export type CF_widths_mobile =
| "w-full"
| "w-1/12"
| "w-2/12"
| "w-3/12"
| "w-4/12"
| "w-5/12"
| "w-6/12"
| "w-7/12"
| "w-8/12"
| "w-9/12"
| "w-10/12"
| "w-11/12";
export type CF_widths_tablet =
| ""
| "md:w-full"
| "md:w-1/12"
| "md:w-2/12"
| "md:w-3/12"
| "md:w-4/12"
| "md:w-5/12"
| "md:w-6/12"
| "md:w-7/12"
| "md:w-8/12"
| "md:w-9/12"
| "md:w-10/12"
| "md:w-11/12";
export type CF_widths_desktop =
| ""
| "lg:w-full"
| "lg:w-1/12"
| "lg:w-2/12"
| "lg:w-3/12"
| "lg:w-4/12"
| "lg:w-5/12"
| "lg:w-6/12"
| "lg:w-7/12"
| "lg:w-8/12"
| "lg:w-9/12"
| "lg:w-10/12"
| "lg:w-11/12";
export type CF_Component_Layout_Space =
| 0
| .5
| 1
| 1.5
| 2
export type CF_Component_Space =
| ""
| "mb-[0.5rem]"
| "mb-[1rem]"
| "mb-[1.5rem]"
| "mb-[2rem]"
export type CF_justfyContent =
| "justify-start"
| "justify-end"
| "justify-center"
| "justify-between"
| "justify-around"
| "justify-evenly";
export type CF_alignItems =
| "items-start"
| "items-end"
| "items-center"
| "items-baseline"
| "items-stretch";
export interface CF_ComponentLayout {
mobile: CF_Component_Layout_Width;
tablet?: CF_Component_Layout_Width;
desktop?: CF_Component_Layout_Width;
spaceBottom?: CF_Component_Layout_Space
}
export interface CF_ComponentLayoutSkeleton {
contentTypeId: CF_ContentType.rowLayout
fields: CF_ComponentLayout
}

View File

@@ -0,0 +1,23 @@
import type { CF_ContentType } from "./Contentful_ContentType.enum";
export interface CF_Link {
name: string;
internal: string;
linkName: string;
icon?: string;
color?: string;
url: string;
newTab?: boolean;
external?: boolean;
description?: string;
alt?: string;
showText?: boolean;
author: string;
date: string;
source: string;
}
export interface CF_LinkSkeleton {
contentTypeId: CF_ContentType.link;
fields: CF_Link;
}

View File

@@ -0,0 +1,12 @@
import type { CF_ContentType } from "./Contentful_ContentType.enum";
import type { CF_LinkSkeleton } from "./Contentful_Link";
export interface CF_Link_List {
headline: string;
links: CF_LinkSkeleton[];
}
export type CF_LinkListSkeleton = {
contentTypeId: CF_ContentType.componentLinkList;
fields: CF_Link_List;
};

View File

@@ -0,0 +1,11 @@
import type { CF_ContentType } from "src/@types/Contentful_ContentType.enum";
export interface CF_ComponentList {
internal: string;
item: string[];
}
export interface CF_ComponentListSkeleton {
contentTypeId: CF_ContentType.list
fields: CF_ComponentList
}

View File

@@ -0,0 +1,15 @@
import type { CF_ContentType } from "./Contentful_ContentType.enum";
import type { CF_ComponentLayout } from "./Contentful_Layout";
import type { TextAlignment } from "./TextAlignment";
export interface CF_Markdown {
name: string;
content: string;
layout: CF_ComponentLayout;
alignment: TextAlignment;
}
export type CF_MarkdownSkeleton = {
contentTypeId: CF_ContentType.markdown;
fields: CF_Markdown;
};

View File

@@ -0,0 +1,17 @@
export enum CF_Navigation_Keys {
"header" = "navigation-header",
"socialMedia" = "navigation-social-media",
"footer" = "navigation-footer",
}
export enum CF_PageConfigKey {
"pageConfig" = "page-config",
}
export enum CF_Footer_Keys {
"main" = "main",
}
export enum CF_Campaigns_Keys {
"campaigns" = "campaigns",
}

View File

@@ -0,0 +1,14 @@
import type { CF_Link } from "./Contentful_Link";
import type { CF_ContentType } from "./Contentful_ContentType.enum";
import type { CF_Page } from "./Contentful_Page";
export interface CF_Navigation {
name: string;
internal: string;
links: Array<{ fields: CF_Link | CF_Page }>;
}
export type CF_NavigationSkeleton = {
contentTypeId: CF_ContentType.navigation;
fields: CF_Navigation;
};

View File

@@ -0,0 +1,19 @@
import type { CF_ContentType } from "./Contentful_ContentType.enum";
import type { CF_FullwidthBannerSkeleton } from "./Contentful_FullwidthBanner";
import type { CF_Content } from "./Contentful_Content";
import type { CF_SEO } from "./Contentful_SEO";
export interface CF_Page extends CF_Content, CF_SEO {
slug: string;
name: string;
linkName: string;
icon?: string;
headline: string;
subheadline: string;
topFullwidthBanner: CF_FullwidthBannerSkeleton;
}
export type CF_PageSkeleton = {
contentTypeId: CF_ContentType.page;
fields: CF_Page;
};

View File

@@ -0,0 +1,18 @@
import type { CF_ContentType } from "./Contentful_ContentType.enum";
import type { CF_ComponentImgSkeleton } from "./Contentful_Img";
export interface CF_PageConfig {
logo: CF_ComponentImgSkeleton;
footerText1: string;
seoTitle: string;
seoDescription: string;
blogTagPageHeadline: string;
blogPostsPageHeadline: string;
blogPostsPageSubHeadline: string;
website: string;
}
export type CF_PageConfigSkeleton = {
contentTypeId: CF_ContentType.pageConfig;
fields: CF_PageConfig;
};

View File

@@ -0,0 +1,7 @@
export interface CF_Page_Seo {
name: "page-about-seo",
title: "about",
description: "about",
metaRobotsIndex: "index",
metaRobotsFollow: "follow"
}

View File

@@ -0,0 +1,57 @@
import type { EntryFieldTypes } from "contentful";
import type { CF_ContentType } from "src/@types/Contentful_ContentType.enum";
import type { CF_CloudinaryImage } from "src/@types/Contentful_CloudinaryImage";
export type CF_PictureWidths = 400 | 800 | 1200 | 1400
export type CF_PictureFormats = "aviv" | "jpg" | "png" | "webp"
export type CF_PictureFit = "contain" | "cover" | "fill" | "inside" | "outside"
export type CF_PicturePosition = "top"
| "right top"
| "right"
| "right bottom"
| "bottom"
| "left bottom"
| "left"
| "left top"
| "north"
| "northeast"
| "east"
| "southeast"
| "south"
| "southwest"
| "west"
| "northwest"
| "center"
| "centre"
| "cover"
| "entropy"
| "attention"
export type CF_PictureAspectRatio = 'original'
| '32:9'
| '16:9'
| '5:4'
| '4:3'
| '3:2'
| '1:1'
| '2:3'
| '3:4'
| '4:5'
export interface CF_Picture {
name: EntryFieldTypes.Text;
image: CF_CloudinaryImage[];
alt?: EntryFieldTypes.Text;
widths: Array<CF_PictureWidths>;
aspectRatio: CF_PictureAspectRatio;
formats: CF_PictureFormats;
fit: CF_PictureFit;
position: CF_PicturePosition;
layout?: any;
}
export type CF_PictureSkeleton = {
contentTypeId: CF_ContentType.picture
fields: CF_Picture
}

View File

@@ -0,0 +1,25 @@
import type { CF_ContentType } from "src/@types/Contentful_ContentType.enum";
import type { CF_ComponentImgSkeleton } from "./Contentful_Img";
import type { CF_Content } from "./Contentful_Content";
import type { CF_SEO } from "./Contentful_SEO";
import type { CF_TagSkeleton } from "./Contentful_Tag";
export interface CF_Post extends CF_Content, CF_SEO {
postImage: CF_ComponentImgSkeleton;
postTag: CF_TagSkeleton[];
slug: string;
linkName: string;
icon?: string;
headline: string;
important: boolean;
created: string;
date?: string;
subheadline: string;
excerpt: string;
content: string;
}
export type CF_PostEntrySkeleton = {
contentTypeId: CF_ContentType.post;
fields: CF_Post;
};

View File

@@ -0,0 +1,14 @@
import type { CF_ContentType } from "./Contentful_ContentType.enum";
import type { CF_ComponentLayout } from "./Contentful_Layout";
import type { CF_PostEntrySkeleton } from "./Contentful_Post";
export interface CF_PostComponent {
id: string;
post: CF_PostEntrySkeleton;
layout: CF_ComponentLayout;
}
export interface CF_PostComponentSkeleton {
contentTypeId: CF_ContentType.postComponent;
fields: CF_PostComponent;
}

View File

@@ -0,0 +1,24 @@
import type { CF_ContentType } from "src/@types/Contentful_ContentType.enum";
import type { CF_Content } from "./Contentful_Content";
import type { CF_SEO } from "src/@types/Contentful_SEO";
import type { CF_ComponentLayout } from "src/@types/Contentful_Layout";
import type { CF_PostEntrySkeleton } from "src/@types/Contentful_Post";
import type { CF_TagSkeleton } from "src/@types/Contentful_Tag";
import type { Document } from "@contentful/rich-text-types";
export interface CF_Post_Overview extends CF_Content, CF_SEO {
id: string;
headline: string;
text: Document;
layout: CF_ComponentLayout;
allPosts: boolean;
filterByTag: CF_TagSkeleton[];
posts: CF_PostEntrySkeleton[];
numberItems: number;
design?: "cards" | "list";
}
export type CF_Post_OverviewEntrySkeleton = {
contentTypeId: CF_ContentType.post;
fields: CF_Post_Overview;
};

View File

@@ -0,0 +1,14 @@
import type { CF_ContentType } from "./Contentful_ContentType.enum.js";
import type { CF_ComponentLayout } from "./Contentful_Layout.js";
export interface CF_Quote {
quote: string;
author: string;
variant: "left" | "right";
layout: CF_ComponentLayout;
}
export type CF_QuoteSkeleton = {
contentTypeId: CF_ContentType.quote;
fields: CF_Quote;
};

View File

@@ -0,0 +1,11 @@
import type { CF_ContentType } from "src/@types/Contentful_ContentType.enum";
export interface CF_ComponentRichtext {
content?: Document;
layout?: any;
}
export interface CF_ComponentRichtextSkeleton {
contentTypeId: CF_ContentType.richtext
fields: CF_ComponentRichtext
}

View File

@@ -0,0 +1,8 @@
export type metaRobots = "index, follow" | "noindex, follow" | "index, nofollow" | "noindex, nofollow";
export interface CF_SEO {
seoTitle : string,
seoMetaRobots : metaRobots,
seoDescription : string,
}

View File

@@ -0,0 +1,6 @@
import type { CF_PostEntrySkeleton } from "src/@types/Contentful_Post";
import type { CF_NavigationSkeleton } from "src/@types/Contentful_Navigation";
import type { CF_PageSkeleton } from "src/@types/Contentful_Page";
import type { CF_PageConfigSkeleton } from "src/@types/Contentful_PageConfig";
export type CF_SkeletonTypes = CF_PostEntrySkeleton | CF_NavigationSkeleton | CF_PageSkeleton | CF_PageConfigSkeleton;

View File

@@ -0,0 +1,11 @@
import type { CF_ContentType } from "./Contentful_ContentType.enum";
export interface CF_Tag {
name: string;
icon: string;
}
export interface CF_TagSkeleton {
contentTypeId: CF_ContentType.tag;
fields: CF_Tag;
}

View File

@@ -0,0 +1,16 @@
import type { CF_ContentType } from "./Contentful_ContentType.enum";
import type { CF_ComponentLayout } from "./Contentful_Layout";
export interface CF_YoutubeVideo {
id: string;
youtubeId: string;
params?: string;
title?: string;
description?: string;
layout: CF_ComponentLayout;
}
export interface CF_ComponentYoutubeVideoSkeleton {
contentTypeId: CF_ContentType.youtubeVideo;
fields: CF_YoutubeVideo;
}

View File

@@ -0,0 +1,72 @@
export type TwitterCardType = "summary" | "summary_large_image" | "app" | "player";
export interface Link extends HTMLLinkElement {
prefetch: boolean;
crossorigin: string;
}
export interface Meta extends HTMLMetaElement {
property: string;
}
export interface SeoProperties {
title?: string;
titleTemplate?: string;
titleDefault?: string;
charset?: string;
description?: string;
canonical?: URL | string;
nofollow?: boolean;
noindex?: boolean;
languageAlternates?: {
href: URL | string;
hrefLang: string;
}[];
openGraph?: {
basic: {
title: string;
type: string;
image: string;
url?: URL | string;
};
optional?: {
audio?: string;
description?: string;
determiner?: string;
locale?: string;
localeAlternate?: string[];
siteName?: string;
video?: string;
};
image?: {
url?: URL | string;
secureUrl?: URL | string;
type?: string;
width?: number;
height?: number;
alt?: string;
};
article?: {
publishedTime?: string;
modifiedTime?: string;
expirationTime?: string;
authors?: string[];
section?: string;
tags?: string[];
};
};
twitter?: {
card?: TwitterCardType;
site?: string;
creator?: string;
title?: string;
description?: string;
image?: URL | string;
imageAlt?: string;
};
extend?: {
link?: Partial<Link>[];
meta?: Partial<Meta>[];
};
surpressWarnings?: boolean;
}

View File

@@ -0,0 +1,6 @@
export type TextAlignment = 'left' | 'center' | 'right';
export enum TextAlignmentClasses {
'left' = 'text-left',
'center' = 'text-center',
'right' = 'text-right'
}

View File

@@ -0,0 +1,37 @@
import type { Navigation } from "../../../types/cms/Navigation";
import type { Link } from "../../../types/cms/Link";
import type { Page } from "../../../types/cms/Page";
import { generateMockPages } from "./mockPage";
/**
* Generiert Mock-Navigation mit locale-spezifischen Inhalten
* @param locale - Die gewünschte Locale ("de" oder "en")
*/
export function generateMockNavigation(locale: string = "de"): Navigation {
const isEn = locale === "en";
const pages = generateMockPages(locale);
// Erstelle Links basierend auf den Pages
const links: Array<{ fields: Link | Page }> = [
{
fields: pages["/"] as Page,
},
{
fields: pages["/about"] as Page,
},
{
fields: {
name: isEn ? "Products" : "Produkte",
internal: "products",
linkName: isEn ? "Products" : "Produkte",
url: "/products",
} as Link,
},
];
return {
name: isEn ? "Main Navigation" : "Hauptnavigation",
internal: "main-nav",
links: links as any,
};
}

View File

@@ -0,0 +1,871 @@
import type { Page } from "../../../types/cms/Page";
import type { HTMLSkeleton } from "../../../types/cms/Html";
import type { MarkdownSkeleton } from "../../../types/cms/Markdown";
import type { ComponentIframeSkeleton } from "../../../types/cms/Iframe";
import type { ImageGallerySkeleton } from "../../../types/cms/ImageGallery";
import type { ComponentImageSkeleton } from "../../../types/cms/Image";
import type { QuoteSkeleton } from "../../../types/cms/Quote";
import type { ComponentYoutubeVideoSkeleton } from "../../../types/cms/YoutubeVideo";
import type { ComponentHeadlineSkeleton } from "../../../types/cms/Headline";
import type { ComponentImgSkeleton } from "../../../types/cms/Img";
import { ContentType } from "../../../types/cms/ContentType.enum";
import { FullwidthBannerVariant } from "../../../types/cms/FullwidthBanner";
/**
* Erstellt eine Mock HTML-Komponente
*/
function createMockHTML(
id: string,
html: string,
mobile: string = "12",
tablet?: string,
desktop?: string,
spaceBottom?: number
): HTMLSkeleton {
return {
contentTypeId: ContentType.html,
fields: {
id,
html,
layout: {
mobile: mobile as any,
tablet: tablet as any,
desktop: desktop as any,
spaceBottom: spaceBottom as any,
},
},
};
}
/**
* Erstellt eine Mock Markdown-Komponente
*/
function createMockMarkdown(
name: string,
content: string,
alignment: "left" | "center" | "right" = "left",
mobile: string = "12",
tablet?: string,
desktop?: string,
spaceBottom?: number
): MarkdownSkeleton {
return {
contentTypeId: ContentType.markdown,
fields: {
name,
content,
alignment,
layout: {
mobile: mobile as any,
tablet: tablet as any,
desktop: desktop as any,
spaceBottom: spaceBottom as any,
},
},
};
}
/**
* Erstellt eine Mock-Image-Komponente
*/
function createMockImage(
title: string,
description: string,
url: string
): ComponentImgSkeleton {
return {
contentTypeId: ContentType.img,
fields: {
title,
description,
file: {
url,
details: {
size: 100000,
image: {
width: 800,
height: 600,
},
},
fileName: `${title.toLowerCase().replace(/\s+/g, "-")}.jpg`,
contentType: "image/jpeg",
},
},
};
}
/**
* Erstellt eine Mock Iframe-Komponente
*/
function createMockIframe(
name: string,
content: string,
iframe: string,
overlayImageUrl?: string,
mobile: string = "12",
tablet?: string,
desktop?: string,
spaceBottom?: number
): ComponentIframeSkeleton {
return {
contentTypeId: ContentType.iframe,
fields: {
name,
content,
iframe,
overlayImage: overlayImageUrl
? createMockImage(name, "Overlay Image", overlayImageUrl)
: undefined,
layout: {
mobile: mobile as any,
tablet: tablet as any,
desktop: desktop as any,
spaceBottom: spaceBottom as any,
},
},
};
}
/**
* Erstellt eine Mock ImageGallery-Komponente
*/
function createMockImageGallery(
name: string,
images: Array<{ title: string; description?: string; url: string }>,
description?: string,
mobile: string = "12",
tablet?: string,
desktop?: string,
spaceBottom?: number
): ImageGallerySkeleton {
return {
contentTypeId: ContentType.imgGallery,
fields: {
name,
images: images.map((img) =>
createMockImage(img.title, img.description || "", img.url)
),
description,
layout: {
mobile: mobile as any,
tablet: tablet as any,
desktop: desktop as any,
spaceBottom: spaceBottom as any,
},
},
};
}
/**
* Erstellt eine Mock Image-Komponente
*/
function createMockImageComponent(
name: string,
imageUrl: string,
caption: string,
maxWidth?: number,
aspectRatio?: number,
mobile: string = "12",
tablet?: string,
desktop?: string,
spaceBottom?: number
): ComponentImageSkeleton {
return {
contentTypeId: ContentType.image,
fields: {
name,
image: createMockImage(name, caption, imageUrl),
caption,
maxWidth,
aspectRatio,
layout: {
mobile: mobile as any,
tablet: tablet as any,
desktop: desktop as any,
spaceBottom: spaceBottom as any,
},
},
};
}
/**
* Erstellt eine Mock Quote-Komponente
*/
function createMockQuote(
quote: string,
author: string,
variant: "left" | "right" = "left",
mobile: string = "12",
tablet?: string,
desktop?: string,
spaceBottom?: number
): QuoteSkeleton {
return {
contentTypeId: ContentType.quote,
fields: {
quote,
author,
variant,
layout: {
mobile: mobile as any,
tablet: tablet as any,
desktop: desktop as any,
spaceBottom: spaceBottom as any,
},
},
};
}
/**
* Erstellt eine Mock YouTube-Video-Komponente
*/
function createMockYoutubeVideo(
id: string,
youtubeId: string,
params?: string,
title?: string,
description?: string,
mobile: string = "12",
tablet?: string,
desktop?: string,
spaceBottom?: number
): ComponentYoutubeVideoSkeleton {
return {
contentTypeId: ContentType.youtubeVideo,
fields: {
id,
youtubeId,
params,
title,
description,
layout: {
mobile: mobile as any,
tablet: tablet as any,
desktop: desktop as any,
spaceBottom: spaceBottom as any,
},
},
};
}
/**
* Erstellt eine Mock Headline-Komponente
*/
function createMockHeadline(
internal: string,
text: string,
tag: "h1" | "h2" | "h3" | "h4" | "h5" | "h6" = "h2",
align?: "left" | "center" | "right",
mobile: string = "12",
tablet?: string,
desktop?: string,
spaceBottom?: number
): ComponentHeadlineSkeleton {
return {
contentTypeId: ContentType.headline,
fields: {
internal,
text,
tag,
align,
layout: {
mobile: mobile as any,
tablet: tablet as any,
desktop: desktop as any,
spaceBottom: spaceBottom as any,
},
},
};
}
/**
* Generiert Mock-Seiten mit locale-spezifischen Inhalten
* @param locale - Die gewünschte Locale ("de" oder "en")
* @returns Record von Seiten mit locale-spezifischen Inhalten
*/
export function generateMockPages(locale: string = "de"): Record<string, Page> {
const isEn = locale === "en";
return {
"/": {
slug: "/",
name: isEn ? "Home" : "Home",
linkName: isEn ? "Home" : "Startseite",
headline: isEn
? "Welcome to our website"
: "Willkommen auf unserer Website",
subheadline: isEn
? "Discover our products and services"
: "Entdecken Sie unsere Produkte und Dienstleistungen",
seoTitle: isEn ? "Home - Welcome" : "Home - Willkommen",
seoMetaRobots: "index, follow",
seoDescription: isEn
? "Welcome to our website. Discover our products and services."
: "Willkommen auf unserer Website. Entdecken Sie unsere Produkte und Dienstleistungen.",
row1JustifyContent: "center",
row1AlignItems: "center",
row1Content: [
createMockMarkdown(
"welcome-intro",
isEn
? "# Welcome\n\nThis is a **markdown** component showcasing our content system.\n\n- Feature 1\n- Feature 2\n- Feature 3"
: "# Willkommen\n\nDies ist eine **Markdown**-Komponente, die unser Content-System zeigt.\n\n- Funktion 1\n- Funktion 2\n- Funktion 3",
"center",
"12",
"10",
"8",
2
),
],
row2JustifyContent: "start",
row2AlignItems: "start",
row2Content: [
createMockHTML(
"intro-html",
isEn
? '<div class="p-4 bg-blue-50 rounded-lg"><h2 class="text-2xl font-bold mb-2">HTML Content</h2><p>This is an HTML component with custom styling.</p></div>'
: '<div class="p-4 bg-blue-50 rounded-lg"><h2 class="text-2xl font-bold mb-2">HTML Inhalt</h2><p>Dies ist eine HTML-Komponente mit benutzerdefiniertem Styling.</p></div>',
"12",
"6",
"6",
1.5
),
createMockMarkdown(
"features",
isEn
? "## Features\n\n- Fast and reliable\n- Modern technology\n- Great support"
: "## Funktionen\n\n- Schnell und zuverlässig\n- Moderne Technologie\n- Großer Support",
"left",
"12",
"6",
"6",
1.5
),
],
row3JustifyContent: "start",
row3AlignItems: "start",
row3Content: [
createMockMarkdown(
"footer-note",
isEn
? "---\n\n*Thank you for visiting our website!*"
: "---\n\n*Vielen Dank für Ihren Besuch auf unserer Website!*",
"center",
"12",
"8",
"6"
),
],
topFullwidthBanner: {
contentTypeId: ContentType.fullwidthBanner,
fields: {
name: "home-banner",
variant: FullwidthBannerVariant.light,
headline: isEn ? "Welcome" : "Herzlich Willkommen",
subheadline: isEn
? "Your solution for all needs"
: "Ihre Lösung für alle Bedürfnisse",
text: isEn
? "Discover our diverse range of offerings and find exactly what you're looking for."
: "Entdecken Sie unsere vielfältigen Angebote und finden Sie genau das, was Sie suchen.",
image: [],
img: {
contentTypeId: ContentType.img,
fields: {
title: isEn ? "Home Banner" : "Home Banner",
description: isEn
? "Banner for the homepage"
: "Banner für die Startseite",
file: {
url: "https://picsum.photos/1200/400?random=home",
details: {
size: 45678,
image: {
width: 1200,
height: 400,
},
},
fileName: "home-banner.jpg",
contentType: "image/jpeg",
},
},
},
},
},
},
"/about": {
slug: "/about",
name: isEn ? "About Us" : "Über uns",
linkName: isEn ? "About Us" : "Über uns",
headline: isEn ? "About Us" : "Über uns",
subheadline: isEn
? "Get to know us better"
: "Lernen Sie uns besser kennen",
seoTitle: isEn ? "About Us - Our Story" : "Über uns - Unsere Geschichte",
seoMetaRobots: "index, follow",
seoDescription: isEn
? "Learn more about our company, our values and our mission."
: "Erfahren Sie mehr über unsere Firma, unsere Werte und unsere Mission.",
row1JustifyContent: "start",
row1AlignItems: "start",
row1Content: [
createMockMarkdown(
"about-intro",
isEn
? "# Our Story\n\nWe are a company dedicated to providing excellent service and innovative solutions."
: "# Unsere Geschichte\n\nWir sind ein Unternehmen, das sich der Bereitstellung exzellenter Dienstleistungen und innovativer Lösungen widmet.",
"left",
"12",
"8",
"8"
),
],
row2JustifyContent: "between",
row2AlignItems: "start",
row2Content: [
createMockHTML(
"mission",
isEn
? '<div class="p-6 border-l-4 border-blue-500"><h3 class="text-xl font-semibold mb-2">Our Mission</h3><p>To deliver exceptional value to our customers.</p></div>'
: '<div class="p-6 border-l-4 border-blue-500"><h3 class="text-xl font-semibold mb-2">Unsere Mission</h3><p>Außergewöhnlichen Mehrwert für unsere Kunden zu schaffen.</p></div>',
"12",
"5",
"5",
1
),
createMockHTML(
"vision",
isEn
? '<div class="p-6 border-l-4 border-green-500"><h3 class="text-xl font-semibold mb-2">Our Vision</h3><p>To be the leading provider in our industry.</p></div>'
: '<div class="p-6 border-l-4 border-green-500"><h3 class="text-xl font-semibold mb-2">Unsere Vision</h3><p>Der führende Anbieter in unserer Branche zu sein.</p></div>',
"12",
"5",
"5",
1
),
],
row3JustifyContent: "start",
row3AlignItems: "start",
row3Content: [
createMockMarkdown(
"values",
isEn
? "## Our Values\n\n1. **Integrity** - We do what we say\n2. **Innovation** - We embrace new ideas\n3. **Excellence** - We strive for the best"
: "## Unsere Werte\n\n1. **Integrität** - Wir halten, was wir versprechen\n2. **Innovation** - Wir begrüßen neue Ideen\n3. **Exzellenz** - Wir streben nach dem Besten",
"left",
"12",
"10",
"8"
),
],
topFullwidthBanner: {
contentTypeId: ContentType.fullwidthBanner,
fields: {
name: "about-banner",
variant: FullwidthBannerVariant.dark,
headline: isEn ? "About Us" : "Über uns",
subheadline: isEn
? "Our Story and Values"
: "Unsere Geschichte und Werte",
text: isEn
? "For many years, we have been your reliable partner for innovative solutions."
: "Seit vielen Jahren sind wir Ihr zuverlässiger Partner für innovative Lösungen.",
image: [],
img: {
contentTypeId: ContentType.img,
fields: {
title: isEn ? "About Banner" : "About Banner",
description: isEn
? "Banner for the about page"
: "Banner für die Über-uns-Seite",
file: {
url: "https://picsum.photos/1200/400?random=about",
details: {
size: 45678,
image: {
width: 1200,
height: 400,
},
},
fileName: "about-banner.jpg",
contentType: "image/jpeg",
},
},
},
},
},
},
"/404": {
slug: "/404",
name: isEn ? "404" : "404",
linkName: isEn ? "404" : "404",
headline: isEn ? "404 - Page Not Found" : "404 - Seite nicht gefunden",
subheadline: isEn
? "The page you are looking for does not exist."
: "Die gesuchte Seite existiert nicht.",
seoTitle: isEn ? "Page Not Found" : "Seite nicht gefunden",
seoMetaRobots: "noindex, follow",
seoDescription: isEn
? "The page you are looking for does not exist."
: "Die gesuchte Seite existiert nicht.",
row1JustifyContent: "center",
row1AlignItems: "center",
row1Content: [
createMockMarkdown(
"404-message",
isEn
? "# Page Not Found\n\nThe page you are looking for does not exist or has been moved.\n\nPlease check the URL or return to the [homepage](/)."
: "# Seite nicht gefunden\n\nDie gesuchte Seite existiert nicht oder wurde verschoben.\n\nBitte überprüfen Sie die URL oder kehren Sie zur [Startseite](/) zurück.",
"center",
"12",
"10",
"8"
),
],
row2JustifyContent: "start",
row2AlignItems: "start",
row2Content: [],
row3JustifyContent: "start",
row3AlignItems: "start",
row3Content: [],
topFullwidthBanner: {
contentTypeId: ContentType.fullwidthBanner,
fields: {
name: "404-banner",
variant: FullwidthBannerVariant.dark,
headline: isEn ? "404" : "404",
subheadline: isEn ? "Page Not Found" : "Seite nicht gefunden",
text: isEn
? "The page you are looking for does not exist."
: "Die gesuchte Seite existiert nicht.",
image: [],
img: {
contentTypeId: ContentType.img,
fields: {
title: isEn ? "404 Banner" : "404 Banner",
description: isEn
? "Banner for the 404 page"
: "Banner für die 404-Seite",
file: {
url: "https://picsum.photos/1200/400?random=404",
details: {
size: 45678,
image: {
width: 1200,
height: 400,
},
},
fileName: "404-banner.jpg",
contentType: "image/jpeg",
},
},
},
},
},
},
"/500": {
slug: "/500",
name: isEn ? "500" : "500",
linkName: isEn ? "500" : "500",
headline: isEn ? "500 - Server Error" : "500 - Serverfehler",
subheadline: isEn
? "Something went wrong on our end. Please try again later."
: "Etwas ist auf unserer Seite schiefgelaufen. Bitte versuchen Sie es später erneut.",
seoTitle: isEn ? "Server Error" : "Serverfehler",
seoMetaRobots: "noindex, follow",
seoDescription: isEn
? "Something went wrong on our end. Please try again later."
: "Etwas ist auf unserer Seite schiefgelaufen. Bitte versuchen Sie es später erneut.",
row1JustifyContent: "center",
row1AlignItems: "center",
row1Content: [
createMockMarkdown(
"500-message",
isEn
? "# Server Error\n\nWe're sorry, but something went wrong on our end.\n\nOur team has been notified and is working on fixing the issue. Please try again later or return to the [homepage](/)."
: "# Serverfehler\n\nEs tut uns leid, aber etwas ist auf unserer Seite schiefgelaufen.\n\nUnser Team wurde benachrichtigt und arbeitet an der Behebung des Problems. Bitte versuchen Sie es später erneut oder kehren Sie zur [Startseite](/) zurück.",
"center",
"12",
"10",
"8"
),
],
row2JustifyContent: "start",
row2AlignItems: "start",
row2Content: [],
row3JustifyContent: "start",
row3AlignItems: "start",
row3Content: [],
topFullwidthBanner: {
contentTypeId: ContentType.fullwidthBanner,
fields: {
name: "500-banner",
variant: FullwidthBannerVariant.dark,
headline: isEn ? "500" : "500",
subheadline: isEn ? "Server Error" : "Serverfehler",
text: isEn
? "Something went wrong on our end. Please try again later."
: "Etwas ist auf unserer Seite schiefgelaufen. Bitte versuchen Sie es später erneut.",
image: [],
img: {
contentTypeId: ContentType.img,
fields: {
title: isEn ? "500 Banner" : "500 Banner",
description: isEn
? "Banner for the 500 page"
: "Banner für die 500-Seite",
file: {
url: "https://picsum.photos/1200/400?random=500",
details: {
size: 45678,
image: {
width: 1200,
height: 400,
},
},
fileName: "500-banner.jpg",
contentType: "image/jpeg",
},
},
},
},
},
},
"/components": {
slug: "/components",
name: isEn ? "Components" : "Komponenten",
linkName: isEn ? "Components" : "Komponenten",
headline: isEn ? "Component Showcase" : "Komponenten-Showcase",
subheadline: isEn
? "All available content components"
: "Alle verfügbaren Content-Komponenten",
seoTitle: isEn ? "Components - Showcase" : "Komponenten - Showcase",
seoMetaRobots: "index, follow",
seoDescription: isEn
? "Showcase of all available content components."
: "Showcase aller verfügbaren Content-Komponenten.",
row1JustifyContent: "start",
row1AlignItems: "start",
row1Content: [
createMockHeadline(
"headline-h1",
isEn ? "Component Showcase" : "Komponenten-Showcase",
"h1",
"center",
"12"
),
createMockMarkdown(
"intro",
isEn
? "This page demonstrates all available content components in our CMS system."
: "Diese Seite demonstriert alle verfügbaren Content-Komponenten in unserem CMS-System.",
"center",
"12",
"10",
"8"
),
],
row2JustifyContent: "start",
row2AlignItems: "start",
row2Content: [
createMockHeadline(
"headline-html",
isEn ? "HTML Component" : "HTML-Komponente",
"h2",
"left",
"12",
"6",
"6"
),
createMockHTML(
"html-example",
isEn
? '<div class="p-4 bg-blue-50 rounded-lg border-l-4 border-blue-500"><h3 class="text-xl font-semibold mb-2">HTML Content</h3><p>This is an <strong>HTML</strong> component with custom styling.</p></div>'
: '<div class="p-4 bg-blue-50 rounded-lg border-l-4 border-blue-500"><h3 class="text-xl font-semibold mb-2">HTML Inhalt</h3><p>Dies ist eine <strong>HTML</strong>-Komponente mit benutzerdefiniertem Styling.</p></div>',
"12",
"6",
"6"
),
createMockHeadline(
"headline-markdown",
isEn ? "Markdown Component" : "Markdown-Komponente",
"h2",
"left",
"12",
"6",
"6"
),
createMockMarkdown(
"markdown-example",
isEn
? "## Markdown Content\n\nThis is a **Markdown** component with:\n\n- Lists\n- **Bold** text\n- *Italic* text\n- [Links](/)"
: "## Markdown Inhalt\n\nDies ist eine **Markdown**-Komponente mit:\n\n- Listen\n- **Fettem** Text\n- *Kursivem* Text\n- [Links](/)",
"left",
"12",
"6",
"6"
),
],
row3JustifyContent: "start",
row3AlignItems: "start",
row3Content: [
createMockHeadline(
"headline-image",
isEn ? "Image Component" : "Bild-Komponente",
"h2",
"left",
"12"
),
createMockImageComponent(
"sample-image",
"https://picsum.photos/800/600?random=1",
isEn
? "A sample image with caption"
: "Ein Beispielbild mit Beschriftung",
undefined,
undefined,
"12",
"8",
"6"
),
createMockHeadline(
"headline-gallery",
isEn ? "Image Gallery" : "Bildergalerie",
"h2",
"left",
"12"
),
createMockImageGallery(
"gallery-example",
[
{
title: isEn ? "Image 1" : "Bild 1",
description: isEn ? "First gallery image" : "Erstes Galeriebild",
url: "https://picsum.photos/400/300?random=2",
},
{
title: isEn ? "Image 2" : "Bild 2",
description: isEn
? "Second gallery image"
: "Zweites Galeriebild",
url: "https://picsum.photos/400/300?random=3",
},
{
title: isEn ? "Image 3" : "Bild 3",
description: isEn ? "Third gallery image" : "Drittes Galeriebild",
url: "https://picsum.photos/400/300?random=4",
},
{
title: isEn ? "Image 4" : "Bild 4",
url: "https://picsum.photos/400/300?random=5",
},
],
isEn
? "A collection of sample images"
: "Eine Sammlung von Beispielbildern",
"12"
),
createMockHeadline(
"headline-quote",
isEn ? "Quote Component" : "Zitat-Komponente",
"h2",
"left",
"12"
),
createMockQuote(
isEn
? "The only way to do great work is to love what you do."
: "Die einzige Möglichkeit, großartige Arbeit zu leisten, ist, das zu lieben, was man tut.",
isEn ? "Steve Jobs" : "Steve Jobs",
"left",
"12",
"6",
"6"
),
createMockQuote(
isEn
? "Innovation distinguishes between a leader and a follower."
: "Innovation unterscheidet einen Führer von einem Anhänger.",
isEn ? "Steve Jobs" : "Steve Jobs",
"right",
"12",
"6",
"6"
),
createMockHeadline(
"headline-youtube",
isEn ? "YouTube Video" : "YouTube-Video",
"h2",
"left",
"12"
),
createMockYoutubeVideo(
"youtube-1",
"dQw4w9WgXcQ",
"autoplay=0",
isEn ? "Sample YouTube Video" : "Beispiel-YouTube-Video",
isEn
? "A sample YouTube video embedded in the page"
: "Ein eingebettetes YouTube-Video auf der Seite",
"12",
"10",
"8"
),
createMockHeadline(
"headline-iframe",
isEn ? "Iframe Component" : "Iframe-Komponente",
"h2",
"left",
"12"
),
createMockIframe(
"iframe-example",
isEn
? "<p>This is an iframe component with embedded content.</p>"
: "<p>Dies ist eine Iframe-Komponente mit eingebettetem Inhalt.</p>",
"https://example.com",
"https://picsum.photos/800/400?random=6",
"12",
"10",
"8"
),
],
topFullwidthBanner: {
contentTypeId: ContentType.fullwidthBanner,
fields: {
name: "components-banner",
variant: FullwidthBannerVariant.light,
headline: isEn ? "Component Showcase" : "Komponenten-Showcase",
subheadline: isEn
? "All available content components"
: "Alle verfügbaren Content-Komponenten",
text: isEn
? "Explore all the different content components available in our CMS system."
: "Entdecken Sie alle verschiedenen Content-Komponenten, die in unserem CMS-System verfügbar sind.",
image: [],
img: {
contentTypeId: ContentType.img,
fields: {
title: isEn ? "Components Banner" : "Komponenten Banner",
description: isEn
? "Banner for the components page"
: "Banner für die Komponenten-Seite",
file: {
url: "https://picsum.photos/1200/400?random=components",
details: {
size: 45678,
image: {
width: 1200,
height: 400,
},
},
fileName: "components-banner.jpg",
contentType: "image/jpeg",
},
},
},
},
},
},
};
}

View File

@@ -0,0 +1,50 @@
import type { PageConfig } from "../../../types/cms/PageConfig";
import { ContentType } from "../../../types/cms/ContentType.enum";
/**
* Generiert Mock-PageConfig mit locale-spezifischen Inhalten
* @param locale - Die gewünschte Locale ("de" oder "en")
*/
export function generateMockPageConfig(locale: string = "de"): PageConfig {
const isEn = locale === "en";
return {
logo: {
contentTypeId: ContentType.img,
fields: {
title: isEn ? 'Company Logo' : 'Firmenlogo',
description: isEn ? 'Main company logo' : 'Haupt-Firmenlogo',
file: {
url: 'https://picsum.photos/200/60?random=logo',
details: {
size: 12345,
image: {
width: 200,
height: 60,
},
},
fileName: 'logo.png',
contentType: 'image/png',
},
},
},
footerText1: isEn
? '© 2024 My Company. All rights reserved.'
: '© 2024 Meine Firma. Alle Rechte vorbehalten.',
seoTitle: isEn
? 'Welcome to our website'
: 'Willkommen auf unserer Website',
seoDescription: isEn
? 'Discover our products and services. We offer high-quality solutions for your needs.'
: 'Entdecken Sie unsere Produkte und Dienstleistungen. Wir bieten hochwertige Lösungen für Ihre Bedürfnisse.',
blogTagPageHeadline: isEn ? 'Blog Tags' : 'Blog Tags',
blogPostsPageHeadline: isEn
? 'Our Blog Posts'
: 'Unsere Blog-Beiträge',
blogPostsPageSubHeadline: isEn
? 'Current articles and news'
: 'Aktuelle Artikel und Neuigkeiten',
website: 'https://example.com',
};
}

View File

@@ -0,0 +1,113 @@
import type { Product } from "../../../types/product";
const productNames = [
"Laptop Pro 15",
"Wireless Headphones",
"Smart Watch Series 8",
"Mechanical Keyboard",
"Gaming Mouse",
"USB-C Hub",
"External SSD 1TB",
"Webcam HD 1080p",
"Standing Desk",
"Ergonomic Chair",
'Monitor 27" 4K',
"Tablet Pro",
"Smart Speaker",
"Action Camera",
"Drone Mini",
];
const categories = [
"Electronics",
"Computers",
"Audio",
"Accessories",
"Furniture",
"Photography",
];
const descriptions = [
"High-performance device with cutting-edge technology",
"Premium quality product designed for professionals",
"Latest model with advanced features",
"Durable and reliable for everyday use",
"Compact design with powerful capabilities",
];
function getRandomElement<T>(array: T[]): T {
return array[Math.floor(Math.random() * array.length)];
}
function getRandomPrice(): number {
return Math.round((Math.random() * 900 + 10) * 100) / 100;
}
function generateProduct(id: string): Product {
const basePrice = getRandomPrice();
// 40% Chance auf Promotion
const hasPromotion = Math.random() < 0.4;
let price = basePrice;
let originalPrice: number | undefined;
let promotion: Product["promotion"];
if (hasPromotion) {
// Zufällige Promotion auswählen
const promotionType = Math.random();
if (promotionType < 0.33) {
// Sale mit Rabatt
const discountPercent = [10, 20, 30, 40, 50][
Math.floor(Math.random() * 5)
];
originalPrice = basePrice;
price = Math.round(basePrice * (1 - discountPercent / 100) * 100) / 100;
promotion = {
category: "sale",
text: `-${discountPercent}%`,
};
} else if (promotionType < 0.66) {
// Sale (generisch)
originalPrice = basePrice;
price = Math.round(basePrice * 0.7 * 100) / 100; // 30% Rabatt
promotion = {
category: "sale",
text: "-30%",
};
} else {
// Topseller
promotion = {
category: "topseller",
text: "top",
};
}
}
return {
id,
name: getRandomElement(productNames),
description: getRandomElement(descriptions),
price,
originalPrice,
currency: "EUR",
imageUrl: `https://picsum.photos/400/300?random=${id}`,
category: getRandomElement(categories),
inStock: Math.random() > 0.2, // 80% chance of being in stock
promotion,
};
}
export function generateRandomProducts(count: number = 4): Product[] {
const products: Product[] = [];
const usedIds = new Set<string>();
while (products.length < count) {
const id = `prod-${Math.floor(Math.random() * 10000)}`;
if (!usedIds.has(id)) {
usedIds.add(id);
products.push(generateProduct(id));
}
}
return products;
}

View File

@@ -0,0 +1,191 @@
/**
* Mock-Daten für Übersetzungen
* Diese können später durch einen echten CMS-Adapter ersetzt werden
*/
export interface Translation {
key: string;
value: string;
namespace?: string;
}
export interface TranslationsData {
locale: string;
translations: Translation[];
}
/**
* Mock-Übersetzungen für Deutsch (de)
*/
export const mockTranslationsDe: TranslationsData = {
locale: "de",
translations: [
// Login Modal
{ key: "login.title", value: "Anmelden", namespace: "auth" },
{ key: "login.email", value: "E-Mail", namespace: "auth" },
{ key: "login.password", value: "Passwort", namespace: "auth" },
{ key: "login.submit", value: "Anmelden", namespace: "auth" },
{ key: "login.loading", value: "Wird geladen...", namespace: "auth" },
{
key: "login.error",
value: "Login fehlgeschlagen. Bitte versuchen Sie es erneut.",
namespace: "auth",
},
{
key: "login.noAccount",
value: "Noch kein Konto?",
namespace: "auth",
},
{
key: "login.registerNow",
value: "Jetzt registrieren",
namespace: "auth",
},
// Register Modal
{ key: "register.title", value: "Registrieren", namespace: "auth" },
{ key: "register.name", value: "Name", namespace: "auth" },
{ key: "register.email", value: "E-Mail", namespace: "auth" },
{ key: "register.password", value: "Passwort", namespace: "auth" },
{
key: "register.confirmPassword",
value: "Passwort bestätigen",
namespace: "auth",
},
{ key: "register.submit", value: "Registrieren", namespace: "auth" },
{
key: "register.loading",
value: "Wird geladen...",
namespace: "auth",
},
{
key: "register.error",
value: "Registrierung fehlgeschlagen. Bitte versuchen Sie es erneut.",
namespace: "auth",
},
{
key: "register.passwordMismatch",
value: "Passwörter stimmen nicht überein",
namespace: "auth",
},
{
key: "register.passwordTooShort",
value: "Passwort muss mindestens 6 Zeichen lang sein",
namespace: "auth",
},
{
key: "register.hasAccount",
value: "Bereits ein Konto?",
namespace: "auth",
},
{
key: "register.loginNow",
value: "Jetzt anmelden",
namespace: "auth",
},
// Navigation
{ key: "nav.login", value: "Anmelden", namespace: "common" },
{ key: "nav.register", value: "Registrieren", namespace: "common" },
{ key: "nav.logout", value: "Abmelden", namespace: "common" },
{ key: "nav.greeting", value: "Hallo, {name}", namespace: "common" },
],
};
/**
* Mock-Übersetzungen für Englisch (en)
*/
export const mockTranslationsEn: TranslationsData = {
locale: "en",
translations: [
// Login Modal
{ key: "login.title", value: "Sign In", namespace: "auth" },
{ key: "login.email", value: "Email", namespace: "auth" },
{ key: "login.password", value: "Password", namespace: "auth" },
{ key: "login.submit", value: "Sign In", namespace: "auth" },
{ key: "login.loading", value: "Loading...", namespace: "auth" },
{
key: "login.error",
value: "Login failed. Please try again.",
namespace: "auth",
},
{
key: "login.noAccount",
value: "Don't have an account?",
namespace: "auth",
},
{
key: "login.registerNow",
value: "Register now",
namespace: "auth",
},
// Register Modal
{ key: "register.title", value: "Register", namespace: "auth" },
{ key: "register.name", value: "Name", namespace: "auth" },
{ key: "register.email", value: "Email", namespace: "auth" },
{ key: "register.password", value: "Password", namespace: "auth" },
{
key: "register.confirmPassword",
value: "Confirm Password",
namespace: "auth",
},
{ key: "register.submit", value: "Register", namespace: "auth" },
{
key: "register.loading",
value: "Loading...",
namespace: "auth",
},
{
key: "register.error",
value: "Registration failed. Please try again.",
namespace: "auth",
},
{
key: "register.passwordMismatch",
value: "Passwords do not match",
namespace: "auth",
},
{
key: "register.passwordTooShort",
value: "Password must be at least 6 characters long",
namespace: "auth",
},
{
key: "register.hasAccount",
value: "Already have an account?",
namespace: "auth",
},
{
key: "register.loginNow",
value: "Sign in now",
namespace: "auth",
},
// Navigation
{ key: "nav.login", value: "Sign In", namespace: "common" },
{ key: "nav.register", value: "Register", namespace: "common" },
{ key: "nav.logout", value: "Logout", namespace: "common" },
{ key: "nav.greeting", value: "Hello, {name}", namespace: "common" },
],
};
/**
* Gibt Übersetzungen für eine bestimmte Locale zurück
*/
export function getTranslations(
locale: string = "de",
namespace?: string
): TranslationsData {
const translations =
locale === "en" ? mockTranslationsEn : mockTranslationsDe;
// Filter nach Namespace, falls angegeben
if (namespace) {
return {
locale: translations.locale,
translations: translations.translations.filter(
(t) => t.namespace === namespace
),
};
}
return translations;
}

View File

@@ -0,0 +1,93 @@
import type { DataAdapter } from "../interface";
import type { PageSeo, Page, Navigation, Product } from "../../types/index";
import { generateMockPageConfig } from "./_cms/mockPageConfig";
import { generateMockPages } from "./_cms/mockPage";
import { generateMockNavigation } from "./_cms/mockNavigation";
import { generateRandomProducts } from "./_cms/mockProducts";
import { PageMapper } from "../../mappers/pageMapper";
import { getTranslations } from "./_i18n/mockTranslations";
import type { TranslationsData } from "./_i18n/mockTranslations";
/**
* Mockdata Adapter - verwendet lokale Mock-Daten
*/
export class MockdataAdapter implements DataAdapter {
async getProducts(limit: number = 4): Promise<Product[]> {
return generateRandomProducts(limit);
}
async getProduct(id: string): Promise<Product | null> {
const products = generateRandomProducts(1);
return products[0] ? { ...products[0], id } : null;
}
async getPage(slug: string, locale?: string): Promise<Page | null> {
// Verwende Locale für locale-spezifische Inhalte
const pages = generateMockPages(locale || "de");
const page = pages[slug];
if (!page) return null;
return PageMapper.fromCms(page);
}
async getPages(locale?: string): Promise<Page[]> {
// Verwende Locale für locale-spezifische Inhalte
const pages = generateMockPages(locale || "de");
return PageMapper.fromCmsArray(Object.values(pages));
}
async getPageSeo(locale?: string): Promise<PageSeo> {
// Verwende Locale für locale-spezifische SEO-Daten
const pageConfig = generateMockPageConfig(locale || "de");
return {
title: pageConfig.seoTitle,
description: pageConfig.seoDescription,
metaRobotsIndex: "index",
metaRobotsFollow: "follow",
};
}
async getNavigation(locale?: string): Promise<Navigation> {
// Verwende Locale für locale-spezifische Navigation
const nav = generateMockNavigation(locale || "de");
const pages = generateMockPages(locale || "de");
// Konvertiere die Links zu NavigationLink-Format
const links = nav.links.map((link: any) => {
// Wenn es eine Page ist (hat slug)
if (link.fields.slug) {
const page = pages[link.fields.slug];
if (page) {
return {
slug: page.slug,
name: page.name,
linkName: page.linkName,
url: page.slug,
icon: page.icon,
newTab: false,
};
}
}
// Wenn es ein Link ist
return {
name: link.fields.name || link.fields.linkName,
linkName: link.fields.linkName,
url: link.fields.url,
icon: link.fields.icon,
newTab: link.fields.newTab || false,
};
});
return {
name: nav.name,
internal: nav.internal,
links: links.filter(Boolean),
};
}
async getTranslations(
locale: string = "de",
namespace?: string
): Promise<TranslationsData> {
return getTranslations(locale, namespace);
}
}

View File

@@ -0,0 +1,23 @@
import { MockdataAdapter } from "./Mock/mockdata";
import type { DataAdapter } from "./interface";
/**
* Adapter-Konfiguration
* Bestimmt welcher Adapter basierend auf Environment-Variablen verwendet wird
*/
export function createAdapter(): DataAdapter {
const adapterType = process.env.DATA_ADAPTER || "mock";
switch (adapterType) {
case "mock":
return new MockdataAdapter();
// Weitere Adapter können hier hinzugefügt werden:
// case 'contentful':
// return new ContentfulAdapter(process.env.CONTENTFUL_SPACE_ID!, process.env.CONTENTFUL_ACCESS_TOKEN!);
default:
console.warn(
`Unbekannter Adapter-Typ: ${adapterType}. Verwende Mock-Adapter.`
);
return new MockdataAdapter();
}
}

View File

@@ -0,0 +1,27 @@
import type { PageSeo, Page, Navigation, Product } from "../types/index";
import type {
TranslationsData,
} from "./Mock/_i18n/mockTranslations";
/**
* Adapter Interface für Datenquellen
* Jeder Adapter muss diese Schnittstelle implementieren
*/
export interface DataAdapter {
// Product Operations
getProducts(limit?: number): Promise<Product[]>;
getProduct(id: string): Promise<Product | null>;
// Page Operations
getPage(slug: string, locale?: string): Promise<Page | null>;
getPages(locale?: string): Promise<Page[]>;
// SEO Operations
getPageSeo(locale?: string): Promise<PageSeo>;
// Navigation Operations
getNavigation(locale?: string): Promise<Navigation>;
// Translation Operations
getTranslations(locale?: string, namespace?: string): Promise<TranslationsData>;
}

150
middlelayer/auth/README.md Normal file
View File

@@ -0,0 +1,150 @@
# Authentication & Authorization
## Übersicht
Der Middlelayer unterstützt JWT-basierte Authentication und Role-Based Access Control (RBAC).
## Features
- ✅ JWT-basierte Authentication
- ✅ Passwort-Hashing mit bcrypt
- ✅ Role-Based Access Control (Admin, Customer, Guest)
- ✅ Protected Resolvers
- ✅ User-Context in GraphQL Requests
## User-Rollen
- **ADMIN**: Vollzugriff auf alle Ressourcen
- **CUSTOMER**: Zugriff auf Kunden-spezifische Ressourcen
- **GUEST**: Nur öffentliche Ressourcen
## GraphQL Mutations
### Register
```graphql
mutation Register {
register(email: "user@example.com", password: "secure123", name: "Max Mustermann") {
user {
id
email
name
role
}
token
}
}
```
### Login
```graphql
mutation Login {
login(email: "user@example.com", password: "secure123") {
user {
id
email
name
role
}
token
}
}
```
## GraphQL Queries
### Aktueller User
```graphql
query Me {
me {
id
email
name
role
}
}
```
## Authorization in Resolvers
### Beispiel: Protected Resolver
```typescript
import { requireAuth, requireAdmin } from "./auth/authorization.js";
export const resolvers = {
Query: {
adminOnlyData: async (_: unknown, __: unknown, context: GraphQLContext) => {
// Prüft ob User Admin ist
requireAdmin(context.user);
// Resolver-Logik...
},
},
};
```
### Verfügbare Authorization-Helper
- `requireAuth(user)` - Prüft ob User authentifiziert ist
- `requireRole(user, roles)` - Prüft ob User eine bestimmte Rolle hat
- `requireAdmin(user)` - Prüft ob User Admin ist
- `requireCustomer(user)` - Prüft ob User Customer oder Admin ist
## Verwendung im Frontend
### Token speichern
```typescript
// Nach Login/Register
const { token } = await login(email, password);
localStorage.setItem('authToken', token);
```
### Token in Requests verwenden
```typescript
const response = await fetch('http://localhost:4000', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('authToken')}`,
},
body: JSON.stringify({ query, variables }),
});
```
## Konfiguration
### Environment Variables
```bash
JWT_SECRET=your-secret-key-change-in-production
JWT_EXPIRES_IN=7d # Token-Gültigkeitsdauer
```
**Wichtig:** In Production muss `JWT_SECRET` sicher gesetzt werden!
## Security Best Practices
1. **JWT Secret**: Verwende einen starken, zufälligen Secret
2. **HTTPS**: Immer HTTPS in Production verwenden
3. **Token Expiration**: Setze angemessene Expiration-Zeiten
4. **Password Hashing**: Passwörter werden automatisch mit bcrypt gehasht
5. **Rate Limiting**: (Noch zu implementieren) Verhindere Brute-Force-Angriffe
## Mock User Store
Aktuell werden User in einem In-Memory Store gespeichert. Für Production sollte dies durch eine Datenbank ersetzt werden.
## Nächste Schritte
- [ ] Database-Integration für User-Speicherung
- [ ] Refresh Tokens
- [ ] Password Reset
- [ ] Email Verification
- [ ] Rate Limiting für Login/Register
- [ ] Session Management

View File

@@ -0,0 +1,59 @@
import { GraphQLError } from "graphql";
import type { User, UserRole } from "../types/user.js";
import { UserRole as UserRoleEnum } from "../types/user.js";
/**
* Authorization-Fehler
*/
export class AuthorizationError extends GraphQLError {
constructor(message: string) {
super(message, {
extensions: {
code: "UNAUTHORIZED",
},
});
}
}
/**
* Prüft ob User authentifiziert ist
*/
export function requireAuth(user: User | null): User {
if (!user) {
throw new AuthorizationError("Authentifizierung erforderlich");
}
return user;
}
/**
* Prüft ob User eine bestimmte Rolle hat
*/
export function requireRole(
user: User | null,
requiredRoles: UserRole[]
): User {
const authenticatedUser = requireAuth(user);
if (!requiredRoles.includes(authenticatedUser.role)) {
throw new AuthorizationError(
`Zugriff verweigert. Erforderliche Rollen: ${requiredRoles.join(", ")}`
);
}
return authenticatedUser;
}
/**
* Prüft ob User Admin ist
*/
export function requireAdmin(user: User | null): User {
return requireRole(user, [UserRoleEnum.ADMIN]);
}
/**
* Prüft ob User Customer oder Admin ist
*/
export function requireCustomer(user: User | null): User {
return requireRole(user, [UserRoleEnum.CUSTOMER, UserRoleEnum.ADMIN]);
}

63
middlelayer/auth/jwt.ts Normal file
View File

@@ -0,0 +1,63 @@
import jwt from "jsonwebtoken";
import type { JWTPayload, UserRole } from "../types/user.js";
import { logger } from "../monitoring/logger.js";
const JWT_SECRET =
process.env.JWT_SECRET || "your-secret-key-change-in-production";
const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || "7d";
/**
* Erstellt ein JWT Token für einen User
*/
export function createToken(payload: JWTPayload): string {
return jwt.sign(payload, JWT_SECRET, {
expiresIn: JWT_EXPIRES_IN,
});
}
/**
* Verifiziert ein JWT Token
*/
export function verifyToken(token: string): JWTPayload | null {
try {
const decoded = jwt.verify(token, JWT_SECRET) as JWTPayload;
return decoded;
} catch (error) {
logger.warn("JWT verification failed", { error });
return null;
}
}
/**
* Extrahiert Token aus Authorization Header
*/
export function extractTokenFromHeader(
authHeader: string | null
): string | null {
if (!authHeader) return null;
// Format: "Bearer <token>"
const parts = authHeader.split(" ");
if (parts.length !== 2 || parts[0] !== "Bearer") {
return null;
}
return parts[1];
}
/**
* Prüft ob User eine bestimmte Rolle hat
*/
export function hasRole(
userRole: UserRole,
requiredRoles: UserRole[]
): boolean {
return requiredRoles.includes(userRole);
}
/**
* Prüft ob User Admin ist
*/
export function isAdmin(userRole: UserRole): boolean {
return userRole === UserRole.ADMIN;
}

View File

@@ -0,0 +1,31 @@
import bcrypt from "bcryptjs";
import { logger } from "../monitoring/logger.js";
const SALT_ROUNDS = 10;
/**
* Hasht ein Passwort
*/
export async function hashPassword(password: string): Promise<string> {
try {
return await bcrypt.hash(password, SALT_ROUNDS);
} catch (error) {
logger.error("Password hashing failed", { error });
throw new Error("Fehler beim Hashen des Passworts");
}
}
/**
* Vergleicht ein Passwort mit einem Hash
*/
export async function comparePassword(
password: string,
hash: string
): Promise<boolean> {
try {
return await bcrypt.compare(password, hash);
} catch (error) {
logger.error("Password comparison failed", { error });
return false;
}
}

View File

@@ -0,0 +1,140 @@
import type {
User,
UserRole,
LoginCredentials,
RegisterData,
} from "../types/user.js";
import { hashPassword, comparePassword } from "./password.js";
import { createToken, verifyToken } from "./jwt.js";
import { logger } from "../monitoring/logger.js";
/**
* Mock User Store (später durch Datenbank ersetzen)
*/
const users = new Map<string, User & { passwordHash: string }>();
/**
* User Service für Authentication
*/
export class UserService {
/**
* Registriert einen neuen User
*/
async register(
data: RegisterData,
role: UserRole = "customer"
): Promise<{
user: User;
token: string;
}> {
// Prüfe ob User bereits existiert
const existingUser = Array.from(users.values()).find(
(u) => u.email === data.email
);
if (existingUser) {
throw new Error("User mit dieser E-Mail existiert bereits");
}
// Hashe Passwort
const passwordHash = await hashPassword(data.password);
// Erstelle User
const user: User = {
id: `user-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
email: data.email,
name: data.name,
role,
createdAt: new Date(),
};
// Speichere User
users.set(user.id, { ...user, passwordHash });
// Erstelle Token
const token = createToken({
userId: user.id,
email: user.email,
role: user.role,
});
logger.info("User registered", { userId: user.id, email: user.email });
return { user, token };
}
/**
* Login eines Users
*/
async login(credentials: LoginCredentials): Promise<{
user: User;
token: string;
}> {
// Finde User
const userEntry = Array.from(users.values()).find(
(u) => u.email === credentials.email
);
if (!userEntry) {
throw new Error("Ungültige E-Mail oder Passwort");
}
// Vergleiche Passwort
const isValid = await comparePassword(
credentials.password,
userEntry.passwordHash
);
if (!isValid) {
throw new Error("Ungültige E-Mail oder Passwort");
}
// Erstelle User-Objekt ohne Passwort
const user: User = {
id: userEntry.id,
email: userEntry.email,
name: userEntry.name,
role: userEntry.role,
createdAt: userEntry.createdAt,
};
// Erstelle Token
const token = createToken({
userId: user.id,
email: user.email,
role: user.role,
});
logger.info("User logged in", { userId: user.id, email: user.email });
return { user, token };
}
/**
* Holt User anhand der ID
*/
async getUserById(userId: string): Promise<User | null> {
const userEntry = users.get(userId);
if (!userEntry) return null;
return {
id: userEntry.id,
email: userEntry.email,
name: userEntry.name,
role: userEntry.role,
createdAt: userEntry.createdAt,
};
}
/**
* Holt User anhand des Tokens
*/
async getUserFromToken(token: string): Promise<User | null> {
const payload = verifyToken(token);
if (!payload) return null;
return this.getUserById(payload.userId);
}
}
// Singleton-Instanz
export const userService = new UserService();

View File

@@ -0,0 +1,19 @@
/**
* Cache-Konfiguration
* TTL-Werte in Millisekunden
*/
export const cacheConfig = {
pages: {
ttl: parseInt(process.env.CACHE_PAGES_TTL || '60000'), // 60 Sekunden
},
pageSeo: {
ttl: parseInt(process.env.CACHE_PAGE_SEO_TTL || '300000'), // 5 Minuten
},
navigation: {
ttl: parseInt(process.env.CACHE_NAVIGATION_TTL || '300000'), // 5 Minuten
},
products: {
ttl: parseInt(process.env.CACHE_PRODUCTS_TTL || '30000'), // 30 Sekunden
},
} as const;

129
middlelayer/dataService.ts Normal file
View File

@@ -0,0 +1,129 @@
import type { DataAdapter } from "./adapters/interface.js";
import { createAdapter } from "./adapters/config.js";
import type { PageSeo, Page, Navigation, Product } from "./types/index.js";
import type { TranslationsData } from "./adapters/Mock/_i18n/mockTranslations.js";
import { AdapterError } from "./utils/errors.js";
import { cache } from "./utils/cache.js";
import { CacheKeyBuilder } from "./utils/cacheKeys.js";
import { DataServiceHelpers } from "./utils/dataServiceHelpers.js";
/**
* DataService - Aggregator für Datenoperationen
* Verwendet den konfigurierten Adapter für alle Datenzugriffe
* Mit Caching und Fehlerbehandlung
*/
class DataService {
private adapter: DataAdapter;
constructor(adapter?: DataAdapter) {
this.adapter = adapter || createAdapter();
}
/**
* Setzt einen neuen Adapter
*/
async setAdapter(adapter: DataAdapter): Promise<void> {
this.adapter = adapter;
// Cache leeren bei Adapter-Wechsel
await Promise.all([
cache.pages.clear(),
cache.pageSeo.clear(),
cache.navigation.clear(),
cache.products.clear(),
]);
}
/**
* Holt eine einzelne Seite
*/
async getPage(slug: string, locale?: string): Promise<Page | null> {
return DataServiceHelpers.withCacheAndMetrics(
"getPage",
cache.pages,
CacheKeyBuilder.page(slug, locale),
() => this.adapter.getPage(slug, locale),
`Fehler beim Laden der Seite '${slug}'`,
{ slug, locale }
);
}
/**
* Holt alle Seiten
*/
async getPages(locale?: string): Promise<Page[]> {
return DataServiceHelpers.withCache(
cache.pages,
CacheKeyBuilder.pages(locale),
() => this.adapter.getPages(locale),
"Fehler beim Laden der Seiten"
);
}
/**
* Holt SEO-Daten
*/
async getPageSeo(locale?: string): Promise<PageSeo> {
return DataServiceHelpers.withCache(
cache.pageSeo,
CacheKeyBuilder.pageSeo(locale),
() => this.adapter.getPageSeo(locale),
"Fehler beim Laden der SEO-Daten"
);
}
/**
* Holt Navigation
*/
async getNavigation(locale?: string): Promise<Navigation> {
return DataServiceHelpers.withCache(
cache.navigation,
CacheKeyBuilder.navigation(locale),
() => this.adapter.getNavigation(locale),
"Fehler beim Laden der Navigation"
);
}
/**
* Holt Produkte
*/
async getProducts(limit?: number): Promise<Product[]> {
return DataServiceHelpers.withCacheAndMetrics(
"getProducts",
cache.products,
CacheKeyBuilder.products(limit),
() => this.adapter.getProducts(limit),
"Fehler beim Laden der Produkte",
{ limit }
);
}
/**
* Holt ein einzelnes Produkt
*/
async getProduct(id: string): Promise<Product | null> {
return DataServiceHelpers.withCache(
cache.products,
CacheKeyBuilder.product(id),
() => this.adapter.getProduct(id),
`Fehler beim Laden des Produkts '${id}'`
);
}
/**
* Holt Übersetzungen
*/
async getTranslations(
locale: string = "de",
namespace?: string
): Promise<TranslationsData> {
return DataServiceHelpers.withCache(
cache.pages,
CacheKeyBuilder.translations(locale, namespace),
() => this.adapter.getTranslations(locale, namespace),
`Fehler beim Laden der Übersetzungen für '${locale}'`
);
}
}
// Singleton-Instanz mit konfiguriertem Adapter
export const dataService = new DataService();

119
middlelayer/index.ts Normal file
View File

@@ -0,0 +1,119 @@
import { ApolloServer } from "@apollo/server";
import { startStandaloneServer } from "@apollo/server/standalone";
import { createServer } from "http";
import { typeDefs } from "./schema.js";
import { resolvers } from "./resolvers.js";
import { queryComplexityPlugin } from "./plugins/queryComplexity.js";
import { createResponseCachePlugin } from "./plugins/responseCache.js";
import {
monitoringPlugin,
queryComplexityMonitoringPlugin,
} from "./plugins/monitoring.js";
import { createContext, type GraphQLContext } from "./utils/dataloaders.js";
import { logger } from "./monitoring/logger.js";
import { getMetrics } from "./monitoring/metrics.js";
import { extractTokenFromHeader } from "./auth/jwt.js";
import { userService } from "./auth/userService.js";
const PORT = process.env.PORT ? parseInt(process.env.PORT) : 4000;
const METRICS_PORT = process.env.METRICS_PORT
? parseInt(process.env.METRICS_PORT)
: 9090;
// Konfiguration aus Environment Variables
const MAX_QUERY_COMPLEXITY = process.env.MAX_QUERY_COMPLEXITY
? parseInt(process.env.MAX_QUERY_COMPLEXITY)
: 1000;
/**
* Startet einen separaten HTTP-Server für Metrics (Prometheus)
*/
function startMetricsServer() {
const server = createServer(async (req, res) => {
if (req.url === "/metrics" && req.method === "GET") {
try {
const metrics = await getMetrics();
res.writeHead(200, { "Content-Type": "text/plain; version=0.0.4" });
res.end(metrics);
} catch (error) {
logger.error("Failed to get metrics", { error });
res.writeHead(500, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "Failed to get metrics" }));
}
} else if (req.url === "/health" && req.method === "GET") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ status: "ok", service: "graphql-middlelayer" }));
} else {
res.writeHead(404, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "Not found" }));
}
});
server.listen(METRICS_PORT, () => {
logger.info(
`📊 Metrics Server läuft auf: http://localhost:${METRICS_PORT}/metrics`
);
logger.info(
`❤️ Health Check verfügbar unter: http://localhost:${METRICS_PORT}/health`
);
});
return server;
}
async function startServer() {
const server = new ApolloServer<GraphQLContext>({
typeDefs,
resolvers,
plugins: [
// Monitoring (muss zuerst sein für vollständiges Tracking)
monitoringPlugin(),
queryComplexityMonitoringPlugin(),
// Query Complexity Limit
queryComplexityPlugin({
maxComplexity: MAX_QUERY_COMPLEXITY,
defaultComplexity: 1,
}),
// Response Caching
createResponseCachePlugin(),
],
});
const { url } = await startStandaloneServer(server, {
listen: { port: PORT },
context: async ({ req }) => {
// Extrahiere User aus Authorization Header
let user = null;
const authHeader = req.headers.authorization;
const token = extractTokenFromHeader(authHeader || null);
if (token) {
try {
user = await userService.getUserFromToken(token);
} catch (error) {
logger.warn("Token verification failed", { error });
}
}
// Erstelle Context mit Dataloadern und User
return createContext(user);
},
});
// Starte Metrics Server
startMetricsServer();
logger.info(`🚀 GraphQL Middlelayer läuft auf: ${url}`);
logger.info(`📊 GraphQL Playground verfügbar unter: ${url}`);
logger.info(`⚡ Query Complexity Limit: ${MAX_QUERY_COMPLEXITY}`);
logger.info(`💾 Response Caching: Aktiviert`);
logger.info(`🔄 Dataloader: Aktiviert`);
logger.info(
`📈 Monitoring: Aktiviert (Structured Logging, Prometheus, Tracing)`
);
}
startServer().catch((error) => {
logger.error("Fehler beim Starten des Servers", { error });
process.exit(1);
});

View File

@@ -0,0 +1,235 @@
import type { Page as CmsPage } from "../types/cms/Page";
import type { HTMLSkeleton } from "../types/cms/Html";
import type { MarkdownSkeleton } from "../types/cms/Markdown";
import type { ComponentIframeSkeleton } from "../types/cms/Iframe";
import type { ImageGallerySkeleton } from "../types/cms/ImageGallery";
import type { ComponentImageSkeleton } from "../types/cms/Image";
import type { QuoteSkeleton } from "../types/cms/Quote";
import type { ComponentYoutubeVideoSkeleton } from "../types/cms/YoutubeVideo";
import type { ComponentHeadlineSkeleton } from "../types/cms/Headline";
import type { Page, ContentItem, ContentRow } from "../types/page";
import { ContentType } from "../types/cms/ContentType.enum";
type ContentEntry =
| HTMLSkeleton
| MarkdownSkeleton
| ComponentIframeSkeleton
| ImageGallerySkeleton
| ComponentImageSkeleton
| QuoteSkeleton
| ComponentYoutubeVideoSkeleton
| ComponentHeadlineSkeleton;
/**
* Mapper für Page-Transformationen
* Konvertiert CMS-Typen zu unseren Domain-Typen
*/
export class PageMapper {
/**
* Strategy Pattern: Map mit Mapper-Funktionen für jeden Content-Type
*/
private static contentMappers = new Map<
ContentType,
(entry: ContentEntry) => ContentItem | null
>([
[
ContentType.html,
(entry) => {
const htmlEntry = entry as HTMLSkeleton;
return {
type: "html",
name: htmlEntry.fields.id || "",
html: htmlEntry.fields.html,
layout: htmlEntry.fields.layout,
};
},
],
[
ContentType.markdown,
(entry) => {
const markdownEntry = entry as MarkdownSkeleton;
return {
type: "markdown",
name: markdownEntry.fields.name,
content: markdownEntry.fields.content,
layout: markdownEntry.fields.layout,
alignment: markdownEntry.fields.alignment,
};
},
],
[
ContentType.iframe,
(entry) => {
const iframeEntry = entry as ComponentIframeSkeleton;
return {
type: "iframe",
name: iframeEntry.fields.name,
content: iframeEntry.fields.content,
iframe: iframeEntry.fields.iframe,
overlayImageUrl: iframeEntry.fields.overlayImage?.fields.file.url,
layout: iframeEntry.fields.layout,
};
},
],
[
ContentType.imgGallery,
(entry) => {
const galleryEntry = entry as ImageGallerySkeleton;
return {
type: "imageGallery",
name: galleryEntry.fields.name,
images: galleryEntry.fields.images.map((img) => ({
url: img.fields.file.url,
title: img.fields.title,
description: img.fields.description,
})),
description: galleryEntry.fields.description,
layout: galleryEntry.fields.layout,
};
},
],
[
ContentType.image,
(entry) => {
const imageEntry = entry as ComponentImageSkeleton;
return {
type: "image",
name: imageEntry.fields.name,
imageUrl: imageEntry.fields.image.fields.file.url,
caption: imageEntry.fields.caption,
maxWidth: imageEntry.fields.maxWidth,
aspectRatio: imageEntry.fields.aspectRatio,
layout: imageEntry.fields.layout,
};
},
],
[
ContentType.quote,
(entry) => {
const quoteEntry = entry as QuoteSkeleton;
return {
type: "quote",
quote: quoteEntry.fields.quote,
author: quoteEntry.fields.author,
variant: quoteEntry.fields.variant,
layout: quoteEntry.fields.layout,
};
},
],
[
ContentType.youtubeVideo,
(entry) => {
const videoEntry = entry as ComponentYoutubeVideoSkeleton;
return {
type: "youtubeVideo",
id: videoEntry.fields.id,
youtubeId: videoEntry.fields.youtubeId,
params: videoEntry.fields.params,
title: videoEntry.fields.title,
description: videoEntry.fields.description,
layout: videoEntry.fields.layout,
};
},
],
[
ContentType.headline,
(entry) => {
const headlineEntry = entry as ComponentHeadlineSkeleton;
return {
type: "headline",
internal: headlineEntry.fields.internal,
text: headlineEntry.fields.text,
tag: headlineEntry.fields.tag,
align: headlineEntry.fields.align,
layout: headlineEntry.fields.layout,
};
},
],
]);
/**
* Mappt ein Contentful Content-Item zu unserem ContentItem
* Verwendet Strategy Pattern für wartbaren Code
*/
private static mapContentItem(entry: ContentEntry): ContentItem | null {
if (!entry.contentTypeId || !entry.fields) {
return null;
}
const mapper = this.contentMappers.get(entry.contentTypeId);
if (!mapper) {
return null;
}
return mapper(entry);
}
/**
* Mappt eine CMS Content-Row zu unserer ContentRow
*/
private static mapContentRow(
content: ContentEntry[],
justifyContent: string,
alignItems: string
): ContentRow | undefined {
if (!content || content.length === 0) {
return undefined;
}
const mappedContent = content
.map((entry) => this.mapContentItem(entry))
.filter((item): item is ContentItem => item !== null);
if (mappedContent.length === 0) {
return undefined;
}
return {
justifyContent: justifyContent as ContentRow["justifyContent"],
alignItems: alignItems as ContentRow["alignItems"],
content: mappedContent,
};
}
static fromCms(cmsPage: CmsPage): Page {
return {
slug: cmsPage.slug,
name: cmsPage.name,
linkName: cmsPage.linkName,
headline: cmsPage.headline,
subheadline: cmsPage.subheadline,
seoTitle: cmsPage.seoTitle,
seoMetaRobots: cmsPage.seoMetaRobots,
seoDescription: cmsPage.seoDescription,
topFullwidthBanner: cmsPage.topFullwidthBanner
? {
name: cmsPage.topFullwidthBanner.fields.name,
variant: cmsPage.topFullwidthBanner.fields.variant,
headline: cmsPage.topFullwidthBanner.fields.headline,
subheadline: cmsPage.topFullwidthBanner.fields.subheadline,
text: cmsPage.topFullwidthBanner.fields.text,
imageUrl: cmsPage.topFullwidthBanner.fields.img.fields.file.url,
}
: undefined,
row1: this.mapContentRow(
cmsPage.row1Content,
cmsPage.row1JustifyContent,
cmsPage.row1AlignItems
),
row2: this.mapContentRow(
cmsPage.row2Content,
cmsPage.row2JustifyContent,
cmsPage.row2AlignItems
),
row3: this.mapContentRow(
cmsPage.row3Content,
cmsPage.row3JustifyContent,
cmsPage.row3AlignItems
),
};
}
static fromCmsArray(cmsPages: CmsPage[]): Page[] {
return cmsPages.map((page) => this.fromCms(page));
}
}

View File

@@ -0,0 +1,112 @@
# Monitoring & Observability
Der Middlelayer ist mit umfassendem Monitoring ausgestattet:
## 1. Structured Logging (Winston)
**Konfiguration:**
- Log-Level: `LOG_LEVEL` (default: `info`)
- Format: JSON in Production, farbig in Development
- Output: Console + Files (`logs/error.log`, `logs/combined.log`)
**Verwendung:**
```typescript
import { logger, logQuery, logError } from './monitoring/logger.js';
logger.info('Info message', { context: 'data' });
logQuery('GetProducts', { limit: 10 }, 45);
logError(error, { operation: 'getProducts' });
```
## 2. Prometheus Metrics
**Endpoints:**
- `GET http://localhost:9090/metrics` - Prometheus Metrics
- `GET http://localhost:9090/health` - Health Check
**Verfügbare Metriken:**
### Query Metrics
- `graphql_queries_total` - Anzahl der Queries (Labels: `operation`, `status`)
- `graphql_query_duration_seconds` - Query-Dauer (Histogram)
- `graphql_query_complexity` - Query-Komplexität (Gauge)
### Cache Metrics
- `cache_hits_total` - Cache Hits (Label: `cache_type`)
- `cache_misses_total` - Cache Misses (Label: `cache_type`)
### DataService Metrics
- `dataservice_calls_total` - DataService Aufrufe (Labels: `method`, `status`)
- `dataservice_duration_seconds` - DataService Dauer (Histogram)
### Error Metrics
- `errors_total` - Anzahl der Fehler (Labels: `type`, `operation`)
**Beispiel Prometheus Query:**
```promql
# Query Rate
rate(graphql_queries_total[5m])
# Error Rate
rate(errors_total[5m])
# Cache Hit Ratio
rate(cache_hits_total[5m]) / (rate(cache_hits_total[5m]) + rate(cache_misses_total[5m]))
```
## 3. Distributed Tracing
**Features:**
- Automatische Trace-ID-Generierung pro Request
- Span-Tracking für verschachtelte Operationen
- Dauer-Messung für Performance-Analyse
**Trace-IDs werden automatisch in Logs und Metrics eingebunden.**
## Environment Variables
```bash
# Logging
LOG_LEVEL=info # debug, info, warn, error
# Metrics
METRICS_PORT=9090 # Port für Metrics-Endpoint
# Query Complexity
MAX_QUERY_COMPLEXITY=1000 # Max. Query-Komplexität
```
## Integration mit Grafana
**Prometheus Scrape Config:**
```yaml
scrape_configs:
- job_name: 'graphql-middlelayer'
static_configs:
- targets: ['localhost:9090']
```
**Grafana Dashboard:**
- Importiere die Metriken in Grafana
- Erstelle Dashboards für:
- Query Performance
- Cache Hit Rates
- Error Rates
- Request Throughput
## Beispiel-Dashboard Queries
```promql
# Requests pro Sekunde
sum(rate(graphql_queries_total[1m])) by (operation)
# Durchschnittliche Query-Dauer
avg(graphql_query_duration_seconds) by (operation)
# Cache Hit Rate
sum(rate(cache_hits_total[5m])) / (sum(rate(cache_hits_total[5m])) + sum(rate(cache_misses_total[5m])))
# Error Rate
sum(rate(errors_total[5m])) by (type, operation)
```

View File

@@ -0,0 +1,86 @@
import winston from "winston";
/**
* Structured Logging mit Winston
* Erstellt JSON-Logs für bessere Analyse und Monitoring
*/
export const logger = winston.createLogger({
level: process.env.LOG_LEVEL || "info",
format: winston.format.combine(
winston.format.timestamp(),
winston.format.errors({ stack: true }),
winston.format.json()
),
defaultMeta: {
service: "graphql-middlelayer",
environment: process.env.NODE_ENV || "development",
},
transports: [
// Console Output (für Development)
new winston.transports.Console({
format: winston.format.combine(
winston.format.colorize(),
winston.format.printf(({ timestamp, level, message, ...meta }) => {
const metaStr = Object.keys(meta).length
? JSON.stringify(meta, null, 2)
: "";
return `${timestamp} [${level}]: ${message} ${metaStr}`;
})
),
}),
// File Output (für Production)
...(process.env.NODE_ENV === "production"
? [
new winston.transports.File({
filename: "logs/error.log",
level: "error",
}),
new winston.transports.File({
filename: "logs/combined.log",
}),
]
: []),
],
});
// Helper-Funktionen für strukturiertes Logging
export const logQuery = (
operationName: string,
variables: any,
duration: number
) => {
logger.info("GraphQL Query executed", {
operation: operationName,
variables,
duration: `${duration}ms`,
type: "query",
});
};
export const logError = (error: Error, context?: Record<string, any>) => {
logger.error("Error occurred", {
error: {
message: error.message,
stack: error.stack,
name: error.name,
},
...context,
type: "error",
});
};
export const logCacheHit = (key: string, type: string) => {
logger.debug("Cache hit", {
cacheKey: key,
cacheType: type,
type: "cache",
});
};
export const logCacheMiss = (key: string, type: string) => {
logger.debug("Cache miss", {
cacheKey: key,
cacheType: type,
type: "cache",
});
};

View File

@@ -0,0 +1,90 @@
import { Registry, Counter, Histogram, Gauge } from 'prom-client';
/**
* Prometheus Metrics Registry
* Sammelt Metriken für Monitoring und Alerting
*/
export const register = new Registry();
// Default Metrics (CPU, Memory, etc.)
register.setDefaultLabels({
app: 'graphql-middlelayer',
});
// Query Metrics
export const queryCounter = new Counter({
name: 'graphql_queries_total',
help: 'Total number of GraphQL queries',
labelNames: ['operation', 'status'],
registers: [register],
});
export const queryDuration = new Histogram({
name: 'graphql_query_duration_seconds',
help: 'Duration of GraphQL queries in seconds',
labelNames: ['operation'],
buckets: [0.01, 0.05, 0.1, 0.5, 1, 2, 5],
registers: [register],
});
// Cache Metrics
export const cacheHits = new Counter({
name: 'cache_hits_total',
help: 'Total number of cache hits',
labelNames: ['cache_type'],
registers: [register],
});
export const cacheMisses = new Counter({
name: 'cache_misses_total',
help: 'Total number of cache misses',
labelNames: ['cache_type'],
registers: [register],
});
// DataService Metrics
export const dataServiceCalls = new Counter({
name: 'dataservice_calls_total',
help: 'Total number of DataService calls',
labelNames: ['method', 'status'],
registers: [register],
});
export const dataServiceDuration = new Histogram({
name: 'dataservice_duration_seconds',
help: 'Duration of DataService calls in seconds',
labelNames: ['method'],
buckets: [0.001, 0.005, 0.01, 0.05, 0.1, 0.5, 1],
registers: [register],
});
// Error Metrics
export const errorCounter = new Counter({
name: 'errors_total',
help: 'Total number of errors',
labelNames: ['type', 'operation'],
registers: [register],
});
// Active Connections
export const activeConnections = new Gauge({
name: 'active_connections',
help: 'Number of active connections',
registers: [register],
});
// Query Complexity
export const queryComplexityGauge = new Gauge({
name: 'graphql_query_complexity',
help: 'Complexity of GraphQL queries',
labelNames: ['operation'],
registers: [register],
});
/**
* Exportiert Metriken im Prometheus-Format
*/
export async function getMetrics(): Promise<string> {
return register.metrics();
}

View File

@@ -0,0 +1,78 @@
/**
* Einfaches Distributed Tracing
* Erstellt Trace-IDs für Request-Tracking
*/
interface TraceContext {
traceId: string;
spanId: string;
parentSpanId?: string;
startTime: number;
}
const traces = new Map<string, TraceContext>();
/**
* Erstellt einen neuen Trace
*/
export function createTrace(traceId?: string): TraceContext {
const id = traceId || generateTraceId();
const trace: TraceContext = {
traceId: id,
spanId: generateSpanId(),
startTime: Date.now(),
};
traces.set(id, trace);
return trace;
}
/**
* Erstellt einen Child-Span
*/
export function createSpan(traceId: string, parentSpanId?: string): string {
const trace = traces.get(traceId);
if (!trace) {
throw new Error(`Trace ${traceId} not found`);
}
const spanId = generateSpanId();
trace.parentSpanId = parentSpanId || trace.spanId;
return spanId;
}
/**
* Beendet einen Trace und gibt die Dauer zurück
*/
export function endTrace(traceId: string): number {
const trace = traces.get(traceId);
if (!trace) {
return 0;
}
const duration = Date.now() - trace.startTime;
traces.delete(traceId);
return duration;
}
/**
* Generiert eine Trace-ID
*/
function generateTraceId(): string {
return `trace-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
}
/**
* Generiert eine Span-ID
*/
function generateSpanId(): string {
return `span-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
}
/**
* Holt Trace-Informationen
*/
export function getTrace(traceId: string): TraceContext | undefined {
return traces.get(traceId);
}

View File

@@ -0,0 +1,101 @@
import type { ApolloServerPlugin } from '@apollo/server';
import { logger, logQuery, logError } from '../monitoring/logger.js';
import {
queryCounter,
queryDuration,
errorCounter,
queryComplexityGauge,
} from '../monitoring/metrics.js';
import { createTrace, endTrace } from '../monitoring/tracing.js';
/**
* Monitoring Plugin für Apollo Server
* Sammelt Logs, Metrics und Traces für jeden Request
*/
export const monitoringPlugin = (): ApolloServerPlugin => {
return {
async requestDidStart() {
return {
async didResolveOperation({ request, operationName }) {
// Erstelle Trace für Request
const traceId = createTrace().traceId;
(request as any).traceId = traceId;
logger.info('GraphQL operation started', {
operationName: operationName || 'unknown',
query: request.query,
variables: request.variables,
traceId,
});
},
async willSendResponse({ request, response }) {
const traceId = (request as any).traceId;
const operationName = request.operationName || 'unknown';
const duration = traceId ? endTrace(traceId) : 0;
// Log Query
logQuery(operationName, request.variables, duration);
// Metrics
const status = response.errors && response.errors.length > 0 ? 'error' : 'success';
queryCounter.inc({ operation: operationName, status });
queryDuration.observe({ operation: operationName }, duration / 1000);
// Log Errors
if (response.errors && response.errors.length > 0) {
response.errors.forEach((error) => {
logError(error as Error, {
operationName,
traceId,
});
errorCounter.inc({
type: error.extensions?.code as string || 'UNKNOWN',
operation: operationName,
});
});
}
},
async didEncounterErrors({ request, errors }) {
const traceId = (request as any).traceId;
const operationName = request.operationName || 'unknown';
errors.forEach((error) => {
logError(error as Error, {
operationName,
traceId,
});
errorCounter.inc({
type: error.extensions?.code as string || 'UNKNOWN',
operation: operationName,
});
});
},
};
},
};
};
/**
* Plugin für Query Complexity Tracking
*/
export const queryComplexityMonitoringPlugin = (): ApolloServerPlugin => {
return {
async requestDidStart() {
return {
async didResolveOperation({ request, operationName }) {
// Wird vom queryComplexityPlugin gesetzt
const complexity = (request as any).complexity;
if (complexity) {
queryComplexityGauge.set(
{ operation: operationName || 'unknown' },
complexity
);
}
},
};
},
};
};

View File

@@ -0,0 +1,67 @@
import type { ApolloServerPlugin } from "@apollo/server";
import { getComplexity, simpleEstimator } from "graphql-query-complexity";
import { GraphQLError } from "graphql";
interface QueryComplexityPluginOptions {
maxComplexity?: number;
defaultComplexity?: number;
}
/**
* Apollo Server Plugin für Query Complexity Limits
* Verhindert zu komplexe Queries, die das System überlasten könnten
*/
export const queryComplexityPlugin = (
options: QueryComplexityPluginOptions = {}
): ApolloServerPlugin => {
const maxComplexity = options.maxComplexity ?? 1000;
const defaultComplexity = options.defaultComplexity ?? 1;
return {
async requestDidStart() {
return {
async didResolveOperation({ request, document, schema }) {
if (!schema) return;
try {
const complexity = getComplexity({
schema,
operationName: request.operationName || undefined,
query: document,
variables: request.variables || {},
estimators: [
// Basis-Komplexität für jeden Field
simpleEstimator({ defaultComplexity }),
],
});
// Speichere Complexity im Request für Monitoring
(request as any).complexity = complexity;
if (complexity > maxComplexity) {
throw new GraphQLError(
`Query zu komplex (${complexity}). Maximum: ${maxComplexity}`,
{
extensions: {
code: "QUERY_TOO_COMPLEX",
complexity,
maxComplexity,
},
}
);
}
} catch (error: any) {
// Wenn es ein Schema-Realm-Problem gibt, logge es aber blockiere nicht
if (error.message?.includes("another module or realm")) {
console.warn(
"[Query Complexity] Schema-Realm-Konflikt, Complexity-Check übersprungen"
);
return;
}
throw error;
}
},
};
},
};
};

View File

@@ -0,0 +1,32 @@
import ApolloServerPluginResponseCache from "@apollo/server-plugin-response-cache";
import type { GraphQLRequestContext } from "@apollo/server";
/**
* Response Caching Plugin für Apollo Server
* Cached GraphQL Responses basierend auf Query und Variablen
*/
export const createResponseCachePlugin = () => {
return ApolloServerPluginResponseCache({
// Session-ID für User-spezifisches Caching
sessionId: async (
requestContext: GraphQLRequestContext<any>
): Promise<string | null> => {
// Optional: User-ID aus Headers oder Context
const userId = requestContext.request.http?.headers.get("x-user-id");
return userId || null;
},
// Cache nur bei erfolgreichen Queries
shouldWriteToCache: async (
requestContext: GraphQLRequestContext<any>
): Promise<boolean> => {
const query = requestContext.request.query;
if (!query) return false;
// Cache nur bestimmte Queries
const cacheableQueries = ["products", "pageSeo", "navigation", "page"];
return cacheableQueries.some((q) => query.includes(q));
},
});
};

207
middlelayer/resolvers.ts Normal file
View File

@@ -0,0 +1,207 @@
import { dataService } from "./dataService.js";
import { AdapterError, NotFoundError } from "./utils/errors.js";
import type { GraphQLContext } from "./utils/dataloaders.js";
import { userService } from "./auth/userService.js";
import { logger } from "./monitoring/logger.js";
import type { ContentItem } from "./types/page.js";
import type { User } from "./types/user.js";
/**
* Fehlerbehandlung für GraphQL Resolver
*/
function handleError(error: unknown): never {
if (error instanceof NotFoundError) {
throw error;
}
if (error instanceof AdapterError) {
logger.error("Adapter-Fehler", {
message: error.message,
originalError: error.originalError,
});
throw new Error(`Datenfehler: ${error.message}`);
}
logger.error("Unerwarteter Fehler", { error });
throw new Error("Ein unerwarteter Fehler ist aufgetreten");
}
/**
* Wrapper-Funktion für GraphQL Resolver mit automatischem Error Handling
*/
async function withErrorHandling<T>(resolver: () => Promise<T>): Promise<T> {
try {
return await resolver();
} catch (error) {
handleError(error);
}
}
/**
* Formatiert einen User für GraphQL (konvertiert Date zu ISO String)
*/
function formatUserForGraphQL(user: User) {
return {
...user,
createdAt: user.createdAt.toISOString(),
};
}
export const resolvers = {
Query: {
products: async (
_: unknown,
args: { limit?: number },
context: GraphQLContext
) => {
return withErrorHandling(() => dataService.getProducts(args.limit));
},
product: async (
_: unknown,
args: { id: string },
context: GraphQLContext
) => {
return withErrorHandling(async () => {
// Verwende Dataloader für Batch-Loading
const product = await context.loaders.product.load(args.id);
if (!product) {
throw new NotFoundError("Produkt", args.id);
}
return product;
});
},
pageSeo: async (
_: unknown,
args: { locale?: string },
context: GraphQLContext
) => {
return withErrorHandling(() => dataService.getPageSeo(args.locale));
},
page: async (
_: unknown,
args: { slug: string; locale?: string },
context: GraphQLContext
) => {
return withErrorHandling(async () => {
// Verwende Dataloader für Batch-Loading (mit Locale im Key)
// Format: "slug:locale" oder "slug" (ohne "page:" Präfix)
const cacheKey = args.locale
? `${args.slug}:${args.locale}`
: args.slug;
const page = await context.loaders.page.load(cacheKey);
if (!page) {
throw new NotFoundError("Seite", args.slug);
}
return page;
});
},
pages: async (
_: unknown,
args: { locale?: string },
context: GraphQLContext
) => {
return withErrorHandling(() => dataService.getPages(args.locale));
},
homepage: async (
_: unknown,
args: { locale?: string },
context: GraphQLContext
) => {
return withErrorHandling(async () => {
// Homepage ist immer die Seite mit dem Slug "/"
const homepage = await dataService.getPage("/", args.locale);
if (!homepage) {
throw new NotFoundError("Homepage", "/");
}
return homepage;
});
},
navigation: async (
_: unknown,
args: { locale?: string },
context: GraphQLContext
) => {
return withErrorHandling(() => dataService.getNavigation(args.locale));
},
me: async (_: unknown, __: unknown, context: GraphQLContext) => {
return withErrorHandling(async () => {
// Gibt aktuellen User zurück (null wenn nicht authentifiziert)
if (!context.user) {
return null;
}
return formatUserForGraphQL(context.user);
});
},
translations: async (
_: unknown,
args: { locale?: string; namespace?: string },
context: GraphQLContext
) => {
return withErrorHandling(() =>
dataService.getTranslations(args.locale, args.namespace)
);
},
},
Mutation: {
register: async (
_: unknown,
args: { email: string; password: string; name: string },
context: GraphQLContext
) => {
return withErrorHandling(async () => {
const result = await userService.register({
email: args.email,
password: args.password,
name: args.name,
});
logger.info("User registered via GraphQL", {
userId: result.user.id,
email: result.user.email,
});
return {
user: formatUserForGraphQL(result.user),
token: result.token,
};
});
},
login: async (
_: unknown,
args: { email: string; password: string },
context: GraphQLContext
) => {
return withErrorHandling(async () => {
const result = await userService.login({
email: args.email,
password: args.password,
});
logger.info("User logged in via GraphQL", {
userId: result.user.id,
email: result.user.email,
});
return {
user: formatUserForGraphQL(result.user),
token: result.token,
};
});
},
},
ContentItem: {
__resolveType(obj: ContentItem): string | null {
// Map für Type-Resolution (Strategy Pattern)
const typeMap: Record<ContentItem["type"], string> = {
html: "HTMLContent",
markdown: "MarkdownContent",
iframe: "IframeContent",
imageGallery: "ImageGalleryContent",
image: "ImageContent",
quote: "QuoteContent",
youtubeVideo: "YoutubeVideoContent",
headline: "HeadlineContent",
};
return typeMap[obj.type] || null;
},
},
};

217
middlelayer/schema.ts Normal file
View File

@@ -0,0 +1,217 @@
export const typeDefs = `#graphql
type ProductPromotion {
category: String!
text: String!
}
type Product {
id: ID!
name: String!
description: String
price: Float!
originalPrice: Float
currency: String!
imageUrl: String
category: String
inStock: Boolean!
promotion: ProductPromotion
}
type PageSeo {
title: String!
description: String!
metaRobotsIndex: String
metaRobotsFollow: String
}
type contentLayout {
mobile: String!
tablet: String
desktop: String
spaceBottom: Float
}
type HTMLContent {
type: String!
name: String!
html: String!
layout: contentLayout!
}
type MarkdownContent {
type: String!
name: String!
content: String!
layout: contentLayout!
alignment: String!
}
type IframeContent {
type: String!
name: String!
content: String!
iframe: String!
overlayImageUrl: String
layout: contentLayout!
}
type ImageGalleryContent {
type: String!
name: String!
images: [ImageGalleryImage!]!
description: String
layout: contentLayout!
}
type ImageGalleryImage {
url: String!
title: String
description: String
}
type ImageContent {
type: String!
name: String!
imageUrl: String!
caption: String!
maxWidth: Float
aspectRatio: Float
layout: contentLayout!
}
type QuoteContent {
type: String!
quote: String!
author: String!
variant: String!
layout: contentLayout!
}
type YoutubeVideoContent {
type: String!
id: String!
youtubeId: String!
params: String
title: String
description: String
layout: contentLayout!
}
type HeadlineContent {
type: String!
internal: String!
text: String!
tag: String!
align: String
layout: contentLayout!
}
union ContentItem = HTMLContent | MarkdownContent | IframeContent | ImageGalleryContent | ImageContent | QuoteContent | YoutubeVideoContent | HeadlineContent
type ContentRow {
justifyContent: String!
alignItems: String!
content: [ContentItem!]!
}
type Page {
slug: String!
name: String!
linkName: String!
headline: String!
subheadline: String!
seoTitle: String!
seoMetaRobots: String!
seoDescription: String!
topFullwidthBanner: FullwidthBanner
row1: ContentRow
row2: ContentRow
row3: ContentRow
}
type FullwidthBanner {
name: String!
variant: String!
headline: String!
subheadline: String!
text: String!
imageUrl: String
}
type NavigationLink {
slug: String
name: String!
linkName: String!
url: String
icon: String
newTab: Boolean
}
type Navigation {
name: String!
internal: String!
links: [NavigationLink!]!
}
enum UserRole {
ADMIN
CUSTOMER
GUEST
}
type User {
id: ID!
email: String!
name: String!
role: UserRole!
createdAt: String!
}
type AuthResponse {
user: User!
token: String!
}
type Translation {
key: String!
value: String!
namespace: String
}
type Translations {
locale: String!
translations: [Translation!]!
}
type Query {
products(limit: Int): [Product!]!
product(id: ID!): Product
pageSeo(locale: String): PageSeo!
page(slug: String!, locale: String): Page
pages(locale: String): [Page!]!
homepage(locale: String): Page
navigation(locale: String): Navigation!
me: User
translations(locale: String!, namespace: String): Translations!
}
type Mutation {
register(email: String!, password: String!, name: String!): AuthResponse!
login(email: String!, password: String!): AuthResponse!
}
type __Type {
kind: __TypeKind!
}
enum __TypeKind {
SCALAR
OBJECT
INTERFACE
UNION
ENUM
INPUT_OBJECT
LIST
NON_NULL
}
`;

View File

@@ -0,0 +1,98 @@
# Type Structure
## Übersicht
Die Typen sind in zwei Kategorien aufgeteilt:
### 1. CMS-Typen (`types/cms/`)
**Zweck:** Struktur, wie Daten vom CMS (Contentful) kommen
- `*Skeleton` - Wrapper mit `contentTypeId` und `fields`
- Verwendet `ComponentLayout` (ist jetzt ein Alias für `contentLayout`)
- Beispiel: `ComponentHeadlineSkeleton`, `HTMLSkeleton`, `MarkdownSkeleton`
**Verwendung:** Nur im Mapper (`mappers/pageMapper.ts`) und Mock-Daten (`adapters/Mock/_cms/`)
**Dateien:**
- `Headline.ts`, `Html.ts`, `Markdown.ts`, `Iframe.ts`, `ImageGallery.ts`, `Image.ts`, `Quote.ts`, `YoutubeVideo.ts`
- `Page.ts`, `Navigation.ts`, `SEO.ts`, `FullwidthBanner.ts`
- `Layout.ts` - `ComponentLayout` (Alias für `contentLayout`)
- `ContentType.enum.ts` - Enum für alle Content-Typen
### 2. Domain-Typen (`types/c_*.ts`, `types/page.ts`, etc.)
**Zweck:** Struktur, wie Daten in der App verwendet werden
- `c_*` - Content-Item-Typen mit `type`-Feld für Discriminated Union
- Verwendet `contentLayout` direkt
- Beispiel: `c_headline`, `c_html`, `c_markdown`, `c_iframe`, `c_imageGallery`, `c_image`, `c_quote`, `c_youtubeVideo`
**Verwendung:** Überall in der App (GraphQL Schema, Astro Components, etc.)
**Dateien:**
- `c_*.ts` - Alle Content-Item-Typen
- `page.ts` - Page-Typ mit `ContentItem` Union und `ContentRow`
- `contentLayout.ts` - Einheitlicher Layout-Typ
- `pageSeo.ts`, `navigation.ts`, `product.ts`, `user.ts` - Weitere Domain-Typen
## Mapping
Der `PageMapper` konvertiert von CMS-Typen zu Domain-Typen:
```typescript
// Beispiel: Headline
CMS: ComponentHeadlineSkeleton {
contentTypeId: ContentType.headline,
fields: {
internal: string,
text: string,
tag: "h1" | "h2" | ...,
align?: "left" | "center" | "right",
layout: ComponentLayout
}
}
(PageMapper.mapContentItem)
Domain: c_headline {
type: "headline",
internal: string,
text: string,
tag: "h1" | "h2" | ...,
align?: "left" | "center" | "right",
layout: contentLayout
}
```
## Layout-Typen
- `ComponentLayout` (in `types/cms/Layout.ts`) = Alias für `contentLayout`
- `contentLayout` (in `types/contentLayout.ts`) = Einheitlicher Layout-Typ für alle Content-Items
**Struktur:**
```typescript
{
mobile: string; // z.B. "12", "6", "4"
tablet?: string; // Optional, z.B. "8", "6"
desktop?: string; // Optional, z.B. "6", "4"
spaceBottom?: number; // Optional, z.B. 0, 0.5, 1, 1.5, 2
}
```
**Warum:** Redundanz vermeiden - beide haben die gleiche Struktur. `ComponentLayout` ist nur ein Alias, um die Semantik klar zu machen (CMS vs Domain).
## Content-Items Union
Alle Content-Items werden in `types/page.ts` zu einer Union zusammengefasst:
```typescript
export type ContentItem =
| c_html
| c_markdown
| c_iframe
| c_imageGallery
| c_image
| c_quote
| c_youtubeVideo
| c_headline;
```
Dies ermöglicht Type-Safe Discriminated Unions über das `type`-Feld.

View File

@@ -0,0 +1,10 @@
import type { contentLayout } from "./contentLayout";
export type c_headline = {
type: "headline";
internal: string;
text: string;
tag: "h1" | "h2" | "h3" | "h4" | "h5" | "h6";
align?: "left" | "center" | "right";
layout: contentLayout;
};

View File

@@ -0,0 +1,8 @@
import type { contentLayout } from "./contentLayout";
export type c_html = {
type: "html";
name: string;
html: string;
layout: contentLayout;
};

View File

@@ -0,0 +1,10 @@
import type { contentLayout } from "./contentLayout";
export type c_iframe = {
type: "iframe";
name: string;
content: string;
iframe: string;
overlayImageUrl?: string;
layout: contentLayout;
};

View File

@@ -0,0 +1,11 @@
import type { contentLayout } from "./contentLayout";
export type c_image = {
type: "image";
name: string;
imageUrl: string;
caption: string;
maxWidth?: number;
aspectRatio?: number;
layout: contentLayout;
};

View File

@@ -0,0 +1,13 @@
import type { contentLayout } from "./contentLayout";
export type c_imageGallery = {
type: "imageGallery";
name: string;
images: Array<{
url: string;
title?: string;
description?: string;
}>;
description?: string;
layout: contentLayout;
};

View File

@@ -0,0 +1,9 @@
import type { contentLayout } from "./contentLayout";
export type c_markdown = {
type: "markdown";
name: string;
content: string;
layout: contentLayout;
alignment: "left" | "center" | "right";
};

View File

@@ -0,0 +1,9 @@
import type { contentLayout } from "./contentLayout";
export type c_quote = {
type: "quote";
quote: string;
author: string;
variant: "left" | "right";
layout: contentLayout;
};

View File

@@ -0,0 +1,11 @@
import type { contentLayout } from "./contentLayout";
export type c_youtubeVideo = {
type: "youtubeVideo";
id: string;
youtubeId: string;
params?: string;
title?: string;
description?: string;
layout: contentLayout;
};

View File

@@ -0,0 +1,16 @@
export interface CloudinaryImage {
bytes: number;
created_at: string;
format: string;
height: number;
original_secure_url: string;
original_url: string;
public_id: string;
resource_type: string;
secure_url: string;
type: string;
url: string;
version: number;
width: number;
}

View File

@@ -0,0 +1,42 @@
import type { HTMLSkeleton } from "./Html";
import type { MarkdownSkeleton } from "./Markdown";
import type { ComponentIframeSkeleton } from "./Iframe";
import type { ImageGallerySkeleton } from "./ImageGallery";
import type { ComponentImageSkeleton } from "./Image";
import type { QuoteSkeleton } from "./Quote";
import type { ComponentYoutubeVideoSkeleton } from "./YoutubeVideo";
import type { ComponentHeadlineSkeleton } from "./Headline";
export type rowJutify =
| "start"
| "end"
| "center"
| "between"
| "around"
| "evenly";
export type rowAlignItems = "start" | "end" | "center" | "baseline" | "stretch";
export type ContentEntry =
| HTMLSkeleton
| MarkdownSkeleton
| ComponentIframeSkeleton
| ImageGallerySkeleton
| ComponentImageSkeleton
| QuoteSkeleton
| ComponentYoutubeVideoSkeleton
| ComponentHeadlineSkeleton;
export interface Content {
row1JustifyContent: rowJutify;
row1AlignItems: rowAlignItems;
row1Content: ContentEntry[];
row2JustifyContent: rowJutify;
row2AlignItems: rowAlignItems;
row2Content: ContentEntry[];
row3JustifyContent: rowJutify;
row3AlignItems: rowAlignItems;
row3Content: ContentEntry[];
}

View File

@@ -0,0 +1,32 @@
export enum ContentType {
"componentLinkList" = "componentLinkList",
"badges" = "badges",
"componentPostOverview" = "componentPostOverview",
"footer" = "footer",
"fullwidthBanner" = "fullwidthBanner",
"headline" = "headline",
"html" = "html",
"image" = "image",
"img" = "img",
"iframe" = "iframe",
"imgGallery" = "imageGallery",
"internalReference" = "internalComponent",
"link" = "link",
"list" = "list",
"markdown" = "markdown",
"navigation" = "navigation",
"page" = "page",
"pageConfig" = "pageConfig",
"picture" = "picture",
"post" = "post",
"postComponent" = "postComponent",
"quote" = "quoteComponent",
"richtext" = "richtext",
"row" = "row",
"rowLayout" = "rowLayout",
"tag" = "tag",
"youtubeVideo" = "youtubeVideo",
"campaign" = "campaign",
"campaigns" = "campaigns",
}

View File

@@ -0,0 +1,24 @@
import type { ComponentImgSkeleton } from "./Img";
import type { CloudinaryImage } from "./CloudinaryImage";
import type { ContentType } from "./ContentType.enum";
export enum FullwidthBannerVariant {
"dark" = "dark",
"light" = "light",
}
export interface FullwidthBanner {
name: string;
variant: FullwidthBannerVariant;
headline: string;
subheadline: string;
text: string;
image: CloudinaryImage[];
img: ComponentImgSkeleton;
}
export type FullwidthBannerSkeleton = {
contentTypeId: ContentType.fullwidthBanner;
fields: FullwidthBanner;
};

View File

@@ -0,0 +1,21 @@
import type { ContentType } from "./ContentType.enum";
import type { ComponentLayout } from "./Layout";
export type Component_Headline_Align = "left" | "center" | "right";
export type Component_Headline_Tag = "h1" | "h2" | "h3" | "h4" | "h5" | "h6";
export type alignTextClasses = "text-left" | "text-center" | "text-right";
export interface ComponentHeadline {
internal: string;
text: string;
tag: Component_Headline_Tag;
layout: ComponentLayout;
align?: Component_Headline_Align;
}
export interface ComponentHeadlineSkeleton {
contentTypeId: ContentType.headline;
fields: ComponentHeadline;
}

View File

@@ -0,0 +1,14 @@
import type { ContentType } from "./ContentType.enum";
import type { ComponentLayout } from "./Layout";
export interface HTML {
id: string;
html: string;
layout: ComponentLayout;
}
export type HTMLSkeleton = {
contentTypeId: ContentType.html;
fields: HTML;
};

View File

@@ -0,0 +1,17 @@
import type { ContentType } from "./ContentType.enum";
import type { ComponentImgSkeleton } from "./Img";
import type { ComponentLayout } from "./Layout";
export interface ComponentIframe {
name: string;
content: string;
iframe: string;
overlayImage?: ComponentImgSkeleton;
layout: ComponentLayout;
}
export interface ComponentIframeSkeleton {
contentTypeId: ContentType.iframe;
fields: ComponentIframe;
}

View File

@@ -0,0 +1,18 @@
import type { ContentType } from "./ContentType.enum";
import type { ComponentImgSkeleton } from "./Img";
import type { ComponentLayout } from "./Layout";
export interface ComponentImage {
name: string;
image: ComponentImgSkeleton;
caption: string;
layout: ComponentLayout;
maxWidth?: number;
aspectRatio?: number;
}
export interface ComponentImageSkeleton {
contentTypeId: ContentType.image;
fields: ComponentImage;
}

View File

@@ -0,0 +1,16 @@
import type { ContentType } from "./ContentType.enum";
import type { ComponentImgSkeleton } from "./Img";
import type { ComponentLayout } from "./Layout";
export interface ImageGallery {
name: string;
images: ComponentImgSkeleton[];
layout: ComponentLayout;
description?: string;
}
export interface ImageGallerySkeleton {
contentTypeId: ContentType.imgGallery;
fields: ImageGallery;
}

View File

@@ -0,0 +1,26 @@
import type { ContentType } from "./ContentType.enum";
export interface ComponentImgDetails {
size: number;
image: {
width: number;
height: number;
};
}
export interface ComponentImg {
title: string;
description: string;
file: {
url: string;
details: ComponentImgDetails;
fileName: string;
contentType: string;
};
}
export interface ComponentImgSkeleton {
contentTypeId: ContentType.img;
fields: ComponentImg;
}

View File

@@ -0,0 +1,13 @@
import type { ContentType } from "./ContentType.enum";
import type { contentLayout } from "../contentLayout";
/**
* CMS-spezifisches Layout (wird vom Mapper zu contentLayout konvertiert)
* Verwendet contentLayout direkt, um Redundanz zu vermeiden
*/
export type ComponentLayout = contentLayout;
export interface ComponentLayoutSkeleton {
contentTypeId: ContentType.rowLayout;
fields: ComponentLayout;
}

View File

@@ -0,0 +1,9 @@
export interface Link {
name: string;
internal: string;
linkName: string;
url: string;
icon?: string;
newTab?: boolean;
}

View File

@@ -0,0 +1,16 @@
import type { ContentType } from "./ContentType.enum";
import type { ComponentLayout } from "./Layout";
import type { TextAlignment } from "./TextAlignment";
export interface Markdown {
name: string;
content: string;
layout: ComponentLayout;
alignment: TextAlignment;
}
export type MarkdownSkeleton = {
contentTypeId: ContentType.markdown;
fields: Markdown;
};

View File

@@ -0,0 +1,15 @@
import type { ContentType } from "./ContentType.enum";
import type { Link } from "./Link";
import type { Page } from "./Page";
export interface Navigation {
name: string;
internal: string;
links: Array<{ fields: Link | Page }>;
}
export interface NavigationSkeleton {
contentTypeId: ContentType.navigation;
fields: Navigation;
}

View File

@@ -0,0 +1,20 @@
import type { ContentType } from "./ContentType.enum";
import type { FullwidthBannerSkeleton } from "./FullwidthBanner";
import type { Content } from "./Content";
import type { SEO } from "./SEO";
export interface Page extends Content, SEO {
slug: string;
name: string;
linkName: string;
icon?: string;
headline: string;
subheadline: string;
topFullwidthBanner: FullwidthBannerSkeleton;
}
export type PageSkeleton = {
contentTypeId: ContentType.page;
fields: Page;
};

View File

@@ -0,0 +1,19 @@
import type { ContentType } from "./ContentType.enum";
import type { ComponentImgSkeleton } from "./Img";
export interface PageConfig {
logo: ComponentImgSkeleton;
footerText1: string;
seoTitle: string;
seoDescription: string;
blogTagPageHeadline: string;
blogPostsPageHeadline: string;
blogPostsPageSubHeadline: string;
website: string;
}
export interface PageConfigSkeleton {
contentTypeId: ContentType.pageConfig;
fields: PageConfig;
}

View File

@@ -0,0 +1,15 @@
import type { ContentType } from "./ContentType.enum";
import type { ComponentLayout } from "./Layout";
export interface Quote {
quote: string;
author: string;
variant: "left" | "right";
layout: ComponentLayout;
}
export type QuoteSkeleton = {
contentTypeId: ContentType.quote;
fields: Quote;
};

View File

@@ -0,0 +1,12 @@
export type metaRobots =
| "index, follow"
| "noindex, follow"
| "index, nofollow"
| "noindex, nofollow";
export interface SEO {
seoTitle: string;
seoMetaRobots: metaRobots;
seoDescription: string;
}

View File

@@ -0,0 +1,7 @@
export type TextAlignment = "left" | "center" | "right";
export enum TextAlignmentClasses {
"left" = "text-left",
"center" = "text-center",
"right" = "text-right",
}

View File

@@ -0,0 +1,17 @@
import type { ContentType } from "./ContentType.enum";
import type { ComponentLayout } from "./Layout";
export interface YoutubeVideo {
id: string;
youtubeId: string;
params?: string;
title?: string;
description?: string;
layout: ComponentLayout;
}
export interface ComponentYoutubeVideoSkeleton {
contentTypeId: ContentType.youtubeVideo;
fields: YoutubeVideo;
}

Some files were not shown because too many files have changed in this diff Show More