release: Alpha V1.0.0 - INSIGHT MVP

Erster vollstaendiger Alpha-Release mit folgenden Features:

## Authentifizierung & Sicherheit
- Lokaler Login mit E-Mail/Passwort (Bcrypt Cost 12, RS256 JWT)
- TOTP 2FA mit QR-Code und Backup-Codes
- Microsoft Entra ID (Azure AD) SSO Integration
- Silent Refresh (Access Token in Memory, Refresh Token HttpOnly Cookie)
- Token-Revocation via Redis

## Benutzerverwaltung (Admin)
- Benutzer erstellen, bearbeiten, aktivieren/deaktivieren, loeschen
- Rollen: PLATFORM_ADMIN, ADMIN, USER
- Profilseite mit Avatar-Upload und persoenlichen Daten

## Experten-Profil
- Umfangreiche Profilfelder (Kompetenzen, Zertifikate, Ausbildung, Projekte)
- PDF-Export mit Corporate Design
- DOCX-Export mit formatiertem Layout

## Admin-Bereich
- SSO-Konfiguration ueber UI (dynamisch, ohne Server-Zugang)
- Externe Links mit automatischer Favicon-Erkennung + optionalem Icon-Upload
- Branding: Logo-Upload und Sidebar-Farbkonfiguration
- Benutzerverwaltung

## UI/UX
- Responsive Sidebar mit Ein-/Ausklapp-Funktion
- Dark Mode (Hell/Dunkel/System)
- Aufklappbare Menue-Sektionen
- Externe Links oeffnen im App-Modus (Popup-Fenster)
- Theme-Toggle unter dem Profil

## Infrastruktur
- NestJS Backend mit TypeScript strict
- React + Vite Frontend
- PostgreSQL mit Prisma ORM
- Redis fuer Cache, Sessions, Konfiguration
- Docker Compose Deployment
- Traefik API Gateway

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Thomas Reitz 2026-03-10 12:26:29 +01:00
commit 0aa64ed1af
154 changed files with 32154 additions and 1 deletions

81
.env.example Normal file
View file

@ -0,0 +1,81 @@
# ============================================================
# INSIGHT MVP - Umgebungsvariablen
# ============================================================
# Kopiere diese Datei nach .env und befuelle die Werte.
# .env wird NIEMALS in Git committed!
# ============================================================
# --- Allgemein ---
NODE_ENV=development
APP_PORT=3000
APP_URL=http://172.20.10.59
FRONTEND_URL=http://172.20.10.59
LOG_LEVEL=info
# --- PostgreSQL ---
DB_HOST=pgbouncer
DB_PORT=5432
DB_USER=insight
DB_PASSWORD= # Sicheres Passwort setzen!
DB_NAME=platform_core
DATABASE_URL=postgresql://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}
# Direktverbindung (fuer Prisma Migrate, umgeht PgBouncer)
DB_DIRECT_HOST=postgres
DB_DIRECT_PORT=5432
DATABASE_URL_DIRECT=postgresql://${DB_USER}:${DB_PASSWORD}@${DB_DIRECT_HOST}:${DB_DIRECT_PORT}/${DB_NAME}
# --- Redis ---
REDIS_HOST=redis
REDIS_PORT=6379
REDIS_PASSWORD= # Optional, aber empfohlen
# --- JWT (RS256) ---
JWT_PRIVATE_KEY_PATH=/app/keys/jwt-private.pem
JWT_PUBLIC_KEY_PATH=/app/keys/jwt-public.pem
JWT_ACCESS_TOKEN_EXPIRY=15m
JWT_REFRESH_TOKEN_EXPIRY=7d
JWT_ISSUER=insight-platform
# --- Bcrypt ---
BCRYPT_COST=12
# --- CORS ---
CORS_ORIGINS=http://172.20.10.59
# --- Rate Limiting ---
THROTTLE_TTL=60000
THROTTLE_LIMIT=200
# --- Traefik ---
TRAEFIK_DASHBOARD_USER=admin
TRAEFIK_DASHBOARD_PASSWORD= # htpasswd Hash
# --- step-ca (mTLS) ---
STEP_CA_URL=https://step-ca:9000
STEP_CA_FINGERPRINT= # step-ca Root CA Fingerprint
# --- SMTP (fuer Einladungs-E-Mails) ---
SMTP_HOST=
SMTP_PORT=587
SMTP_USER=
SMTP_PASSWORD=
SMTP_FROM=noreply@xinion.de
# --- Observability ---
GRAFANA_ADMIN_USER=admin
GRAFANA_ADMIN_PASSWORD= # Sicheres Passwort setzen!
# --- Microsoft Entra ID (Azure AD) SSO ---
# Azure App Registration: https://portal.azure.com → App registrations
AZURE_TENANT_ID= # Directory (Tenant) ID
AZURE_CLIENT_ID= # Application (Client) ID
AZURE_CLIENT_SECRET= # Client Secret Value
AZURE_REDIRECT_URI=http://172.20.10.59/api/v1/auth/sso/microsoft/callback
# --- KI-Hilfe-Chat (optional) ---
# ANTHROPIC_API_KEY= # Claude API Key
# AI_CHAT_ENABLED=false
# --- DeepL (optional, fuer Hilfesystem-Uebersetzungen) ---
# DEEPL_API_KEY=

82
.forgejo/workflows/ci.yml Normal file
View file

@ -0,0 +1,82 @@
# ============================================================
# INSIGHT MVP - CI Pipeline (Lint, Type-Check, Test, Build)
# ============================================================
# Wird bei jedem Push und Pull Request ausgefuehrt.
# ============================================================
name: CI
on:
push:
branches: [main, develop, 'feature/**', 'fix/**', 'hotfix/**']
pull_request:
branches: [main, develop]
jobs:
# --------------------------------------------------------
# Core-Service: Lint, Type-Check, Test, Build
# --------------------------------------------------------
core-service:
name: Core-Service CI
runs-on: ubuntu-latest
defaults:
run:
working-directory: packages/core-service
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install dependencies
run: npm ci
- name: Generate Prisma Client
run: npx prisma generate --schema=prisma/core.schema.prisma
- name: Lint
run: npm run lint:check
- name: Type-Check
run: npm run typecheck
- name: Test
run: npm test -- --passWithNoTests
- name: Build
run: npm run build
# --------------------------------------------------------
# Frontend: Lint, Type-Check, Build
# --------------------------------------------------------
frontend:
name: Frontend CI
runs-on: ubuntu-latest
defaults:
run:
working-directory: packages/frontend
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install dependencies
run: npm ci
- name: Lint
run: npm run lint:check
- name: Type-Check
run: npm run typecheck
- name: Build
run: npm run build

View file

@ -0,0 +1,108 @@
# ============================================================
# INSIGHT MVP - Deploy Pipeline
# ============================================================
# Baut Docker-Images, pusht sie in die Forgejo Registry
# und deployed auf den insight-dev-01 Server.
#
# Wird nur bei Push auf 'main' oder 'develop' ausgefuehrt.
# ============================================================
name: Deploy
on:
push:
branches: [main, develop]
jobs:
# --------------------------------------------------------
# Docker Images bauen und in Registry pushen
# --------------------------------------------------------
build-and-push:
name: Build & Push Images
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Determine Tag
id: tag
run: |
if [ "${{ github.ref_name }}" = "main" ]; then
echo "tag=latest" >> $GITHUB_OUTPUT
else
echo "tag=develop" >> $GITHUB_OUTPUT
fi
- name: Login to Container Registry
run: |
echo "${{ secrets.REGISTRY_PASSWORD }}" | \
docker login git.xinion.lan -u ${{ secrets.REGISTRY_USER }} --password-stdin
# Core-Service Image
- name: Build Core-Service
run: |
docker build \
-t git.xinion.lan/gitadmin/insight-core:${{ steps.tag.outputs.tag }} \
-f packages/core-service/Dockerfile \
--target production \
packages/core-service
- name: Push Core-Service
run: docker push git.xinion.lan/gitadmin/insight-core:${{ steps.tag.outputs.tag }}
# Frontend Image
- name: Build Frontend
run: |
docker build \
-t git.xinion.lan/gitadmin/insight-frontend:${{ steps.tag.outputs.tag }} \
-f packages/frontend/Dockerfile \
--target production \
packages/frontend
- name: Push Frontend
run: docker push git.xinion.lan/gitadmin/insight-frontend:${{ steps.tag.outputs.tag }}
# --------------------------------------------------------
# Auf Server deployen
# --------------------------------------------------------
deploy:
name: Deploy to Server
runs-on: ubuntu-latest
needs: build-and-push
steps:
- name: Deploy via SSH
run: |
# SSH-Key vorbereiten
mkdir -p ~/.ssh
echo "${{ secrets.SSH_DEPLOY_KEY }}" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
ssh-keyscan -H ${{ secrets.DEPLOY_HOST }} >> ~/.ssh/known_hosts
# Deploy-Befehle auf dem Server ausfuehren
ssh -i ~/.ssh/deploy_key ${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }} << 'DEPLOY'
cd ~/insight
# Registry Login
echo "${{ secrets.REGISTRY_PASSWORD }}" | \
docker login git.xinion.lan -u ${{ secrets.REGISTRY_USER }} --password-stdin
# Neue Images pullen
docker compose pull core frontend
# Services mit neuem Image starten
docker compose up -d core frontend
# Health-Check warten
sleep 10
curl -f http://localhost:3000/health || echo "WARNUNG: Health-Check fehlgeschlagen"
# Alte Images aufraeumen
docker image prune -f
DEPLOY
- name: Verify Deployment
run: |
ssh -i ~/.ssh/deploy_key ${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }} \
"docker compose ps && echo '--- Deployment erfolgreich ---'"

63
.gitignore vendored Normal file
View file

@ -0,0 +1,63 @@
# Dependencies
node_modules/
.pnp
.pnp.js
# Build output
dist/
build/
*.tsbuildinfo
# Environment (NIEMALS committen!)
.env
.env.local
.env.*.local
# IDE
.idea/
.vscode/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Docker volumes (lokal)
docker-data/
postgres-data/
redis-data/
media-uploads/
# Logs
logs/
*.log
npm-debug.log*
# Test coverage
coverage/
# Prisma
packages/core-service/prisma/*.db
packages/core-service/prisma/*.db-journal
# Generated Prisma Client
packages/core-service/node_modules/.prisma/
# Temporary files
tmp/
temp/
*.tmp
# Certificates (generierte Zertifikate, nicht die CA-Config)
config/step-ca/secrets/
config/step-ca/db/
*.pem
*.key
*.crt
!config/step-ca/*.example
# Backup files
*.bak
*.backup

7
.keys/cicd_ed25519 Normal file
View file

@ -0,0 +1,7 @@
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
QyNTUxOQAAACDZT6PgLwzEzGQtBuPaPpLlPfP2gvOTfdEFN2vhWk46BgAAAKC7x6Lou8ei
6AAAAAtzc2gtZWQyNTUxOQAAACDZT6PgLwzEzGQtBuPaPpLlPfP2gvOTfdEFN2vhWk46Bg
AAAECBB/Q1ujr07L/3IwgTE3siUvM5fBLMO5iuw5eHkR1VctlPo+AvDMTMZC0G49o+kuU9
8/aC85N90QU3a+FaTjoGAAAAF2luc2lnaHQtY2ljZEB4aW5pb24ubGFuAQIDBAUG
-----END OPENSSH PRIVATE KEY-----

1
.keys/cicd_ed25519.pub Normal file
View file

@ -0,0 +1 @@
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINlPo+AvDMTMZC0G49o+kuU98/aC85N90QU3a+FaTjoG insight-cicd@xinion.lan

7
.keys/deploy_ed25519 Normal file
View file

@ -0,0 +1,7 @@
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
QyNTUxOQAAACDLk6asy8o6kyAzCeG8BBOKNiXhx94pi/jXoqXrgX4k6AAAAKBprr69aa6+
vQAAAAtzc2gtZWQyNTUxOQAAACDLk6asy8o6kyAzCeG8BBOKNiXhx94pi/jXoqXrgX4k6A
AAAECki73xblIq6Dx917rd90A5YrQwWVvp4RBMkU+RHsxNncuTpqzLyjqTIDMJ4bwEE4o2
JeHH3imL+NeipeuBfiToAAAAGWluc2lnaHQtZGVwbG95QHhpbmlvbi5sYW4BAgME
-----END OPENSSH PRIVATE KEY-----

1
.keys/deploy_ed25519.pub Normal file
View file

@ -0,0 +1 @@
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMuTpqzLyjqTIDMJ4bwEE4o2JeHH3imL+NeipeuBfiTo insight-deploy@xinion.lan

BIN
CLAUDE_BRIEFING.docx Normal file

Binary file not shown.

BIN
INSIGHT_Konzept_v1.0.docx Normal file

Binary file not shown.

BIN
Icons/Address.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

BIN
Icons/Mail.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

BIN
Icons/Mobile.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 750 B

BIN
Icons/Phone.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

207
README.md
View file

@ -1,2 +1,207 @@
# INSIGHT-MVP # INSIGHT MVP
Erweiterbare, mandantenfaehige SaaS-Business-Plattform der Xinion IT GmbH.
---
## Inhaltsverzeichnis
- [Projektuebersicht](#projektuebersicht)
- [Voraussetzungen](#voraussetzungen)
- [Setup (Entwicklungsumgebung)](#setup-entwicklungsumgebung)
- [Services & Ports](#services--ports)
- [Projektstruktur](#projektstruktur)
- [Branching & Commits](#branching--commits)
- [Dokumentation](#dokumentation)
---
## Projektuebersicht
INSIGHT ist eine Infrastruktur-Shell, auf die fachliche Module (erstes Modul: CRM) als isolierte Docker-Container aufgesetzt werden. Das System ist Cloud-Native und Kubernetes-ready.
**Kernprinzipien:**
- Zero-Trust (mTLS intern)
- Stateless Backend-Services
- Separate Datenbank pro Mandant (Tenant-Isolation)
- Provider-Modell fuer Authentifizierung (lokal + MS SSO)
**Tech Stack:**
TypeScript | NestJS | React + Vite | PostgreSQL | Prisma | Redis | Traefik | Docker
---
## Voraussetzungen
### Fuer lokale Entwicklung (MacBook)
- Git mit SSH-Zugang zu `git.xinion.lan`
- Docker Desktop oder Docker Engine
- Node.js >= 20 LTS
- npm oder yarn
### Fuer den Server (ProxmoxVE VM)
- Ubuntu 24.04 LTS
- Docker Engine + Compose Plugin (kein Docker Desktop)
- SSH-Key aus `.keys/deploy_ed25519.pub` im `authorized_keys` des `deploy`-Users
---
## Setup (Entwicklungsumgebung)
### 1. Repository klonen
```bash
git clone ssh://git@git.xinion.lan/gitadmin/INSIGHT-MVP.git
cd INSIGHT-MVP
```
### 2. Environment konfigurieren
```bash
cp .env.example .env
# .env oeffnen und alle Werte befuellen (Passwoerter, Keys, etc.)
```
### 3. JWT-Schluessel generieren
```bash
# RS256 Schluessel fuer JWT-Signierung
mkdir -p keys
openssl genpkey -algorithm RSA -out keys/jwt-private.pem -pkeyopt rsa_keygen_bits:2048
openssl rsa -pubout -in keys/jwt-private.pem -out keys/jwt-public.pem
```
### 4. Services starten
```bash
# Basis-Services
docker compose up -d
# Mit Observability-Stack
docker compose -f docker-compose.yml -f docker-compose.observability.yml up -d
```
### 5. Datenbank-Migration + Seed
```bash
# Core-Schema migrieren
docker compose run --rm core npx prisma migrate deploy --schema=./prisma/core.schema.prisma
# Admin-User anlegen
docker compose run --rm core npx ts-node prisma/seed.ts
```
### 6. Health-Checks pruefen
```bash
curl http://172.20.10.59/health
```
### 7. Erster Login
- URL: `http://172.20.10.59`
- Admin: `admin@xinion.de` / `ChangeMe123!`
- Passwort nach erstem Login aendern!
---
## Services & Ports
| Service | Port (intern) | URL (extern via Traefik) | Beschreibung |
|---------------|---------------|----------------------------------|------------------------|
| Traefik | 80 | http://172.20.10.59 | API Gateway |
| Core-Service | 3000 | /api/v1/* | NestJS Backend |
| Frontend | 8080 | /* | React App |
| PostgreSQL | 5432 | - | Datenbank |
| PgBouncer | 6432 | - | Connection Pooler |
| Redis | 6379 | - | Cache & Event Bus |
| step-ca | 9000 | - | Interne CA (mTLS) |
| Grafana | 3001 | SSH-Tunnel | Monitoring Dashboards |
---
## Projektstruktur
```
INSIGHT-MVP/
docker-compose.yml # Basis-Services
docker-compose.observability.yml # Monitoring-Stack
.env.example # Alle Umgebungsvariablen (keine Werte!)
.gitignore
README.md # <- Du bist hier
.keys/ # SSH Deployment Keys
deploy_ed25519
deploy_ed25519.pub
docs/ # Projektdokumentation
INFRASTRUCTURE.md # Server & VM Konfiguration
ACCESS.md # Zugangsdaten & SSH-Infos
packages/
core-service/ # NestJS Backend
src/
core/
auth/ # Auth-Service (Provider-Modell)
users/ # User-Verwaltung
tenants/ # Tenant-Verwaltung
modules/ # Module-Registry
common/
guards/ # JwtGuard, RolesGuard, ScopeGuard
decorators/ # @Public(), @Roles(), @RequireScope()
filters/ # GlobalExceptionFilter
interceptors/ # Logging, Response-Transformation
config/ # Env-Validierung (class-validator)
prisma/ # PrismaService + TenantPrismaService
prisma/
core.schema.prisma # platform_core Tabellen
tenant.schema.prisma # Tenant-DB Tabellen
frontend/ # React + Vite
src/
shell/ # App-Shell (Layout, Routing)
auth/ # Login, 2FA, Token-Management
admin/ # Admin-Bereich
components/ # Shared UI-Komponenten
config/ # Service-Konfigurationen
traefik/
prometheus/
step-ca/
.forgejo/
workflows/ # CI/CD Pipelines
ci.yml
develop.yml
release.yml
```
---
## Branching & Commits
### Branching-Strategie: GitFlow
| Branch | Zweck |
|------------- |------------------------------------------|
| `main` | Produktion (nur via Merge, geschuetzt) |
| `develop` | Integration (nur via Merge, geschuetzt) |
| `feature/*` | Neue Features |
| `fix/*` | Bugfixes |
| `hotfix/*` | Kritische Fixes auf main |
### Commit-Format: Conventional Commits
```
feat: Neues Feature
fix: Bugfix
chore: Tooling, Dependencies
docs: Dokumentation
refactor: Refactoring ohne Funktionsaenderung
```
---
## Dokumentation
| Dokument | Beschreibung |
|---------------------------------|-------------------------------------------|
| `README.md` | Dieses Dokument (Onboarding) |
| `docs/INFRASTRUCTURE.md` | Server-Infrastruktur & VM-Setup |
| `docs/ACCESS.md` | Zugangsdaten & SSH-Verbindungen |
| `INSIGHT_Konzept_v1.0.docx` | Vollstaendiges Konzeptdokument (23 Kap.) |
| `CLAUDE_BRIEFING.docx` | Entwickler-Briefing (Kurzreferenz) |
| `Summarize.md` | Aenderungsprotokoll (aktueller Stand) |
| `RUNBOOK.md` | Disaster Recovery Anleitung (folgt) |

290
Summarize.md Normal file
View file

@ -0,0 +1,290 @@
# INSIGHT MVP - Aenderungsprotokoll
## Stand: 2026-03-08
### Aktueller Sprint: Sprint 1 (Alpha)
---
### Aenderungen in dieser Session
#### 1. Projektinitialisierung & Infrastruktur-Definition
**Was wurde gemacht:**
1. **SSH Keys erstellt**
- Deploy-Key (`.keys/deploy_ed25519`) fuer Server-Zugriff
- CI/CD-Key (`.keys/cicd_ed25519`) fuer Forgejo Actions Pipeline
2. **Infrastruktur-Definition erstellt** (`docs/INFRASTRUCTURE.md`)
- ProxmoxVE VM-Spezifikation: Ubuntu 24.04 LTS, 4 vCPU, 8 GB RAM, 60 GB SSD
- Docker-Netzwerk-Architektur mit 3 isolierten Netzwerken
- Komplette Service-Landschaft definiert
- Schritt-fuer-Schritt VM-Setup Anleitung
3. **Zugangsdaten-Dokument erstellt** (`docs/ACCESS.md`)
- Server-IP: 172.20.10.59 (insight-dev-01)
- Git-Server: 172.20.10.11 (GAIA-GIT)
- Alle SSH-Keys, Ports, Befehle dokumentiert
4. **Projektstruktur aufgesetzt** (packages/core-service, packages/frontend, config, .forgejo)
5. **Basis-Konfigurationsdateien** (.gitignore, .env.example, README.md)
#### 2. Forgejo Git-Server Konfiguration
**Was wurde auf dem Git-Server (172.20.10.11) gemacht:**
1. **Docker Engine 29.3 installiert** (fuer Forgejo Actions Runner)
2. **Forgejo Actions aktiviert** (`[actions] ENABLED = true` in app.ini)
3. **Container Registry aktiviert** (`[packages] ENABLED = true` in app.ini)
4. **Forgejo Runner v6.3.1 installiert und registriert**
- Runner-Name: `insight-runner`
- Labels: `ubuntu-latest` (docker://node:20)
- Laeuft als Systemd-Service (`forgejo-runner.service`)
5. **Repository Secrets angelegt:**
- `SSH_DEPLOY_KEY` - CI/CD Private Key
- `DEPLOY_HOST` - 172.20.10.59
- `DEPLOY_USER` - deploy
- `REGISTRY_USER` - gitadmin
- `REGISTRY_PASSWORD` - Forgejo Access Token
6. **Branch Protection eingerichtet:**
- `main`: Kein direkter Push, 1 Approval erforderlich
- `develop`: Kein direkter Push, 1 Approval erforderlich
7. **Forgejo Setup-Anleitung erstellt** (`docs/FORGEJO_SETUP.md`)
#### 3. Server-Setup (insight-dev-01)
**Was wurde auf dem Entwicklungsserver (172.20.10.59) gemacht:**
1. **SSH Public Keys hinterlegt** in `/home/deploy/.ssh/authorized_keys`
- Deploy-Key (`insight-deploy@xinion.lan`) - fuer manuellen Zugriff
- CI/CD-Key (`insight-cicd@xinion.lan`) - fuer Forgejo Actions Pipeline
2. **SSH-Zugang getestet** - Key-basierter Login als `deploy` funktioniert
#### 4. Docker Compose & Service-Konfiguration
**Erstellte Dateien:**
1. **`docker-compose.yml`** - Alle Basis-Services:
- Traefik 3 (API Gateway, Reverse Proxy, Rate Limiting)
- PostgreSQL 16-alpine (Performance-Tuning: 1GB shared_buffers, 4GB cache)
- PgBouncer (Connection Pooling, Transaction Mode)
- Redis 7-alpine (Cache, Sessions, Token-Revocation)
- step-ca (Interne Certificate Authority fuer mTLS - geplant)
- Core-Service (NestJS) mit Traefik-Labels
- Frontend (React) mit Traefik-Labels
- 3 isolierte Docker-Netzwerke (insight-web, insight-db, insight-cache)
- Health-Checks fuer alle Services
2. **`docker-compose.observability.yml`** - Monitoring-Stack:
- Prometheus (Metrics-Sammlung, 30 Tage Retention)
- Grafana (Dashboards, automatisch provisionierte Datenquellen)
- Loki (Log-Aggregation)
- Promtail (Docker Log-Collector)
- Tempo (Distributed Tracing, OTLP gRPC)
- cAdvisor (Container-Metriken)
- PostgreSQL Exporter (DB-Metriken)
3. **Konfigurationsdateien:**
- `config/traefik/dynamic/tls.yml` - TLS deaktiviert (Alpha/Dev)
- `config/traefik/dynamic/middlewares.yml` - Security-Headers, CORS, Compression
- `config/prometheus/prometheus.yml` - Scrape-Konfiguration
- `config/loki/loki.yml` - Log-Storage-Konfiguration
- `config/promtail/promtail.yml` - Docker-Log-Collector
- `config/tempo/tempo.yml` - Tracing-Backend
- `config/grafana/provisioning/datasources/datasources.yml` - Auto-Provisioning
- `config/postgres/init/01-init-extensions.sql` - DB-Extensions (uuid-ossp, pgcrypto, pg_trgm)
#### 5. NestJS Core-Service Implementierung
**Projekt-Setup:**
- `package.json` mit allen Dependencies (NestJS 10, Prisma 6, Passport, JWT, bcrypt, TOTP)
- `tsconfig.json` mit strict: true, noImplicitAny, strictNullChecks
- `Dockerfile` (Multi-Stage: base, deps, development, build, production)
- `nest-cli.json` Konfiguration
**Implementierte Module:**
1. **Auth-Modul** (`src/core/auth/`)
- `AuthService`: Login (E-Mail/Passwort), Token-Refresh, Logout, Token-Revocation
- `AuthController`: POST /login, /refresh, /logout
- `JwtStrategy`: RS256 Passport-Strategy
- `TotpService`: TOTP 2FA (Google Authenticator kompatibel)
- `LoginDto`: Validierung mit class-validator
- Account-Lockout nach 5 Fehlversuchen (15 Min Sperre)
- Refresh-Token als HttpOnly Cookie (secure/sameSite umgebungsabhaengig)
- Token-Rotation mit Redis-basierter Familien-Erkennung
2. **Users-Modul** (`src/core/users/`)
- `UsersService`: CRUD, Bcrypt Cost 12, Passwort-Hashing
- `UsersController`: GET /me, GET /users, POST /users, PATCH /users/:id
- DTOs: CreateUserDto, UpdateUserDto
- Paginierung mit Meta-Informationen
3. **Tenants-Modul** (`src/core/tenants/`)
- `TenantsService`: CRUD, Member-Management
- `TenantsController`: CRUD + POST /:id/members, DELETE /:id/members/:userId
- DTOs: CreateTenantDto, UpdateTenantDto, AddMemberDto
- Slug-Validierung (URL-freundlich)
4. **Infrastruktur-Module:**
- `PrismaService`: PostgreSQL-Verbindung (platform_core)
- `TenantPrismaService`: Dynamische Tenant-DB-Verbindungen mit Caching
- `RedisService`: Token-Blocklist, Refresh-Token-Familien, generischer Cache
- `HealthController`: GET /health (DB + Redis Status)
5. **Common (Guards, Decorators, Filter):**
- `@Public()` Decorator fuer oeffentliche Routen
- `@Roles()` Decorator fuer rollenbasierte Zugriffskontrolle
- `@CurrentUser()` Decorator fuer User-Extraktion aus JWT
- `JwtAuthGuard` (global) mit Token-Revocation-Check
- `RolesGuard` fuer Rollen-Pruefung
- `GlobalExceptionFilter` fuer strukturierte Fehlerantworten
6. **Config:**
- `validateConfig()` mit class-validator fuer Umgebungsvariablen
#### 6. Prisma-Schemas & Migration
1. **`core.schema.prisma`** (platform_core Datenbank):
- `User` - Plattform-Benutzer (mit Login-Tracking, 2FA)
- `AuthProvider` - Multi-Provider Auth (LOCAL, MS_SSO, M2M)
- `Tenant` - Mandanten mit JSON-Settings
- `TenantMembership` - User-Tenant-Zuordnung (M:N)
- `Module` - Verfuegbare Plattform-Module
- `TenantModule` - Module pro Tenant
- `AuditLog` - Plattform-weites Audit-Log
2. **`tenant.schema.prisma`** (tenant_{slug} Datenbanken):
- `Contact` - CRM-Kontakte (Person/Organisation)
- `Activity` - CRM-Aktivitaeten (Notiz, Anruf, E-Mail, Meeting, Task)
- Referenz-Schema fuer Sprint 2 (CRM-Modul)
3. **Erste Migration erstellt** (`prisma/migrations/20260308000000_init/`)
- 7 Tabellen, alle Indizes und Foreign Keys
- `migration_lock.toml` fuer Prisma
4. **Seed-Script erstellt** (`prisma/seed.ts`)
- Erstellt Platform-Admin: `admin@xinion.de` / `ChangeMe123!`
- Bcrypt Cost 12, Rolle: PLATFORM_ADMIN
#### 7. React Frontend-Shell
**Projekt-Setup:**
- `package.json` mit React 18, Vite 6, React Router 6, TanStack Query 5, Axios
- `tsconfig.json` mit strict TypeScript
- `vite.config.ts` mit API-Proxy und Path-Aliases
- `Dockerfile` (Multi-Stage: development mit Vite, production mit Nginx)
- `nginx.conf` (SPA-Routing, Security-Headers, Caching)
**Implementierte Komponenten:**
1. **Auth-System** (`src/auth/`)
- `AuthContext` + `useAuth()` Hook: Login, Logout, Silent Refresh
- `LoginPage`: E-Mail/Passwort + optionaler TOTP 2FA-Code
- Access-Token NUR im Memory (kein localStorage!)
- Automatischer Silent Refresh via HttpOnly Cookie
2. **API-Client** (`src/api/client.ts`)
- Axios-Instanz mit automatischem Token-Handling
- Request-Interceptor fuer Authorization-Header
- Response-Interceptor fuer automatisches Token-Refresh bei 401
3. **App-Shell** (`src/shell/`)
- `App`: React Router mit PrivateRoute-Guard
- `AppLayout`: Sidebar-Navigation + Outlet
- `DashboardPage`: Willkommens-Seite
4. **Admin-Bereich** (`src/admin/`)
- `AdminUsersPage`: Benutzer-Tabelle mit Paginierung
- `AdminTenantsPage`: Mandanten-Tabelle mit Member-Count
5. **Styling:**
- CSS Custom Properties (Farben, Layout, Schatten, Radien)
- CSS Modules fuer komponentenspezifische Styles
- Responsive Sidebar-Layout
#### 8. CI/CD Pipelines
1. **`.forgejo/workflows/ci.yml`** - Continuous Integration:
- Trigger: Push auf alle Branches + Pull Requests
- Core-Service: npm ci, Prisma Generate, Lint, Type-Check, Test, Build
- Frontend: npm ci, Lint, Type-Check, Build
2. **`.forgejo/workflows/deploy.yml`** - Deployment:
- Trigger: Push auf main/develop
- Build Docker-Images (Core + Frontend)
- Push in Forgejo Container Registry
- SSH-Deploy auf insight-dev-01
- Health-Check Verifizierung
#### 9. IP-basierte Deployment-Anpassung (HTTP statt HTTPS)
**Grund:** Kein DNS-Eintrag vorhanden, Zugriff nur ueber IP 172.20.10.59.
**Geaenderte Dateien:**
1. **`auth.controller.ts`** - Cookie secure/sameSite umgebungsabhaengig
- `secure: true` -> `secure: process.env.NODE_ENV === 'production'`
- `sameSite: 'strict'` -> `isProduction ? 'strict' : 'lax'`
- Betrifft `setRefreshTokenCookie()` und `logout()`
2. **`docker-compose.yml`** - HTTP + IP Umstellung
- HTTPS-Redirect entfernt
- TLS-Entrypoint deaktiviert, Port 443 entfernt
- Alle Host-Rules: `insight-dev.xinion.lan` -> `172.20.10.59`
- Alle Entrypoints: `websecure` -> `web`
- URL-Defaults auf `http://172.20.10.59`
- PostgreSQL Memory reduziert (1GB/4GB/256MB fuer 8GB RAM VM)
- JWT-Keys Volume-Mount hinzugefuegt: `./keys:/app/keys:ro`
3. **`config/traefik/dynamic/tls.yml`** - TLS-Konfiguration deaktiviert
4. **`config/traefik/dynamic/middlewares.yml`**
- HSTS-Headers entfernt
- CSP: `wss://insight-dev.xinion.lan` -> `ws://172.20.10.59`
- CORS: `https://insight-dev.xinion.lan` -> `http://172.20.10.59`
5. **`main.ts`** - CORS-Fallback auf `http://172.20.10.59`
6. **`env.validation.ts`** - APP_URL Default auf `http://172.20.10.59`
7. **`.env.example`** - Alle URLs auf `http://172.20.10.59`
8. **`package-lock.json`** - Generiert fuer core-service und frontend (npm ci braucht diese)
9. **Dokumentation aktualisiert:**
- `docs/INFRASTRUCTURE.md` - HTTP statt HTTPS, IP statt DNS
- `docs/ACCESS.md` - Ports, URLs, Default-Zugangsdaten
- `README.md` - Setup-Anleitung, URLs, Seed-Befehle
---
### Naechste Schritte
- [x] SSH Deploy Keys auf insight-dev-01 Server hinterlegen
- [x] `docker-compose.yml` erstellen (alle Basis-Services)
- [x] `docker-compose.observability.yml` erstellen
- [x] NestJS Core-Service implementieren (Auth, Users, Tenants)
- [x] Prisma-Schemas erstellen (core + tenant)
- [x] React Frontend-Shell implementieren
- [x] CI/CD Pipelines (.forgejo/workflows/) definieren
- [x] Codebase auf HTTP + IP (172.20.10.59) umstellen
- [x] Seed-Script erstellen (admin@xinion.de)
- [x] Prisma-Migration erstellen (init)
- [x] package-lock.json generieren
- [x] Dokumentation aktualisieren
- [ ] Commit & Push auf develop
- [ ] LVM-Festplatte auf Server erweitern (60GB -> voll nutzbar)
- [ ] Docker + Docker Compose auf insight-dev-01 installieren
- [ ] Repo klonen, .env + JWT-Keys auf Server erstellen
- [ ] Services starten, Migration + Seed ausfuehren
- [ ] Erster End-to-End Test (Login -> Dashboard)
---
### Offene Fragen / Abhaengigkeiten
- DNS-Eintrag `insight-dev.xinion.lan` wird spaeter eingerichtet (dann HTTPS aktivieren)
- LVM auf Server muss erweitert werden (60GB Disk, nur ~56GB sichtbar)

View file

@ -0,0 +1,47 @@
# ============================================================
# Grafana - Datenquellen (automatisch provisioniert)
# ============================================================
apiVersion: 1
datasources:
# Prometheus - Metriken
- name: Prometheus
type: prometheus
access: proxy
url: http://prometheus:9090
isDefault: true
editable: false
# Loki - Logs
- name: Loki
type: loki
access: proxy
url: http://loki:3100
editable: false
jsonData:
derivedFields:
- datasourceUid: tempo
matcherRegex: "traceId=(\\w+)"
name: TraceID
url: "$${__value.raw}"
# Tempo - Traces
- name: Tempo
type: tempo
access: proxy
uid: tempo
url: http://tempo:3200
editable: false
jsonData:
tracesToLogs:
datasourceUid: loki
tags: ['service']
mappedTags: [{ key: 'service.name', value: 'service' }]
mapTagNamesEnabled: true
filterByTraceID: true
tracesToMetrics:
datasourceUid: prometheus
tags: [{ key: 'service.name', value: 'service' }]
serviceMap:
datasourceUid: prometheus

37
config/loki/loki.yml Normal file
View file

@ -0,0 +1,37 @@
# ============================================================
# Loki - Log-Aggregation Konfiguration
# ============================================================
auth_enabled: false
server:
http_listen_port: 3100
common:
path_prefix: /loki
storage:
filesystem:
chunks_directory: /loki/chunks
rules_directory: /loki/rules
replication_factor: 1
ring:
kvstore:
store: inmemory
schema_config:
configs:
- from: 2024-01-01
store: tsdb
object_store: filesystem
schema: v13
index:
prefix: index_
period: 24h
limits_config:
retention_period: 30d
reject_old_samples: true
reject_old_samples_max_age: 168h
analytics:
reporting_enabled: false

View file

@ -0,0 +1,22 @@
-- ============================================================
-- PostgreSQL Initialisierung
-- ============================================================
-- Wird automatisch beim ersten Start ausgefuehrt.
-- Erstellt benoetigte Extensions fuer die platform_core DB.
-- ============================================================
-- UUID-Generierung (v4)
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
-- Kryptographische Funktionen (fuer Token-Hashing etc.)
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
-- Trigram-Index fuer Volltextsuche
CREATE EXTENSION IF NOT EXISTS "pg_trgm";
-- Bestaetigungsmeldung
DO $$
BEGIN
RAISE NOTICE 'INSIGHT: PostgreSQL Extensions erfolgreich installiert.';
END
$$;

View file

@ -0,0 +1,40 @@
# ============================================================
# Prometheus - Konfiguration
# ============================================================
global:
scrape_interval: 15s
evaluation_interval: 15s
scrape_timeout: 10s
scrape_configs:
# Traefik Metriken
- job_name: "traefik"
static_configs:
- targets: ["traefik:8082"]
# Core-Service Metriken (NestJS)
- job_name: "core-service"
metrics_path: /metrics
static_configs:
- targets: ["core:3000"]
# PostgreSQL Exporter
- job_name: "postgres"
static_configs:
- targets: ["postgres-exporter:9187"]
# cAdvisor (Container-Metriken)
- job_name: "cadvisor"
static_configs:
- targets: ["cadvisor:8080"]
# Redis (wenn Redis Exporter hinzugefuegt wird)
# - job_name: "redis"
# static_configs:
# - targets: ["redis-exporter:9121"]
# Prometheus Self-Monitoring
- job_name: "prometheus"
static_configs:
- targets: ["localhost:9090"]

View file

@ -0,0 +1,51 @@
# ============================================================
# Promtail - Log-Collector Konfiguration
# ============================================================
# Sammelt Docker Container Logs und sendet sie an Loki.
# ============================================================
server:
http_listen_port: 9080
grpc_listen_port: 0
positions:
filename: /tmp/positions.yaml
clients:
- url: http://loki:3100/loki/api/v1/push
scrape_configs:
# Docker Container Logs
- job_name: docker
docker_sd_configs:
- host: unix:///var/run/docker.sock
refresh_interval: 5s
filters:
- name: label
values: ["com.docker.compose.project=insight"]
relabel_configs:
# Container-Name als Label
- source_labels: ['__meta_docker_container_name']
regex: '/(.*)'
target_label: container
# Compose-Service-Name als Label
- source_labels: ['__meta_docker_container_label_com_docker_compose_service']
target_label: service
# Log-Stream (stdout/stderr)
- source_labels: ['__meta_docker_container_log_stream']
target_label: stream
pipeline_stages:
# JSON-Logs parsen (NestJS)
- json:
expressions:
level: level
message: message
timestamp: timestamp
context: context
- labels:
level:
context:
- timestamp:
source: timestamp
format: RFC3339

0
config/step-ca/.gitkeep Normal file
View file

41
config/tempo/tempo.yml Normal file
View file

@ -0,0 +1,41 @@
# ============================================================
# Tempo - Distributed Tracing Konfiguration
# ============================================================
server:
http_listen_port: 3200
distributor:
receivers:
otlp:
protocols:
grpc:
endpoint: "0.0.0.0:4317"
http:
endpoint: "0.0.0.0:4318"
storage:
trace:
backend: local
local:
path: /var/tempo/traces
wal:
path: /var/tempo/wal
metrics_generator:
registry:
external_labels:
source: tempo
cluster: insight-dev
storage:
path: /var/tempo/generator/wal
remote_write:
- url: http://prometheus:9090/api/v1/write
send_exemplars: true
overrides:
defaults:
metrics_generator:
processors:
- service-graphs
- span-metrics

View file

@ -0,0 +1,49 @@
# ============================================================
# Traefik - Globale Middlewares
# ============================================================
http:
middlewares:
# Security-Headers fuer alle Responses
security-headers:
headers:
browserXssFilter: true
contentTypeNosniff: true
frameDeny: true
customFrameOptionsValue: "SAMEORIGIN"
referrerPolicy: "strict-origin-when-cross-origin"
contentSecurityPolicy: >-
default-src 'self';
script-src 'self' 'unsafe-inline';
style-src 'self' 'unsafe-inline';
img-src 'self' data: blob:;
font-src 'self';
connect-src 'self' ws://172.20.10.59;
frame-ancestors 'self';
# CORS fuer API
cors-api:
headers:
accessControlAllowMethods:
- GET
- POST
- PUT
- PATCH
- DELETE
- OPTIONS
accessControlAllowHeaders:
- Content-Type
- Authorization
- X-Tenant-ID
- X-Request-ID
accessControlAllowOriginList:
- "http://172.20.10.59"
accessControlMaxAge: 86400
accessControlAllowCredentials: true
addVaryHeader: true
# Kompression
gzip-compress:
compress:
excludedContentTypes:
- text/event-stream

View file

@ -0,0 +1,2 @@
# TLS-Konfiguration deaktiviert fuer Alpha/Dev (IP-basierter HTTP-Zugang).
# Wird reaktiviert wenn DNS + HTTPS eingerichtet wird.

View file

@ -0,0 +1,185 @@
# ============================================================
# INSIGHT MVP - Docker Compose (Observability-Stack)
# ============================================================
# Ergaenzt docker-compose.yml um Monitoring, Logging & Tracing.
#
# Nutzung:
# docker compose -f docker-compose.yml -f docker-compose.observability.yml up -d
#
# Grafana (nur via SSH-Tunnel):
# ssh -L 3001:localhost:3001 -i .keys/deploy_ed25519 deploy@172.20.10.59
# Browser: http://localhost:3001
# ============================================================
networks:
insight-web:
external: true
insight-db:
external: true
volumes:
prometheus-data:
name: insight-prometheus-data
grafana-data:
name: insight-grafana-data
loki-data:
name: insight-loki-data
tempo-data:
name: insight-tempo-data
services:
# --------------------------------------------------------
# Prometheus - Metrics-Sammlung & -Speicherung
# --------------------------------------------------------
prometheus:
image: prom/prometheus:latest
container_name: insight-prometheus
restart: unless-stopped
command:
- "--config.file=/etc/prometheus/prometheus.yml"
- "--storage.tsdb.path=/prometheus"
- "--storage.tsdb.retention.time=30d"
- "--web.enable-lifecycle"
volumes:
- ./config/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro
- prometheus-data:/prometheus
networks:
- insight-web
ports:
- "127.0.0.1:9090:9090"
healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://localhost:9090/-/ready"]
interval: 30s
timeout: 5s
retries: 3
# --------------------------------------------------------
# Grafana - Dashboards & Alerting
# --------------------------------------------------------
grafana:
image: grafana/grafana:latest
container_name: insight-grafana
restart: unless-stopped
environment:
GF_SECURITY_ADMIN_USER: ${GRAFANA_ADMIN_USER:-admin}
GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD:?GRAFANA_ADMIN_PASSWORD muss gesetzt sein}
GF_SERVER_ROOT_URL: "http://localhost:3001"
GF_SERVER_HTTP_PORT: 3001
# Datenquellen per Provisioning
GF_PATHS_PROVISIONING: /etc/grafana/provisioning
# Keine anonyme Nutzung
GF_AUTH_ANONYMOUS_ENABLED: "false"
# Logging
GF_LOG_LEVEL: info
volumes:
- grafana-data:/var/lib/grafana
- ./config/grafana/provisioning:/etc/grafana/provisioning:ro
networks:
- insight-web
ports:
- "127.0.0.1:3001:3001"
depends_on:
prometheus:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:3001/api/health || exit 1"]
interval: 30s
timeout: 5s
retries: 3
# --------------------------------------------------------
# Loki - Log-Aggregation
# --------------------------------------------------------
loki:
image: grafana/loki:latest
container_name: insight-loki
restart: unless-stopped
command: -config.file=/etc/loki/loki.yml
volumes:
- ./config/loki/loki.yml:/etc/loki/loki.yml:ro
- loki-data:/loki
networks:
- insight-web
ports:
- "127.0.0.1:3100:3100"
healthcheck:
test: ["CMD-SHELL", "wget --spider -q http://localhost:3100/ready || exit 1"]
interval: 30s
timeout: 5s
retries: 3
# --------------------------------------------------------
# Promtail - Log-Collector (liest Docker Logs)
# --------------------------------------------------------
promtail:
image: grafana/promtail:latest
container_name: insight-promtail
restart: unless-stopped
command: -config.file=/etc/promtail/promtail.yml
volumes:
- ./config/promtail/promtail.yml:/etc/promtail/promtail.yml:ro
- /var/log:/var/log:ro
- /var/lib/docker/containers:/var/lib/docker/containers:ro
- /var/run/docker.sock:/var/run/docker.sock:ro
networks:
- insight-web
depends_on:
- loki
# --------------------------------------------------------
# Tempo - Distributed Tracing
# --------------------------------------------------------
tempo:
image: grafana/tempo:latest
container_name: insight-tempo
restart: unless-stopped
command: -config.file=/etc/tempo/tempo.yml
volumes:
- ./config/tempo/tempo.yml:/etc/tempo/tempo.yml:ro
- tempo-data:/var/tempo
networks:
- insight-web
ports:
- "127.0.0.1:3200:3200" # Tempo API
- "127.0.0.1:4317:4317" # OTLP gRPC
healthcheck:
test: ["CMD-SHELL", "wget --spider -q http://localhost:3200/ready || exit 1"]
interval: 30s
timeout: 5s
retries: 3
# --------------------------------------------------------
# cAdvisor - Container-Metriken
# --------------------------------------------------------
cadvisor:
image: gcr.io/cadvisor/cadvisor:latest
container_name: insight-cadvisor
restart: unless-stopped
privileged: true
volumes:
- /:/rootfs:ro
- /var/run:/var/run:ro
- /sys:/sys:ro
- /var/lib/docker/:/var/lib/docker:ro
- /dev/disk/:/dev/disk:ro
networks:
- insight-web
ports:
- "127.0.0.1:8081:8080"
# --------------------------------------------------------
# PostgreSQL Exporter - DB-Metriken fuer Prometheus
# --------------------------------------------------------
postgres-exporter:
image: prometheuscommunity/postgres-exporter:latest
container_name: insight-postgres-exporter
restart: unless-stopped
environment:
DATA_SOURCE_NAME: "postgresql://${DB_USER:-insight}:${DB_PASSWORD}@postgres:5432/${DB_NAME:-platform_core}?sslmode=disable"
networks:
- insight-web
- insight-db
ports:
- "127.0.0.1:9187:9187"
depends_on:
- postgres

309
docker-compose.yml Normal file
View file

@ -0,0 +1,309 @@
# ============================================================
# INSIGHT MVP - Docker Compose (Basis-Services)
# ============================================================
# Startet alle Kerndienste der INSIGHT-Plattform.
# Observability-Stack separat: docker-compose.observability.yml
#
# Nutzung:
# docker compose up -d
# docker compose logs -f core
# docker compose ps
# ============================================================
networks:
insight-web:
driver: bridge
name: insight-web
insight-db:
driver: bridge
name: insight-db
internal: true
insight-cache:
driver: bridge
name: insight-cache
internal: true
volumes:
postgres-data:
name: insight-postgres-data
redis-data:
name: insight-redis-data
step-ca-data:
name: insight-step-ca-data
traefik-certs:
name: insight-traefik-certs
services:
# --------------------------------------------------------
# Traefik - API Gateway / Reverse Proxy
# --------------------------------------------------------
traefik:
image: traefik:3
container_name: insight-traefik
restart: unless-stopped
command:
# API & Dashboard
- "--api.dashboard=true"
- "--api.insecure=true"
# Entrypoints (nur HTTP fuer Alpha/Dev mit IP-Zugang)
- "--entrypoints.web.address=:80"
# Docker Provider
- "--providers.docker=true"
- "--providers.docker.exposedbydefault=false"
- "--providers.docker.network=insight-web"
# File Provider (fuer dynamische Konfiguration)
- "--providers.file.directory=/etc/traefik/dynamic"
- "--providers.file.watch=true"
# Logging
- "--log.level=INFO"
- "--accesslog=true"
- "--accesslog.format=json"
# Ping (fuer Healthcheck)
- "--ping=true"
# Metrics fuer Prometheus
- "--metrics.prometheus=true"
- "--metrics.prometheus.entryPoint=metrics"
- "--entrypoints.metrics.address=:8082"
ports:
- "80:80"
- "8080:8080" # Dashboard (nur intern)
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- ./config/traefik/dynamic:/etc/traefik/dynamic:ro
- traefik-certs:/certs
networks:
- insight-web
labels:
- "traefik.enable=true"
# Dashboard Route (nur intern, via Port 8080)
- "traefik.http.routers.dashboard.rule=Host(`172.20.10.59`) && PathPrefix(`/dashboard`)"
- "traefik.http.routers.dashboard.service=api@internal"
- "traefik.http.routers.dashboard.entrypoints=web"
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:8080/ping || exit 1"]
interval: 30s
timeout: 5s
retries: 3
# --------------------------------------------------------
# PostgreSQL - Datenbank
# --------------------------------------------------------
postgres:
image: postgres:16-alpine
container_name: insight-postgres
restart: unless-stopped
environment:
POSTGRES_USER: ${DB_USER:-insight}
POSTGRES_PASSWORD: ${DB_PASSWORD:?DB_PASSWORD muss gesetzt sein}
POSTGRES_DB: ${DB_NAME:-platform_core}
# Performance-Tuning fuer 8GB RAM VM
POSTGRES_INITDB_ARGS: "--data-checksums"
volumes:
- postgres-data:/var/lib/postgresql/data
- ./config/postgres/init:/docker-entrypoint-initdb.d:ro
networks:
- insight-db
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-insight} -d ${DB_NAME:-platform_core}"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
# Performance-Tuning via Command
command:
- "postgres"
- "-c"
- "shared_buffers=1GB"
- "-c"
- "effective_cache_size=4GB"
- "-c"
- "work_mem=16MB"
- "-c"
- "maintenance_work_mem=256MB"
- "-c"
- "max_connections=200"
- "-c"
- "log_min_duration_statement=500"
- "-c"
- "log_statement=ddl"
# --------------------------------------------------------
# PgBouncer - Connection Pooling
# --------------------------------------------------------
pgbouncer:
image: edoburu/pgbouncer:latest
container_name: insight-pgbouncer
restart: unless-stopped
environment:
DATABASE_URL: "postgres://${DB_USER:-insight}:${DB_PASSWORD}@postgres:5432/${DB_NAME:-platform_core}"
POOL_MODE: transaction
MAX_CLIENT_CONN: 500
DEFAULT_POOL_SIZE: 25
MIN_POOL_SIZE: 5
RESERVE_POOL_SIZE: 5
SERVER_RESET_QUERY: "DISCARD ALL"
SERVER_CHECK_DELAY: 30
SERVER_CHECK_QUERY: "SELECT 1"
AUTH_TYPE: scram-sha-256
networks:
- insight-db
depends_on:
postgres:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -p 5432"]
interval: 10s
timeout: 5s
retries: 3
# --------------------------------------------------------
# Redis - Cache, Sessions, Event Bus
# --------------------------------------------------------
redis:
image: redis:7-alpine
container_name: insight-redis
restart: unless-stopped
command: >
redis-server
--requirepass ${REDIS_PASSWORD:-}
--maxmemory 512mb
--maxmemory-policy allkeys-lru
--appendonly yes
--appendfsync everysec
--save 60 1000
--save 300 100
volumes:
- redis-data:/data
networks:
- insight-cache
healthcheck:
test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD:-}", "ping"]
interval: 10s
timeout: 5s
retries: 3
# --------------------------------------------------------
# step-ca - Interne Certificate Authority (mTLS)
# --------------------------------------------------------
step-ca:
image: smallstep/step-ca:latest
container_name: insight-step-ca
restart: unless-stopped
environment:
DOCKER_STEPCA_INIT_NAME: "INSIGHT Internal CA"
DOCKER_STEPCA_INIT_DNS_NAMES: "step-ca,localhost"
DOCKER_STEPCA_INIT_REMOTE_MANAGEMENT: "true"
DOCKER_STEPCA_INIT_ACME: "true"
volumes:
- step-ca-data:/home/step
networks:
- insight-web
- insight-db
- insight-cache
healthcheck:
test: ["CMD", "step", "ca", "health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
# --------------------------------------------------------
# Core-Service - NestJS Backend
# --------------------------------------------------------
core:
build:
context: ./packages/core-service
dockerfile: Dockerfile
target: development
container_name: insight-core
restart: unless-stopped
environment:
NODE_ENV: ${NODE_ENV:-development}
APP_PORT: ${APP_PORT:-3000}
APP_URL: ${APP_URL:-http://172.20.10.59}
FRONTEND_URL: ${FRONTEND_URL:-http://172.20.10.59}
LOG_LEVEL: ${LOG_LEVEL:-info}
# Database (via PgBouncer)
DATABASE_URL: "postgresql://${DB_USER:-insight}:${DB_PASSWORD}@pgbouncer:5432/${DB_NAME:-platform_core}"
# Database (direkt fuer Migrations)
DATABASE_URL_DIRECT: "postgresql://${DB_USER:-insight}:${DB_PASSWORD}@postgres:5432/${DB_NAME:-platform_core}"
# Redis
REDIS_HOST: redis
REDIS_PORT: 6379
REDIS_PASSWORD: ${REDIS_PASSWORD:-}
# JWT
JWT_PRIVATE_KEY_PATH: ${JWT_PRIVATE_KEY_PATH:-/app/keys/jwt-private.pem}
JWT_PUBLIC_KEY_PATH: ${JWT_PUBLIC_KEY_PATH:-/app/keys/jwt-public.pem}
JWT_ACCESS_TOKEN_EXPIRY: ${JWT_ACCESS_TOKEN_EXPIRY:-15m}
JWT_REFRESH_TOKEN_EXPIRY: ${JWT_REFRESH_TOKEN_EXPIRY:-7d}
JWT_ISSUER: ${JWT_ISSUER:-insight-platform}
# Bcrypt
BCRYPT_COST: ${BCRYPT_COST:-12}
# CORS
CORS_ORIGINS: ${CORS_ORIGINS:-http://172.20.10.59}
# Microsoft Entra ID (Azure AD) SSO
AZURE_TENANT_ID: ${AZURE_TENANT_ID:-}
AZURE_CLIENT_ID: ${AZURE_CLIENT_ID:-}
AZURE_CLIENT_SECRET: ${AZURE_CLIENT_SECRET:-}
AZURE_REDIRECT_URI: ${AZURE_REDIRECT_URI:-}
# Rate Limiting
THROTTLE_TTL: ${THROTTLE_TTL:-60000}
THROTTLE_LIMIT: ${THROTTLE_LIMIT:-200}
volumes:
- ./keys:/app/keys:ro
networks:
- insight-web
- insight-db
- insight-cache
depends_on:
pgbouncer:
condition: service_healthy
redis:
condition: service_healthy
labels:
- "traefik.enable=true"
# API Routing
- "traefik.http.routers.core-api.rule=Host(`172.20.10.59`) && PathPrefix(`/api`)"
- "traefik.http.routers.core-api.entrypoints=web"
- "traefik.http.routers.core-api.service=core-api"
- "traefik.http.services.core-api.loadbalancer.server.port=3000"
# Health-Endpunkt (ohne Auth)
- "traefik.http.routers.core-health.rule=Host(`172.20.10.59`) && Path(`/health`)"
- "traefik.http.routers.core-health.entrypoints=web"
- "traefik.http.routers.core-health.service=core-api"
# Rate Limiting Middleware
- "traefik.http.middlewares.api-ratelimit.ratelimit.average=100"
- "traefik.http.middlewares.api-ratelimit.ratelimit.burst=50"
- "traefik.http.routers.core-api.middlewares=api-ratelimit"
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:3000/health || exit 1"]
interval: 15s
timeout: 5s
retries: 3
start_period: 30s
# --------------------------------------------------------
# Frontend - React App (via Nginx)
# --------------------------------------------------------
frontend:
build:
context: ./packages/frontend
dockerfile: Dockerfile
target: development
container_name: insight-frontend
restart: unless-stopped
networks:
- insight-web
labels:
- "traefik.enable=true"
# Frontend Routing (Catch-All nach API)
- "traefik.http.routers.frontend.rule=Host(`172.20.10.59`)"
- "traefik.http.routers.frontend.entrypoints=web"
- "traefik.http.routers.frontend.service=frontend"
- "traefik.http.routers.frontend.priority=1"
- "traefik.http.services.frontend.loadbalancer.server.port=8080"
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:8080/ || exit 1"]
interval: 30s
timeout: 5s
retries: 3

264
docs/ACCESS.md Normal file
View file

@ -0,0 +1,264 @@
# INSIGHT MVP - Zugangsdaten & Server-Zugriff
> **Dieses Dokument wird laufend aktualisiert und enthaelt alle relevanten
> Zugangsinformationen fuer das Projekt.**
---
## 1. Git Repository
| Parameter | Wert |
|------------------|-----------------------------------------------------|
| Git-Server | Forgejo (self-hosted) |
| URL | `git.xinion.lan` |
| Repository (SSH) | `ssh://git@git.xinion.lan/gitadmin/INSIGHT-MVP.git` |
| Repository (HTTP)| `https://git.xinion.lan/gitadmin/INSIGHT-MVP` |
| Organisation | `gitadmin` |
| Zugriff | SSH Key-basiert |
| CI/CD | Forgejo Actions (GitHub Actions kompatibel) |
| Container Registry | `git.xinion.lan` (Forgejo built-in) |
---
## 2. SSH Keys
Alle Keys liegen im Repository unter `.keys/` (Repo ist nur intern verfuegbar).
### 2.1 Deploy Key (Server-Zugriff)
Fuer den SSH-Zugriff auf den Entwicklungsserver `insight-dev-01`.
| Datei | Beschreibung |
|------------------------------|---------------------------------|
| `.keys/deploy_ed25519` | Private Key (Server-Zugriff) |
| `.keys/deploy_ed25519.pub` | Public Key |
**Public Key:**
```
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMuTpqzLyjqTIDMJ4bwEE4o2JeHH3imL+NeipeuBfiTo insight-deploy@xinion.lan
```
**Hinterlegen auf:** Server `insight-dev-01` in `/home/deploy/.ssh/authorized_keys`
### 2.2 CI/CD Key (Forgejo Actions)
Fuer automatisierte Deployments durch die Forgejo Actions CI/CD-Pipeline.
Die Pipeline nutzt diesen Key, um sich per SSH auf den Server zu verbinden
und Docker-Container zu aktualisieren.
| Datei | Beschreibung |
|------------------------------|---------------------------------|
| `.keys/cicd_ed25519` | Private Key (CI/CD Pipeline) |
| `.keys/cicd_ed25519.pub` | Public Key |
**Public Key:**
```
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINlPo+AvDMTMZC0G49o+kuU98/aC85N90QU3a+FaTjoG insight-cicd@xinion.lan
```
**Hinterlegen auf:**
1. Server `insight-dev-01` in `/home/deploy/.ssh/authorized_keys`
2. Forgejo: Repository Settings > Secrets (als `SSH_DEPLOY_KEY` fuer Actions)
### 2.3 SSH-Verbindung zum Server
```bash
# Verbindung zum Entwicklungsserver:
ssh -i .keys/deploy_ed25519 deploy@172.20.10.59
# Mit SSH-Config (empfohlen):
# Eintrag in ~/.ssh/config:
Host insight-dev
HostName 172.20.10.59
User deploy
IdentityFile ~/git.xinion.lan/INSIGHT-MVP/.keys/deploy_ed25519
StrictHostKeyChecking accept-new
```
### 2.4 Wo welcher Key hinterlegt werden muss
| Key | Server `authorized_keys` | Forgejo Secrets |
|--------------|--------------------------|------------------------|
| deploy | Ja | Nein |
| cicd | Ja | Ja (`SSH_DEPLOY_KEY`) |
---
## 3. Entwicklungsserver (ProxmoxVE VM)
| Parameter | Wert |
|------------------|-----------------------------------------|
| **Hostname** | `insight-dev-01` |
| **OS** | Ubuntu 24.04 LTS |
| **IP** | `172.20.10.59` |
| **SSH-Port** | 22 |
| **SSH-User** | `deploy` |
| **SSH-Key** | `.keys/deploy_ed25519` |
| **Docker** | Docker Engine + Compose Plugin |
| **Projekt-Pfad** | `/home/deploy/insight/` |
### Schnellzugriff nach VM-Setup
```bash
# SSH auf den Server
ssh -i .keys/deploy_ed25519 deploy@172.20.10.59
# Status aller Container pruefen
docker compose ps
# Logs eines Services
docker compose logs -f core
# Neustart aller Services
docker compose restart
# Nur Backend neustarten
docker compose restart core
```
---
## 4. Service-Ports (auf der VM)
> **Alpha/Dev:** Kein HTTPS, kein DNS. Zugriff via `http://172.20.10.59`
| Service | Interner Port | Externer Port | URL |
|-----------------|---------------|---------------|----------------------------------|
| Traefik (HTTP) | 80 | 80 | http://172.20.10.59 |
| Traefik Dashboard | 8080 | - | Nur intern |
| Core-Service | 3000 | - | Via Traefik: /api/v1/* |
| Frontend | 8080 | - | Via Traefik: /* |
| PostgreSQL | 5432 | - | Nur intern (Docker-Netzwerk) |
| PgBouncer | 6432 | - | Nur intern (Docker-Netzwerk) |
| Redis | 6379 | - | Nur intern (Docker-Netzwerk) |
| step-ca | 9000 | - | Nur intern (Docker-Netzwerk) |
### Observability (nur intern, kein oeffentlicher Zugriff)
| Service | Port | Zugriff |
|-----------------|-------|----------------------------------|
| Grafana | 3001 | SSH-Tunnel: `ssh -L 3001:localhost:3001 deploy@172.20.10.59` |
| Prometheus | 9090 | Nur intern |
| Loki | 3100 | Nur intern |
| Tempo | 3200 | Nur intern |
---
## 5. Datenbank-Zugangsdaten
> **Echte Passwoerter stehen in der `.env`-Datei auf dem Server.
> Niemals in Git committen!**
| Parameter | Wert (Platzhalter) |
|-------------------|---------------------------------|
| DB-Host | `pgbouncer` (via Docker-Netzwerk) |
| DB-Port | `6432` |
| Core-DB-Name | `platform_core` |
| Tenant-DB-Schema | `tenant_{slug}` |
| DB-User | Siehe `.env` -> `DB_USER` |
| DB-Passwort | Siehe `.env` -> `DB_PASSWORD` |
---
## 6. Container Registry
| Parameter | Wert |
|------------------|-----------------------------------------------------|
| Registry-URL | `git.xinion.lan` |
| Image-Prefix | `git.xinion.lan/gitadmin/insight-{service}` |
| Authentifizierung| Forgejo Login-Credentials |
### Image-Namen
```
git.xinion.lan/gitadmin/insight-core:latest
git.xinion.lan/gitadmin/insight-core:develop
git.xinion.lan/gitadmin/insight-core:v0.1.0
git.xinion.lan/gitadmin/insight-frontend:latest
```
---
## 7. Deployment-Pfad
```
MacBook (Entwicklung)
|
| git push
v
Forgejo (git.xinion.lan)
|
| Forgejo Actions CI/CD
| - Lint, Type-Check, Tests, Build
| - Docker Image bauen & pushen
v
Server (insight-dev-01)
|
| docker compose pull && docker compose up -d
v
Laufende Anwendung
```
---
## 8. Git-Server (Forgejo)
| Parameter | Wert |
|------------------|-----------------------------------------|
| **Hostname** | `git.xinion.lan` |
| **IP** | `172.20.10.11` |
| **SSH-User** | `sysadmin` |
| **SSH-Port** | 22 |
| **Web-UI** | `https://git.xinion.lan` |
| **Forgejo-User** | `gitadmin` |
---
## 9. Default-Zugangsdaten (Alpha/Dev)
> **WICHTIG:** Diese Zugangsdaten gelten nur fuer die Ersteinrichtung!
> Passwoerter muessen nach dem ersten Login geaendert werden.
| Service | User / E-Mail | Passwort |
|-------------------|------------------------|--------------------|
| Plattform-Admin | `admin@xinion.de` | `ChangeMe123!` |
| Grafana | `admin` | Siehe `.env` |
| Traefik Dashboard | `admin` | Siehe `.env` |
---
## 10. Wichtige Befehle
### Vom MacBook aus
```bash
# Code pushen
git push origin develop
# SSH auf Server
ssh -i .keys/deploy_ed25519 deploy@172.20.10.59
# Plattform oeffnen
open http://172.20.10.59
# Grafana oeffnen (SSH-Tunnel)
ssh -L 3001:localhost:3001 -i .keys/deploy_ed25519 deploy@172.20.10.59
# Dann im Browser: http://localhost:3001
```
### Auf dem Server
```bash
# Alle Services starten
docker compose up -d
# Mit Observability
docker compose -f docker-compose.yml -f docker-compose.observability.yml up -d
# Health-Check
curl http://172.20.10.59/health
# Datenbank-Migration
docker compose run --rm core npx prisma migrate deploy --schema=./prisma/core.schema.prisma
# Admin-User seeden
docker compose run --rm core npx ts-node prisma/seed.ts
# Logs folgen
docker compose logs -f --tail=100
```

274
docs/FORGEJO_SETUP.md Normal file
View file

@ -0,0 +1,274 @@
# Forgejo Setup - Anleitung fuer den Git-Server
> **Ziel:** Forgejo so konfigurieren, dass CI/CD Pipelines (Forgejo Actions)
> automatisch laufen und Docker Images in die integrierte Container Registry
> gepusht werden.
---
## 1. Voraussetzungen pruefen
### 1.1 Forgejo Actions aktiviert?
Forgejo Actions ist seit Forgejo 1.21+ verfuegbar, muss aber in der
Server-Konfiguration aktiviert sein.
In der Forgejo-Konfiguration (`app.ini` auf dem Git-Server) pruefen:
```ini
# /etc/forgejo/app.ini (oder wo Forgejo installiert ist)
[actions]
ENABLED = true
DEFAULT_ACTIONS_URL = https://code.forgejo.org
```
Falls nicht vorhanden: Eintrag hinzufuegen und Forgejo neustarten.
```bash
sudo systemctl restart forgejo
```
### 1.2 Container Registry aktiviert?
Die Registry muss ebenfalls in `app.ini` aktiviert sein:
```ini
[packages]
ENABLED = true
```
---
## 2. Forgejo Actions Runner einrichten
Forgejo Actions braucht einen **Runner** (vergleichbar mit GitHub Actions
Self-Hosted Runner). Der Runner fuehrt die CI/CD-Jobs aus.
### 2.1 Runner auf dem Git-Server installieren
```bash
# Runner-Binary herunterladen (aktuelle Version pruefen auf:
# https://code.forgejo.org/forgejo/runner/releases)
# Beispiel fuer Linux x86_64:
wget https://code.forgejo.org/forgejo/runner/releases/download/v4.0.0/forgejo-runner-4.0.0-linux-amd64
chmod +x forgejo-runner-4.0.0-linux-amd64
sudo mv forgejo-runner-4.0.0-linux-amd64 /usr/local/bin/forgejo-runner
```
### 2.2 Runner registrieren
Zuerst ein **Registration Token** in Forgejo generieren:
1. Forgejo Web-UI oeffnen: `https://git.xinion.lan`
2. **Site Administration** > **Runners** (oben rechts Zahnrad-Icon)
3. Oder: **Repository** > **Settings** > **Actions** > **Runners**
4. Klick auf **"Create new Runner"** oder **"Registration Token"**
5. Token kopieren
Dann auf dem Server den Runner registrieren:
```bash
# Runner registrieren (interaktiv)
forgejo-runner register \
--instance https://git.xinion.lan \
--token <DEIN-REGISTRATION-TOKEN> \
--name insight-runner \
--labels ubuntu-latest:docker://node:20
# Alternativ: non-interaktiv
forgejo-runner register --no-interactive \
--instance https://git.xinion.lan \
--token <DEIN-REGISTRATION-TOKEN> \
--name insight-runner \
--labels "ubuntu-latest:docker://node:20,docker:docker://docker:latest"
```
### 2.3 Runner als Systemd-Service einrichten
```bash
# Service-Datei erstellen
sudo tee /etc/systemd/system/forgejo-runner.service > /dev/null << 'UNIT'
[Unit]
Description=Forgejo Actions Runner
After=docker.service
[Service]
Type=simple
User=forgejo-runner
WorkingDirectory=/opt/forgejo-runner
ExecStart=/usr/local/bin/forgejo-runner daemon
Restart=always
RestartSec=10
[Install]
WantedBy=multi-user.target
UNIT
# User und Verzeichnis anlegen
sudo useradd -r -s /bin/false forgejo-runner
sudo mkdir -p /opt/forgejo-runner
sudo chown forgejo-runner:forgejo-runner /opt/forgejo-runner
# Docker-Zugriff fuer den Runner
sudo usermod -aG docker forgejo-runner
# Service starten
sudo systemctl daemon-reload
sudo systemctl enable forgejo-runner
sudo systemctl start forgejo-runner
# Status pruefen
sudo systemctl status forgejo-runner
```
### 2.4 Pruefen ob Runner aktiv ist
In Forgejo Web-UI:
- **Site Administration** > **Runners**
- Der Runner `insight-runner` sollte mit Status **"Online"** erscheinen
---
## 3. Repository-Einstellungen
### 3.1 Actions fuer das Repository aktivieren
1. Repository oeffnen: `https://git.xinion.lan/gitadmin/INSIGHT-MVP`
2. **Settings** > **Repository**
3. Unter **"Actions"**: Haken setzen bei **"Enable Repository Actions"**
4. Speichern
### 3.2 Repository Secrets anlegen
Die CI/CD-Pipeline braucht Secrets fuer den Server-Zugang.
1. **Settings** > **Actions** > **Secrets**
2. Folgende Secrets anlegen:
| Secret Name | Wert | Zweck |
|---------------------|---------------------------------------------------------|-----------------------------|
| `SSH_DEPLOY_KEY` | Inhalt von `.keys/cicd_ed25519` (Private Key) | SSH-Zugriff auf Server |
| `DEPLOY_HOST` | IP-Adresse von `insight-dev-01` | Server-Adresse |
| `DEPLOY_USER` | `deploy` | SSH-User auf dem Server |
| `REGISTRY_USER` | Forgejo-Username (z.B. `gitadmin`) | Container Registry Login |
| `REGISTRY_PASSWORD` | Forgejo-Passwort oder Access Token | Container Registry Login |
#### SSH Key als Secret hinterlegen
Den **kompletten** Inhalt des Private Keys kopieren:
```bash
# Auf dem MacBook ausfuehren:
cat .keys/cicd_ed25519
```
Den gesamten Output (inkl. `-----BEGIN OPENSSH PRIVATE KEY-----` und
`-----END OPENSSH PRIVATE KEY-----`) in das Secret-Feld einfuegen.
#### Forgejo Access Token erstellen (fuer Registry)
Statt Passwort empfehlen wir einen **Access Token**:
1. Forgejo Web-UI > **Profil** (oben rechts) > **Settings** > **Applications**
2. **Generate New Token**
3. Name: `insight-registry`
4. Berechtigungen: `write:package` (oder `package` Scope)
5. Token kopieren und als `REGISTRY_PASSWORD` Secret anlegen
### 3.3 Branch Protection einrichten
1. **Settings** > **Branches**
2. **Add Branch Protection Rule**
Fuer `main`:
- **Branch name pattern:** `main`
- **Enable push:** Deaktiviert (nur via Merge)
- **Required approvals:** 1
- **Status checks must pass:** Aktiviert
Fuer `develop`:
- **Branch name pattern:** `develop`
- **Enable push:** Deaktiviert (nur via Merge)
- **Required approvals:** 1
- **Status checks must pass:** Aktiviert
---
## 4. Container Registry testen
### 4.1 Vom MacBook aus testen
```bash
# In die Forgejo Registry einloggen
docker login git.xinion.lan -u <FORGEJO-USERNAME>
# Passwort oder Access Token eingeben
# Test-Image taggen und pushen
docker pull hello-world
docker tag hello-world git.xinion.lan/gitadmin/insight-test:latest
docker push git.xinion.lan/gitadmin/insight-test:latest
# Aufraumen
docker rmi git.xinion.lan/gitadmin/insight-test:latest
```
### 4.2 In Forgejo pruefen
1. Repository > **Packages** Tab
2. Das `insight-test` Image sollte sichtbar sein
3. Nach dem Test kann es geloescht werden
---
## 5. Checkliste
Bitte alle Punkte abarbeiten und abhaken:
- [ ] `app.ini`: `[actions] ENABLED = true` gesetzt
- [ ] `app.ini`: `[packages] ENABLED = true` gesetzt
- [ ] Forgejo neugestartet nach Config-Aenderung
- [ ] Forgejo Runner installiert und registriert
- [ ] Runner laeuft als Systemd-Service und ist "Online"
- [ ] Repository Actions aktiviert (Settings > Repository)
- [ ] Secret `SSH_DEPLOY_KEY` angelegt (CI/CD Private Key)
- [ ] Secret `DEPLOY_HOST` angelegt (Server-IP)
- [ ] Secret `DEPLOY_USER` angelegt (`deploy`)
- [ ] Secret `REGISTRY_USER` angelegt (Forgejo Username)
- [ ] Secret `REGISTRY_PASSWORD` angelegt (Access Token)
- [ ] Branch Protection fuer `main` eingerichtet
- [ ] Branch Protection fuer `develop` eingerichtet
- [ ] Container Registry getestet (docker push/pull funktioniert)
---
## 6. Haeufige Probleme
### Runner startet nicht
```bash
# Logs pruefen
sudo journalctl -u forgejo-runner -f
# Hat der Runner Docker-Zugriff?
sudo -u forgejo-runner docker ps
```
### Actions werden nicht getriggert
- Ist Actions im Repository aktiviert? (Settings > Repository)
- Liegt die Workflow-Datei unter `.forgejo/workflows/`?
- Ist der Runner online? (Site Administration > Runners)
### Registry Push schlaegt fehl
```bash
# Login testen
docker login git.xinion.lan
# Ist die Registry aktiviert?
# In app.ini: [packages] ENABLED = true
```
### Branch Protection blockiert Push
- Korrekt! Main und Develop sind geschuetzt.
- Feature-Branches erstellen: `git checkout -b feature/mein-feature`
- Push auf Feature-Branch, dann Pull Request erstellen

269
docs/INFRASTRUCTURE.md Normal file
View file

@ -0,0 +1,269 @@
# INSIGHT MVP - Infrastruktur-Definition
## 1. Uebersicht
Die gesamte INSIGHT-Plattform laeuft auf einer ProxmoxVE-VM im internen Netzwerk.
Alle Services werden als Docker-Container betrieben.
---
## 2. VM-Konfiguration (ProxmoxVE)
| Komponente | Spezifikation |
|-----------------|----------------------------------------|
| **Hostname** | `insight-dev-01` |
| **OS** | Ubuntu 24.04 LTS (Server) |
| **CPU** | 4 vCPUs |
| **RAM** | 8 GB (16 GB empfohlen) |
| **Storage** | 60 GB SSD |
| **Netzwerk** | Feste interne IP (wird bei Setup vergeben) |
| **SSH-Zugang** | Key-basiert (Ed25519), kein Passwort-Login |
| **User** | `deploy` (non-root, Mitglied der `docker`-Gruppe) |
### Betriebssystem-Hardening
- SSH: nur Key-basiert (`PasswordAuthentication no`)
- Firewall (ufw):
- Port 22 (SSH) - nur internes Netzwerk
- Port 80 (HTTP) - Webzugang (kein HTTPS in Alpha/Dev)
- Alle anderen Ports: DENY
- Automatische Sicherheitsupdates: `unattended-upgrades` aktiviert
- Fail2ban fuer SSH-Brute-Force-Schutz
> **Hinweis:** In der Alpha/Dev-Phase wird kein HTTPS verwendet.
> Zugriff erfolgt ueber `http://172.20.10.59` (IP-basiert, kein DNS).
---
## 3. Software auf der VM
| Software | Version | Installationsmethode |
|---------------------|-------------|--------------------------------|
| Docker Engine | >= 27.x | Official Docker APT Repository |
| Docker Compose | Plugin | Mitgeliefert mit Docker Engine |
| Git | >= 2.x | APT |
| ufw | Aktuell | APT (vorinstalliert) |
| fail2ban | Aktuell | APT |
| unattended-upgrades | Aktuell | APT (vorinstalliert) |
**Kein** Docker Desktop, kein Node.js, kein npm auf der VM.
Alles laeuft in Containern.
---
## 4. Docker-Netzwerk-Architektur
```
Internet / Internes Netz
|
[ Port 80 ]
|
+-------v--------+
| Traefik | API Gateway, Reverse Proxy,
| (Gateway) | Rate Limiting
+---+-------+----+
| |
+---------+ +---------+
| |
+-------v--------+ +-------v--------+
| Core-Service | | Frontend |
| (NestJS) | | (React/Vite) |
| Port: 3000 | | Port: 8080 |
+---+--------+----+ +----------------+
| |
+-----v--+ +--v------+
| Redis | | PgBouncer|
| :6379 | | :6432 |
+----+----+ +----+-----+
| |
| +----v------+
| | PostgreSQL |
| | :5432 |
+-------+------------+
```
### Docker-Netzwerke
| Netzwerk | Zweck |
|---------------|-------------------------------------------------|
| `insight-web` | Traefik <-> Core-Service, Frontend (extern erreichbar) |
| `insight-db` | Core-Service <-> PgBouncer <-> PostgreSQL (intern) |
| `insight-cache`| Core-Service <-> Redis (intern) |
### mTLS (step-ca) - geplant fuer Produktion
> **Status:** mTLS ist in der Alpha/Dev-Phase deaktiviert.
> step-ca wird spaeter fuer interne Container-Kommunikation eingesetzt.
| Komponente | Zertifikat (geplant) |
|---------------|-------------------------------|
| Traefik | Wildcard fuer externe Domain |
| Core-Service | `core-service.insight.local` |
| Frontend | `frontend.insight.local` |
| PostgreSQL | `postgres.insight.local` |
| Redis | `redis.insight.local` |
| PgBouncer | `pgbouncer.insight.local` |
---
## 5. Container-Services (docker-compose.yml)
| Service | Image | Port (intern) | Port (extern) | Beschreibung |
|---------------|--------------------------------|---------------|---------------|-------------------------------|
| `traefik` | traefik:3 | 80, 8080 | 80 | API Gateway, Reverse Proxy |
| `core` | insight-core:latest | 3000 | - | NestJS Backend |
| `frontend` | insight-frontend:latest | 8080 | - | React App (Nginx served) |
| `postgres` | postgres:16-alpine | 5432 | - | Datenbank |
| `pgbouncer` | edoburu/pgbouncer:latest | 6432 | - | Connection Pooler |
| `redis` | redis:7-alpine | 6379 | - | Cache, Sessions, Event Bus |
| `step-ca` | smallstep/step-ca:latest | 9000 | - | Interne Certificate Authority |
---
## 6. Observability-Stack (docker-compose.observability.yml)
| Service | Image | Port (intern) | Beschreibung |
|------------------|---------------------------------|---------------|-----------------------------|
| `prometheus` | prom/prometheus:latest | 9090 | Metrics-Storage |
| `grafana` | grafana/grafana:latest | 3001 | Dashboards & Alerting |
| `loki` | grafana/loki:latest | 3100 | Log-Storage |
| `tempo` | grafana/tempo:latest | 3200, 4317 | Tracing-Backend |
| `promtail` | grafana/promtail:latest | - | Log-Collector |
| `cadvisor` | gcr.io/cadvisor/cadvisor:latest | 8081 | Container-Metrics |
| `postgres-exp` | prometheuscommunity/postgres-exporter | 9187 | DB-Metrics |
**Grafana ist NICHT oeffentlich erreichbar** - nur ueber SSH-Tunnel oder internes Netz.
---
## 7. Datenbank-Struktur
```
PostgreSQL-Server
platform_core <- Einmalig: Tenants, Users, Roles, Modules, Help
tenant_{slug} <- Pro Mandant (z.B. tenant_acme_corp)
```
| Datenbank | Zweck |
|-----------------|-----------------------------------------------------|
| `platform_core` | Plattform-Verwaltung (Users, Tenants, Roles, Modules) |
| `tenant_{slug}` | Mandant-Daten (Profile, Stammdaten, Moduldaten) |
---
## 8. Netzwerk / Zugriff
> **Alpha/Dev-Phase:** Kein DNS, Zugriff ueber IP-Adresse.
> HTTPS wird spaeter mit DNS-Eintrag aktiviert.
| Zugriff | URL | Zweck |
|----------------------------|--------------------------------|-------------------------------|
| Frontend + API | `http://172.20.10.59` | Entwicklungs-Plattform |
| API-Endpunkte | `http://172.20.10.59/api/v1/*` | REST API |
| Git-Server | `git.xinion.lan` | Git Repository & CI/CD |
### Spaeter (mit DNS):
| Eintrag | Ziel | Zweck |
|----------------------------|--------------------|-------------------------------|
| `insight-dev.xinion.lan` | VM-IP | Entwicklungs-Frontend (HTTPS) |
| `git.xinion.lan` | Forgejo-Server | Git Repository & CI/CD |
---
## 9. Backup (Alpha/Dev)
| Was | Wohin | Frequenz |
|----------------------|----------------------------------------|-----------|
| PostgreSQL (alle DBs)| Separates ProxmoxVE Volume | Taeglich |
| Media-Dateien | Separates ProxmoxVE Volume | Taeglich |
| Konfiguration | Git Repository (ohne .env) | Per Commit|
---
## 10. VM-Setup Anleitung (Schritt fuer Schritt)
### 10.1 VM in ProxmoxVE erstellen
```bash
# ProxmoxVE Web-UI oder CLI:
# - Template: Ubuntu 24.04 LTS Cloud-Init
# - CPU: 4 Cores
# - RAM: 8192 MB
# - Disk: 60 GB (SCSI, SSD-backed)
# - Network: vmbr0, DHCP oder feste IP
```
### 10.2 Basis-Setup nach Erstinstallation
```bash
# System aktualisieren
sudo apt update && sudo apt upgrade -y
# Deploy-User anlegen
sudo adduser --disabled-password deploy
sudo usermod -aG sudo deploy
# SSH-Key fuer Deploy-User hinterlegen
sudo mkdir -p /home/deploy/.ssh
sudo cp /path/to/deploy_ed25519.pub /home/deploy/.ssh/authorized_keys
sudo chown -R deploy:deploy /home/deploy/.ssh
sudo chmod 700 /home/deploy/.ssh
sudo chmod 600 /home/deploy/.ssh/authorized_keys
# SSH haerten
sudo sed -i 's/#PasswordAuthentication yes/PasswordAuthentication no/' /etc/ssh/sshd_config
sudo sed -i 's/PermitRootLogin yes/PermitRootLogin no/' /etc/ssh/sshd_config
sudo systemctl restart sshd
```
### 10.3 Firewall
```bash
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow 22/tcp # SSH
sudo ufw allow 80/tcp # HTTP
sudo ufw allow 443/tcp # HTTPS
sudo ufw enable
```
### 10.4 Docker installieren
```bash
# Docker Official GPG Key
sudo apt install -y ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg \
-o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc
# Docker Repo hinzufuegen
echo "deb [arch=$(dpkg --print-architecture) \
signed-by=/etc/apt/keyrings/docker.asc] \
https://download.docker.com/linux/ubuntu \
$(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \
sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
# Docker installieren
sudo apt update
sudo apt install -y docker-ce docker-ce-cli containerd.io \
docker-buildx-plugin docker-compose-plugin
# Deploy-User zur docker-Gruppe
sudo usermod -aG docker deploy
```
### 10.5 Fail2ban
```bash
sudo apt install -y fail2ban
sudo systemctl enable fail2ban
sudo systemctl start fail2ban
```
### 10.6 Projekt deployen
```bash
# Als deploy-User:
su - deploy
git clone git@git.xinion.lan:gitadmin/INSIGHT-MVP.git ~/insight
cd ~/insight
cp .env.example .env
# .env befuellen mit echten Werten
docker compose up -d
```

3
docs/git.md Normal file
View file

@ -0,0 +1,3 @@
IP: 172.20.10.11
User: sysadmin (non root)
Passord: $Sabrina$6506$

View file

@ -0,0 +1,7 @@
node_modules
dist
coverage
.env
*.md
.git
.gitignore

View file

@ -0,0 +1,67 @@
# ============================================================
# INSIGHT Core-Service - Multi-Stage Dockerfile
# ============================================================
# --- Base Stage ---
FROM node:20-alpine AS base
WORKDIR /app
RUN apk add --no-cache openssl
# --- Dependencies Stage ---
FROM base AS deps
# Build-Tools fuer native Module (bcrypt)
RUN apk add --no-cache python3 make g++
COPY package.json package-lock.json* ./
RUN npm ci --ignore-scripts
# Native Module kompilieren (bcrypt)
RUN npm rebuild bcrypt
# Prisma Generate braucht die Schema-Dateien
COPY prisma ./prisma
RUN npx prisma generate --schema=prisma/core.schema.prisma
# --- Development Stage ---
FROM base AS development
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npx prisma generate --schema=prisma/core.schema.prisma
EXPOSE 3000
CMD ["npm", "run", "start:dev"]
# --- Build Stage ---
FROM base AS build
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
# --- Production Stage ---
FROM base AS production
WORKDIR /app
ENV NODE_ENV=production
# Build-Tools fuer native Module (bcrypt)
RUN apk add --no-cache python3 make g++
# Nur Produktions-Dependencies
COPY package.json package-lock.json* ./
RUN npm ci --omit=dev --ignore-scripts
RUN npm rebuild bcrypt
# Build-Tools entfernen (Image klein halten)
RUN apk del python3 make g++
# Prisma Client generieren
COPY prisma ./prisma
RUN npx prisma generate --schema=prisma/core.schema.prisma
# Kompilierter Code
COPY --from=build /app/dist ./dist
# Non-root User
RUN addgroup -g 1001 -S nodejs && \
adduser -S nestjs -u 1001 -G nodejs
USER nestjs
EXPOSE 3000
CMD ["node", "dist/main"]

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 750 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

View file

@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}

11822
packages/core-service/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,107 @@
{
"name": "@insight/core-service",
"version": "0.1.0",
"description": "INSIGHT MVP - Core Service (NestJS Backend)",
"private": true,
"license": "UNLICENSED",
"scripts": {
"build": "nest build",
"start": "nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,test}/**/*.ts\" --fix",
"lint:check": "eslint \"{src,test}/**/*.ts\"",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"format:check": "prettier --check \"src/**/*.ts\" \"test/**/*.ts\"",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:e2e": "jest --config ./test/jest-e2e.json",
"typecheck": "tsc --noEmit",
"prisma:generate": "prisma generate --schema=prisma/core.schema.prisma",
"prisma:migrate": "prisma migrate dev --schema=prisma/core.schema.prisma",
"prisma:migrate:deploy": "prisma migrate deploy --schema=prisma/core.schema.prisma",
"prisma:studio": "prisma studio --schema=prisma/core.schema.prisma",
"prisma:seed": "ts-node prisma/seed.ts"
},
"dependencies": {
"@azure/msal-node": "^5.0.6",
"@nestjs/common": "^10.4.0",
"@nestjs/config": "^3.2.0",
"@nestjs/core": "^10.4.0",
"@nestjs/jwt": "^10.2.0",
"@nestjs/passport": "^10.0.3",
"@nestjs/platform-express": "^10.4.0",
"@nestjs/schedule": "^4.1.0",
"@nestjs/swagger": "^7.4.0",
"@nestjs/throttler": "^6.2.0",
"@prisma/client": "^6.4.0",
"bcrypt": "^5.1.1",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"cookie-parser": "^1.4.7",
"docx": "^9.6.0",
"helmet": "^8.0.0",
"ioredis": "^5.4.1",
"otplib": "^12.0.1",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"pdfkit": "^0.17.2",
"pngjs": "^7.0.0",
"qrcode": "^1.5.4",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"sharp": "^0.34.5",
"uuid": "^10.0.0"
},
"devDependencies": {
"@nestjs/cli": "^10.4.0",
"@nestjs/schematics": "^10.1.0",
"@nestjs/testing": "^10.4.0",
"@types/bcrypt": "^5.0.2",
"@types/cookie-parser": "^1.4.7",
"@types/express": "^5.0.0",
"@types/jest": "^29.5.12",
"@types/node": "^22.0.0",
"@types/passport-jwt": "^4.0.1",
"@types/pdfkit": "^0.17.5",
"@types/pngjs": "^6.0.5",
"@types/qrcode": "^1.5.5",
"@types/uuid": "^10.0.0",
"@typescript-eslint/eslint-plugin": "^8.0.0",
"@typescript-eslint/parser": "^8.0.0",
"eslint": "^9.0.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.2.0",
"jest": "^29.7.0",
"prettier": "^3.3.0",
"prisma": "^6.4.0",
"source-map-support": "^0.5.21",
"ts-jest": "^29.2.0",
"ts-loader": "^9.5.0",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.6.0"
},
"jest": {
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"collectCoverageFrom": [
"**/*.(t|j)s"
],
"coverageDirectory": "../coverage",
"testEnvironment": "node",
"moduleNameMapper": {
"^@/(.*)$": "<rootDir>/$1"
}
}
}

View file

@ -0,0 +1,327 @@
// ============================================================
// INSIGHT MVP - Core Schema (platform_core Datenbank)
// ============================================================
// Zentrale Plattform-Tabellen: Users, Tenants, Auth, Modules
// Kein raw SQL - nur Prisma!
// ============================================================
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
directUrl = env("DATABASE_URL_DIRECT")
}
// --------------------------------------------------------
// User - Plattform-Benutzer
// --------------------------------------------------------
model User {
id String @id @default(uuid()) @db.Uuid
email String @unique @db.VarChar(255)
firstName String @map("first_name") @db.VarChar(100)
lastName String @map("last_name") @db.VarChar(100)
avatar String? @db.Text // Profilbild als Base64 Data-URL
// Kontaktdaten
phone String? @map("phone") @db.VarChar(30)
mobile String? @map("mobile") @db.VarChar(30)
// Adresse
street String? @map("street") @db.VarChar(200)
postalCode String? @map("postal_code") @db.VarChar(10)
city String? @map("city") @db.VarChar(100)
role String @default("USER") @db.VarChar(50) // PLATFORM_ADMIN, TENANT_ADMIN, USER
isActive Boolean @default(true) @map("is_active")
// 2FA
twoFactorEnabled Boolean @default(false) @map("two_factor_enabled")
// Login-Tracking
lastLogin DateTime? @map("last_login")
failedLoginAttempts Int @default(0) @map("failed_login_attempts")
lastFailedLogin DateTime? @map("last_failed_login")
// Timestamps
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
// Relationen
authProvider AuthProvider[]
tenantMemberships TenantMembership[]
auditLogs AuditLog[]
expertProfile ExpertProfile?
@@map("users")
}
// --------------------------------------------------------
// AuthProvider - Authentifizierungs-Provider pro User
// --------------------------------------------------------
// Unterstuetzt mehrere Auth-Methoden pro User:
// LOCAL (Passwort), MS_SSO (spaeter), M2M (Machine-to-Machine)
model AuthProvider {
id String @id @default(uuid()) @db.Uuid
userId String @map("user_id") @db.Uuid
provider String @db.VarChar(50) // LOCAL, MS_SSO, M2M
providerId String? @map("provider_id") @db.VarChar(255) // Externe ID (z.B. MS Object ID)
passwordHash String? @map("password_hash") @db.VarChar(255)
totpSecret String? @map("totp_secret") @db.VarChar(255) // Verschluesselt
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
// Relationen
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([userId, provider])
@@map("auth_providers")
}
// --------------------------------------------------------
// Tenant - Mandant
// --------------------------------------------------------
model Tenant {
id String @id @default(uuid()) @db.Uuid
name String @db.VarChar(200)
slug String @unique @db.VarChar(50) // URL-freundlich, fuer DB-Name
isActive Boolean @default(true) @map("is_active")
// Mandant-Einstellungen (JSON)
settings Json @default("{}")
// Timestamps
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
// Relationen
members TenantMembership[]
modules TenantModule[]
@@map("tenants")
}
// --------------------------------------------------------
// TenantMembership - User-Tenant-Zuordnung (M:N)
// --------------------------------------------------------
model TenantMembership {
id String @id @default(uuid()) @db.Uuid
userId String @map("user_id") @db.Uuid
tenantId String @map("tenant_id") @db.Uuid
tenantRole String @default("MEMBER") @map("tenant_role") @db.VarChar(50) // ADMIN, MEMBER, VIEWER
isActive Boolean @default(true) @map("is_active")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
// Relationen
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
@@unique([userId, tenantId])
@@map("tenant_memberships")
}
// --------------------------------------------------------
// Module - Verfuegbare Plattform-Module
// --------------------------------------------------------
model Module {
id String @id @default(uuid()) @db.Uuid
key String @unique @db.VarChar(50) // z.B. "crm", "project", "docs"
name String @db.VarChar(100)
description String? @db.Text
version String @default("1.0.0") @db.VarChar(20)
isActive Boolean @default(true) @map("is_active")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
// Relationen
tenantModules TenantModule[]
@@map("modules")
}
// --------------------------------------------------------
// TenantModule - Welcher Tenant welche Module nutzt
// --------------------------------------------------------
model TenantModule {
id String @id @default(uuid()) @db.Uuid
tenantId String @map("tenant_id") @db.Uuid
moduleId String @map("module_id") @db.Uuid
isActive Boolean @default(true) @map("is_active")
// Modul-spezifische Konfiguration pro Tenant
config Json @default("{}")
activatedAt DateTime @default(now()) @map("activated_at")
// Relationen
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
module Module @relation(fields: [moduleId], references: [id], onDelete: Cascade)
@@unique([tenantId, moduleId])
@@map("tenant_modules")
}
// --------------------------------------------------------
// AuditLog - Plattform-weites Audit-Log
// --------------------------------------------------------
model AuditLog {
id String @id @default(uuid()) @db.Uuid
userId String? @map("user_id") @db.Uuid
action String @db.VarChar(100) // z.B. "user.login", "tenant.create"
entity String @db.VarChar(100) // z.B. "User", "Tenant"
entityId String? @map("entity_id") @db.VarChar(255)
details Json? // Zusaetzliche Informationen
ipAddress String? @map("ip_address") @db.VarChar(45)
userAgent String? @map("user_agent") @db.Text
createdAt DateTime @default(now()) @map("created_at")
// Relationen
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
@@index([userId])
@@index([action])
@@index([entity, entityId])
@@index([createdAt])
@@map("audit_logs")
}
// --------------------------------------------------------
// ExpertProfile - Experten-Profil (1:1 mit User)
// --------------------------------------------------------
model ExpertProfile {
id String @id @default(uuid()) @db.Uuid
userId String @unique @map("user_id") @db.Uuid
// Skills als Tag-Array
skills String[] @default([])
// Timestamps
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
// Relationen
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
experiences ExpertExperience[]
languages ExpertLanguage[]
projects ExpertProject[]
certifications ExpertCertification[]
attachments ExpertAttachment[]
@@map("expert_profiles")
}
// --------------------------------------------------------
// ExpertExperience - Erfahrung / Expertise-Bereiche
// --------------------------------------------------------
model ExpertExperience {
id String @id @default(uuid()) @db.Uuid
expertProfileId String @map("expert_profile_id") @db.Uuid
area String @db.VarChar(200) // z.B. "IT Infrastruktur"
years Int // Jahre Erfahrung
level String? @db.VarChar(50) // Experte, Fortgeschritten, Grundkenntnisse
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
// Relationen
expertProfile ExpertProfile @relation(fields: [expertProfileId], references: [id], onDelete: Cascade)
@@map("expert_experiences")
}
// --------------------------------------------------------
// ExpertLanguage - Sprachen
// --------------------------------------------------------
model ExpertLanguage {
id String @id @default(uuid()) @db.Uuid
expertProfileId String @map("expert_profile_id") @db.Uuid
language String @db.VarChar(100) // z.B. "Deutsch"
level String @db.VarChar(20) // Muttersprache, C2, C1, B2, B1, A2, A1
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
// Relationen
expertProfile ExpertProfile @relation(fields: [expertProfileId], references: [id], onDelete: Cascade)
@@map("expert_languages")
}
// --------------------------------------------------------
// ExpertProject - Projekthistorie
// --------------------------------------------------------
model ExpertProject {
id String @id @default(uuid()) @db.Uuid
expertProfileId String @map("expert_profile_id") @db.Uuid
// Zeitraum
fromMonth Int @map("from_month") // 1-12
fromYear Int @map("from_year") // z.B. 2023
toMonth Int? @map("to_month") // null wenn isCurrent
toYear Int? @map("to_year")
isCurrent Boolean @default(false) @map("is_current")
// Details
role String @db.VarChar(200) // Taetigkeit
tasks String? @db.Text // Aufgaben (max 1500 Zeichen im DTO)
company String? @db.VarChar(200) // Firma
companySize String? @map("company_size") @db.VarChar(20) // "1-10", "11-50", etc.
industry String? @db.VarChar(200) // Branche
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
// Relationen
expertProfile ExpertProfile @relation(fields: [expertProfileId], references: [id], onDelete: Cascade)
@@index([expertProfileId, fromYear, fromMonth])
@@map("expert_projects")
}
// --------------------------------------------------------
// ExpertCertification - Zertifizierungen
// --------------------------------------------------------
model ExpertCertification {
id String @id @default(uuid()) @db.Uuid
expertProfileId String @map("expert_profile_id") @db.Uuid
title String @db.VarChar(300) // Titel
issuingBody String @map("issuing_body") @db.VarChar(300) // Zertifizierungsstelle
website String? @db.VarChar(500) // URL
issueYear Int @map("issue_year") // Ausstellungsjahr
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
// Relationen
expertProfile ExpertProfile @relation(fields: [expertProfileId], references: [id], onDelete: Cascade)
@@map("expert_certifications")
}
// --------------------------------------------------------
// ExpertAttachment - Profilanlagen (Dateien als Base64)
// --------------------------------------------------------
model ExpertAttachment {
id String @id @default(uuid()) @db.Uuid
expertProfileId String @map("expert_profile_id") @db.Uuid
filename String @db.VarChar(255)
mimetype String @db.VarChar(100)
size Int // Groesse in Bytes
data String @db.Text // Base64-Daten
createdAt DateTime @default(now()) @map("created_at")
// Relationen
expertProfile ExpertProfile @relation(fields: [expertProfileId], references: [id], onDelete: Cascade)
@@map("expert_attachments")
}

View file

@ -0,0 +1,146 @@
-- CreateTable
CREATE TABLE "users" (
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
"email" VARCHAR(255) NOT NULL,
"first_name" VARCHAR(100) NOT NULL,
"last_name" VARCHAR(100) NOT NULL,
"role" VARCHAR(50) NOT NULL DEFAULT 'USER',
"is_active" BOOLEAN NOT NULL DEFAULT true,
"two_factor_enabled" BOOLEAN NOT NULL DEFAULT false,
"last_login" TIMESTAMP(3),
"failed_login_attempts" INTEGER NOT NULL DEFAULT 0,
"last_failed_login" TIMESTAMP(3),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "users_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "auth_providers" (
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
"user_id" UUID NOT NULL,
"provider" VARCHAR(50) NOT NULL,
"provider_id" VARCHAR(255),
"password_hash" VARCHAR(255),
"totp_secret" VARCHAR(255),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "auth_providers_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "tenants" (
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
"name" VARCHAR(200) NOT NULL,
"slug" VARCHAR(50) NOT NULL,
"is_active" BOOLEAN NOT NULL DEFAULT true,
"settings" JSONB NOT NULL DEFAULT '{}',
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "tenants_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "tenant_memberships" (
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
"user_id" UUID NOT NULL,
"tenant_id" UUID NOT NULL,
"tenant_role" VARCHAR(50) NOT NULL DEFAULT 'MEMBER',
"is_active" BOOLEAN NOT NULL DEFAULT true,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "tenant_memberships_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "modules" (
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
"key" VARCHAR(50) NOT NULL,
"name" VARCHAR(100) NOT NULL,
"description" TEXT,
"version" VARCHAR(20) NOT NULL DEFAULT '1.0.0',
"is_active" BOOLEAN NOT NULL DEFAULT true,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "modules_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "tenant_modules" (
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
"tenant_id" UUID NOT NULL,
"module_id" UUID NOT NULL,
"is_active" BOOLEAN NOT NULL DEFAULT true,
"config" JSONB NOT NULL DEFAULT '{}',
"activated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "tenant_modules_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "audit_logs" (
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
"user_id" UUID,
"action" VARCHAR(100) NOT NULL,
"entity" VARCHAR(100) NOT NULL,
"entity_id" VARCHAR(255),
"details" JSONB,
"ip_address" VARCHAR(45),
"user_agent" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "audit_logs_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "users_email_key" ON "users"("email");
-- CreateIndex
CREATE UNIQUE INDEX "auth_providers_user_id_provider_key" ON "auth_providers"("user_id", "provider");
-- CreateIndex
CREATE UNIQUE INDEX "tenants_slug_key" ON "tenants"("slug");
-- CreateIndex
CREATE UNIQUE INDEX "tenant_memberships_user_id_tenant_id_key" ON "tenant_memberships"("user_id", "tenant_id");
-- CreateIndex
CREATE UNIQUE INDEX "modules_key_key" ON "modules"("key");
-- CreateIndex
CREATE UNIQUE INDEX "tenant_modules_tenant_id_module_id_key" ON "tenant_modules"("tenant_id", "module_id");
-- CreateIndex
CREATE INDEX "audit_logs_user_id_idx" ON "audit_logs"("user_id");
-- CreateIndex
CREATE INDEX "audit_logs_action_idx" ON "audit_logs"("action");
-- CreateIndex
CREATE INDEX "audit_logs_entity_entity_id_idx" ON "audit_logs"("entity", "entity_id");
-- CreateIndex
CREATE INDEX "audit_logs_created_at_idx" ON "audit_logs"("created_at");
-- AddForeignKey
ALTER TABLE "auth_providers" ADD CONSTRAINT "auth_providers_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "tenant_memberships" ADD CONSTRAINT "tenant_memberships_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "tenant_memberships" ADD CONSTRAINT "tenant_memberships_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "tenants"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "tenant_modules" ADD CONSTRAINT "tenant_modules_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "tenants"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "tenant_modules" ADD CONSTRAINT "tenant_modules_module_id_fkey" FOREIGN KEY ("module_id") REFERENCES "modules"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "audit_logs" ADD CONSTRAINT "audit_logs_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View file

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "users" ADD COLUMN "avatar" TEXT;

View file

@ -0,0 +1,6 @@
-- AlterTable: Kontakt- und Adressfelder für Benutzerprofil
ALTER TABLE "users" ADD COLUMN "phone" VARCHAR(30);
ALTER TABLE "users" ADD COLUMN "mobile" VARCHAR(30);
ALTER TABLE "users" ADD COLUMN "street" VARCHAR(200);
ALTER TABLE "users" ADD COLUMN "postal_code" VARCHAR(10);
ALTER TABLE "users" ADD COLUMN "city" VARCHAR(100);

View file

@ -0,0 +1,106 @@
-- CreateTable: expert_profiles
CREATE TABLE "expert_profiles" (
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
"user_id" UUID NOT NULL,
"skills" TEXT[] DEFAULT ARRAY[]::TEXT[],
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "expert_profiles_pkey" PRIMARY KEY ("id")
);
-- CreateTable: expert_experiences
CREATE TABLE "expert_experiences" (
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
"expert_profile_id" UUID NOT NULL,
"area" VARCHAR(200) NOT NULL,
"years" INTEGER NOT NULL,
"level" VARCHAR(50),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "expert_experiences_pkey" PRIMARY KEY ("id")
);
-- CreateTable: expert_languages
CREATE TABLE "expert_languages" (
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
"expert_profile_id" UUID NOT NULL,
"language" VARCHAR(100) NOT NULL,
"level" VARCHAR(20) NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "expert_languages_pkey" PRIMARY KEY ("id")
);
-- CreateTable: expert_projects
CREATE TABLE "expert_projects" (
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
"expert_profile_id" UUID NOT NULL,
"from_month" INTEGER NOT NULL,
"from_year" INTEGER NOT NULL,
"to_month" INTEGER,
"to_year" INTEGER,
"is_current" BOOLEAN NOT NULL DEFAULT false,
"role" VARCHAR(200) NOT NULL,
"tasks" TEXT,
"company" VARCHAR(200),
"company_size" VARCHAR(20),
"industry" VARCHAR(200),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "expert_projects_pkey" PRIMARY KEY ("id")
);
-- CreateTable: expert_certifications
CREATE TABLE "expert_certifications" (
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
"expert_profile_id" UUID NOT NULL,
"title" VARCHAR(300) NOT NULL,
"issuing_body" VARCHAR(300) NOT NULL,
"website" VARCHAR(500),
"issue_year" INTEGER NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "expert_certifications_pkey" PRIMARY KEY ("id")
);
-- CreateTable: expert_attachments
CREATE TABLE "expert_attachments" (
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
"expert_profile_id" UUID NOT NULL,
"filename" VARCHAR(255) NOT NULL,
"mimetype" VARCHAR(100) NOT NULL,
"size" INTEGER NOT NULL,
"data" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "expert_attachments_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "expert_profiles_user_id_key" ON "expert_profiles"("user_id");
-- CreateIndex
CREATE INDEX "expert_projects_expert_profile_id_from_year_from_month_idx" ON "expert_projects"("expert_profile_id", "from_year", "from_month");
-- AddForeignKey
ALTER TABLE "expert_profiles" ADD CONSTRAINT "expert_profiles_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "expert_experiences" ADD CONSTRAINT "expert_experiences_expert_profile_id_fkey" FOREIGN KEY ("expert_profile_id") REFERENCES "expert_profiles"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "expert_languages" ADD CONSTRAINT "expert_languages_expert_profile_id_fkey" FOREIGN KEY ("expert_profile_id") REFERENCES "expert_profiles"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "expert_projects" ADD CONSTRAINT "expert_projects_expert_profile_id_fkey" FOREIGN KEY ("expert_profile_id") REFERENCES "expert_profiles"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "expert_certifications" ADD CONSTRAINT "expert_certifications_expert_profile_id_fkey" FOREIGN KEY ("expert_profile_id") REFERENCES "expert_profiles"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "expert_attachments" ADD CONSTRAINT "expert_attachments_expert_profile_id_fkey" FOREIGN KEY ("expert_profile_id") REFERENCES "expert_profiles"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View file

@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "postgresql"

View file

@ -0,0 +1,65 @@
import { PrismaClient } from '@prisma/client';
import * as bcrypt from 'bcrypt';
/**
* Seed-Script: Erstellt den initialen Platform-Admin User.
*
* Ausfuehrung:
* npx ts-node prisma/seed.ts
*
* WICHTIG: Passwort nach erstem Login aendern!
*/
const prisma = new PrismaClient({
datasources: {
db: {
url: process.env.DATABASE_URL_DIRECT || process.env.DATABASE_URL,
},
},
});
async function main(): Promise<void> {
const email = 'admin@xinion.de';
// Pruefen ob Admin bereits existiert
const existing = await prisma.user.findUnique({ where: { email } });
if (existing) {
console.log(`Admin-User ${email} existiert bereits. Seed uebersprungen.`);
return;
}
// Passwort hashen (Bcrypt Cost 12 gemaess Sicherheitsregel)
const passwordHash = await bcrypt.hash('ChangeMe123!', 12);
// Admin-User anlegen
const user = await prisma.user.create({
data: {
email,
firstName: 'Platform',
lastName: 'Admin',
role: 'PLATFORM_ADMIN',
isActive: true,
authProvider: {
create: {
provider: 'LOCAL',
passwordHash,
},
},
},
});
console.log(`Platform-Admin erstellt: ${user.email} (ID: ${user.id})`);
console.log('');
console.log('Zugangsdaten:');
console.log(` E-Mail: ${email}`);
console.log(' Passwort: ChangeMe123!');
console.log('');
console.log('WICHTIG: Passwort nach erstem Login aendern!');
}
main()
.catch((e: Error) => {
console.error('Seed fehlgeschlagen:', e.message);
process.exit(1);
})
.finally(() => prisma.$disconnect());

View file

@ -0,0 +1,111 @@
// ============================================================
// INSIGHT MVP - Tenant Schema (tenant_{slug} Datenbanken)
// ============================================================
// Jeder Mandant hat eine eigene Datenbank mit diesen Tabellen.
// Schema wird per Prisma Migrate auf neue Tenant-DBs angewandt.
//
// HINWEIS: Dieses Schema wird derzeit als Referenz gefuehrt.
// Die tatsaechliche Migration auf Tenant-DBs erfolgt
// in Sprint 2+ wenn das CRM-Modul implementiert wird.
// ============================================================
generator client {
provider = "prisma-client-js"
output = "../node_modules/.prisma/tenant-client"
}
datasource db {
provider = "postgresql"
url = env("TENANT_DATABASE_URL")
}
// --------------------------------------------------------
// Contact - CRM-Kontakte (Personen & Organisationen)
// --------------------------------------------------------
model Contact {
id String @id @default(uuid()) @db.Uuid
type ContactType @default(PERSON)
// Person
firstName String? @map("first_name") @db.VarChar(100)
lastName String? @map("last_name") @db.VarChar(100)
// Organisation
companyName String? @map("company_name") @db.VarChar(200)
// Kontaktdaten
email String? @db.VarChar(255)
phone String? @db.VarChar(50)
mobile String? @db.VarChar(50)
website String? @db.VarChar(500)
// Adresse
street String? @db.VarChar(200)
zip String? @db.VarChar(20)
city String? @db.VarChar(100)
state String? @db.VarChar(100)
country String? @default("DE") @db.VarChar(2)
// Zusaetzlich
notes String? @db.Text
tags String[] @default([])
isActive Boolean @default(true) @map("is_active")
// Wer hat erstellt/bearbeitet (User-IDs aus platform_core)
createdBy String @map("created_by") @db.Uuid
updatedBy String? @map("updated_by") @db.Uuid
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
// Relationen
activities Activity[]
@@index([email])
@@index([companyName])
@@index([lastName, firstName])
@@map("contacts")
}
enum ContactType {
PERSON
ORGANIZATION
}
// --------------------------------------------------------
// Activity - CRM-Aktivitaeten (Notizen, Anrufe, E-Mails)
// --------------------------------------------------------
model Activity {
id String @id @default(uuid()) @db.Uuid
contactId String @map("contact_id") @db.Uuid
type ActivityType
subject String @db.VarChar(500)
description String? @db.Text
// Terminierung
scheduledAt DateTime? @map("scheduled_at")
completedAt DateTime? @map("completed_at")
// Wer hat erstellt (User-ID aus platform_core)
createdBy String @map("created_by") @db.Uuid
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
// Relationen
contact Contact @relation(fields: [contactId], references: [id], onDelete: Cascade)
@@index([contactId])
@@index([type])
@@index([scheduledAt])
@@map("activities")
}
enum ActivityType {
NOTE
CALL
EMAIL
MEETING
TASK
}

View file

@ -0,0 +1,61 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { APP_GUARD } from '@nestjs/core';
import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler';
import { ScheduleModule } from '@nestjs/schedule';
import { HealthModule } from './health/health.module';
import { PrismaModule } from './prisma/prisma.module';
import { RedisModule } from './redis/redis.module';
import { AuthModule } from './core/auth/auth.module';
import { UsersModule } from './core/users/users.module';
import { TenantsModule } from './core/tenants/tenants.module';
import { ExpertProfileModule } from './core/expert-profile/expert-profile.module';
import { SettingsModule } from './core/settings/settings.module';
import { JwtAuthGuard } from './common/guards/jwt-auth.guard';
import { validateConfig } from './config/env.validation';
@Module({
imports: [
// Konfiguration (.env)
ConfigModule.forRoot({
isGlobal: true,
validate: validateConfig,
}),
// Rate Limiting
ThrottlerModule.forRoot([
{
ttl: parseInt(process.env.THROTTLE_TTL ?? '60000', 10),
limit: parseInt(process.env.THROTTLE_LIMIT ?? '200', 10),
},
]),
// Cron-Jobs
ScheduleModule.forRoot(),
// Infrastruktur-Module
PrismaModule,
RedisModule,
// Feature-Module
HealthModule,
AuthModule,
UsersModule,
TenantsModule,
ExpertProfileModule,
SettingsModule,
],
providers: [
// Global Guards: Alle Routen sind standardmaessig geschuetzt
// Oeffentliche Routen muessen mit @Public() dekoriert werden
{
provide: APP_GUARD,
useClass: JwtAuthGuard,
},
{
provide: APP_GUARD,
useClass: ThrottlerGuard,
},
],
})
export class AppModule {}

View file

@ -0,0 +1,35 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import { Request } from 'express';
export interface JwtPayload {
sub: string; // User-ID
email: string;
role: string;
tenantId?: string;
tenantSlug?: string;
jti: string; // Token-ID fuer Revocation
iat: number;
exp: number;
}
/**
* @CurrentUser() - Extrahiert den authentifizierten User aus dem Request.
*
* Beispiel:
* @Get('profile')
* getProfile(@CurrentUser() user: JwtPayload) {
* return user;
* }
*
* @Get('profile/id')
* getProfileId(@CurrentUser('sub') userId: string) {
* return userId;
* }
*/
export const CurrentUser = createParamDecorator(
(data: keyof JwtPayload | undefined, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest<Request>();
const user = request.user as JwtPayload;
return data ? user?.[data] : user;
},
);

View file

@ -0,0 +1,3 @@
export { Public, IS_PUBLIC_KEY } from './public.decorator';
export { Roles, ROLES_KEY } from './roles.decorator';
export { CurrentUser, type JwtPayload } from './current-user.decorator';

View file

@ -0,0 +1,16 @@
import { SetMetadata } from '@nestjs/common';
export const IS_PUBLIC_KEY = 'isPublic';
/**
* @Public() - Markiert eine Route als oeffentlich zugaenglich.
*
* Standardmaessig sind ALLE Routen durch den JwtAuthGuard geschuetzt.
* Nur explizit mit @Public() dekorierte Routen sind ohne Token erreichbar.
*
* Beispiel:
* @Get('health')
* @Public()
* healthCheck() { ... }
*/
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);

View file

@ -0,0 +1,13 @@
import { SetMetadata } from '@nestjs/common';
export const ROLES_KEY = 'roles';
/**
* @Roles() - Beschraenkt den Zugriff auf bestimmte Plattform-Rollen.
*
* Beispiel:
* @Roles('PLATFORM_ADMIN', 'TENANT_ADMIN')
* @Get('admin/users')
* listUsers() { ... }
*/
export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles);

View file

@ -0,0 +1,88 @@
import {
ExceptionFilter,
Catch,
ArgumentsHost,
HttpException,
HttpStatus,
Logger,
} from '@nestjs/common';
import { Request, Response } from 'express';
interface ErrorResponse {
statusCode: number;
message: string;
error: string;
timestamp: string;
path: string;
requestId?: string;
}
/**
* GlobalExceptionFilter - Faengt alle unbehandelten Exceptions.
*
* - Strukturierte Fehlerantworten im JSON-Format
* - Logging aller Fehler
* - Keine internen Details in Produktions-Fehlern
*/
@Catch()
export class GlobalExceptionFilter implements ExceptionFilter {
private readonly logger = new Logger(GlobalExceptionFilter.name);
catch(exception: unknown, host: ArgumentsHost): void {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
let statusCode: number;
let message: string;
let error: string;
if (exception instanceof HttpException) {
statusCode = exception.getStatus();
const exceptionResponse = exception.getResponse();
if (typeof exceptionResponse === 'string') {
message = exceptionResponse;
error = exception.name;
} else if (typeof exceptionResponse === 'object') {
const resp = exceptionResponse as Record<string, unknown>;
message = Array.isArray(resp.message)
? resp.message.join(', ')
: (resp.message as string) || exception.message;
error = (resp.error as string) || exception.name;
} else {
message = exception.message;
error = exception.name;
}
} else if (exception instanceof Error) {
statusCode = HttpStatus.INTERNAL_SERVER_ERROR;
message =
process.env.NODE_ENV === 'production'
? 'Interner Serverfehler'
: exception.message;
error = 'InternalServerError';
// Stack-Trace loggen fuer unerwartete Fehler
this.logger.error(
`Unbehandelter Fehler: ${exception.message}`,
exception.stack,
);
} else {
statusCode = HttpStatus.INTERNAL_SERVER_ERROR;
message = 'Unbekannter Fehler';
error = 'UnknownError';
this.logger.error('Unbekannter Fehler:', exception);
}
const errorResponse: ErrorResponse = {
statusCode,
message,
error,
timestamp: new Date().toISOString(),
path: request.url,
requestId: request.headers['x-request-id'] as string | undefined,
};
response.status(statusCode).json(errorResponse);
}
}

View file

@ -0,0 +1,65 @@
import {
Injectable,
ExecutionContext,
UnauthorizedException,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
import { RedisService } from '../../redis/redis.service';
import { JwtPayload } from '../decorators/current-user.decorator';
/**
* JwtAuthGuard - Globaler Guard fuer JWT-Authentifizierung.
*
* - Standardmaessig aktiv auf ALLEN Routen
* - @Public() dekorierte Routen werden uebersprungen
* - Prueft zusaetzlich ob der Token revoked wurde (Redis Blocklist)
*/
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
constructor(
private readonly reflector: Reflector,
private readonly redis: RedisService,
) {
super();
}
async canActivate(context: ExecutionContext): Promise<boolean> {
// @Public() Routen ueberspringen
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) {
return true;
}
// JWT validieren (Passport Strategy)
const canActivate = await super.canActivate(context);
if (!canActivate) {
return false;
}
// Token-Revocation pruefen (Redis Blocklist)
const request = context.switchToHttp().getRequest();
const user = request.user as JwtPayload;
if (user?.jti) {
const isBlocked = await this.redis.isTokenBlocked(user.jti);
if (isBlocked) {
throw new UnauthorizedException('Token wurde widerrufen');
}
}
return true;
}
handleRequest<T>(err: Error | null, user: T, info: Error | undefined): T {
if (err || !user) {
throw err || new UnauthorizedException('Zugriff verweigert');
}
return user;
}
}

View file

@ -0,0 +1,37 @@
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { ROLES_KEY } from '../decorators/roles.decorator';
import { JwtPayload } from '../decorators/current-user.decorator';
/**
* RolesGuard - Prueft ob der User die erforderliche Rolle hat.
*
* Wird zusammen mit @Roles() verwendet:
* @Roles('PLATFORM_ADMIN')
* @UseGuards(RolesGuard)
* @Get('admin/dashboard')
*/
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const requiredRoles = this.reflector.getAllAndOverride<string[]>(
ROLES_KEY,
[context.getHandler(), context.getClass()],
);
if (!requiredRoles || requiredRoles.length === 0) {
return true;
}
const request = context.switchToHttp().getRequest();
const user = request.user as JwtPayload;
if (!user?.role) {
return false;
}
return requiredRoles.includes(user.role);
}
}

View file

@ -0,0 +1,120 @@
import { plainToInstance, Type } from 'class-transformer';
import {
IsEnum,
IsNotEmpty,
IsNumber,
IsOptional,
IsString,
Min,
Max,
validateSync,
} from 'class-validator';
enum Environment {
Development = 'development',
Production = 'production',
Test = 'test',
}
class EnvironmentVariables {
@IsEnum(Environment)
NODE_ENV: Environment = Environment.Development;
@Type(() => Number)
@IsNumber()
@Min(1)
@Max(65535)
APP_PORT = 3000;
@IsString()
@IsNotEmpty()
APP_URL = 'http://172.20.10.59';
// Datenbank
@IsString()
@IsNotEmpty()
DATABASE_URL!: string;
@IsString()
@IsOptional()
DATABASE_URL_DIRECT?: string;
// Redis
@IsString()
REDIS_HOST = 'redis';
@Type(() => Number)
@IsNumber()
REDIS_PORT = 6379;
@IsString()
@IsOptional()
REDIS_PASSWORD?: string;
// JWT
@IsString()
@IsNotEmpty()
JWT_PRIVATE_KEY_PATH = '/app/keys/jwt-private.pem';
@IsString()
@IsNotEmpty()
JWT_PUBLIC_KEY_PATH = '/app/keys/jwt-public.pem';
@IsString()
JWT_ACCESS_TOKEN_EXPIRY = '15m';
@IsString()
JWT_REFRESH_TOKEN_EXPIRY = '7d';
@IsString()
JWT_ISSUER = 'insight-platform';
// Bcrypt
@Type(() => Number)
@IsNumber()
@Min(10)
@Max(14)
BCRYPT_COST = 12;
// Rate Limiting
@Type(() => Number)
@IsNumber()
THROTTLE_TTL = 60000;
@Type(() => Number)
@IsNumber()
THROTTLE_LIMIT = 200;
// Microsoft Entra ID (Azure AD) SSO - optional
@IsOptional()
@IsString()
AZURE_TENANT_ID?: string;
@IsOptional()
@IsString()
AZURE_CLIENT_ID?: string;
@IsOptional()
@IsString()
AZURE_CLIENT_SECRET?: string;
@IsOptional()
@IsString()
AZURE_REDIRECT_URI?: string;
}
export function validateConfig(
config: Record<string, unknown>,
): EnvironmentVariables {
const validatedConfig = plainToInstance(EnvironmentVariables, config, {
enableImplicitConversion: true,
});
const errors = validateSync(validatedConfig, {
skipMissingProperties: false,
});
if (errors.length > 0) {
throw new Error(`Config validation error: ${errors.toString()}`);
}
return validatedConfig;
}

View file

@ -0,0 +1,171 @@
import {
Controller,
Post,
Body,
Res,
Req,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { Request, Response } from 'express';
import { Public } from '../../common/decorators/public.decorator';
import { CurrentUser, JwtPayload } from '../../common/decorators/current-user.decorator';
import { AuthService } from './auth.service';
import { LoginDto } from './dto/login.dto';
import { Enable2faDto } from './dto/enable-2fa.dto';
import { Disable2faDto } from './dto/disable-2fa.dto';
@ApiTags('Authentifizierung')
@Controller('auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}
/**
* POST /api/v1/auth/login
* Login mit E-Mail + Passwort (+ optionaler TOTP-Code).
*/
@Post('login')
@Public()
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Login mit E-Mail und Passwort' })
async login(
@Body() dto: LoginDto,
@Res({ passthrough: true }) res: Response,
) {
const result = await this.authService.login(dto);
// 2FA erforderlich - kein Token setzen
if (result.requiresTwoFactor) {
return {
requiresTwoFactor: true,
message: 'Bitte 2FA-Code eingeben',
};
}
// Refresh-Token als HttpOnly Cookie setzen (NICHT im localStorage!)
// Regel: Kein localStorage fuer Tokens
this.setRefreshTokenCookie(res, result.refreshToken!);
return {
accessToken: result.accessToken,
user: result.user,
};
}
/**
* POST /api/v1/auth/refresh
* Token-Refresh via HttpOnly Cookie.
*/
@Post('refresh')
@Public()
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Access-Token erneuern (Silent Refresh)' })
async refresh(
@Req() req: Request,
@Res({ passthrough: true }) res: Response,
) {
const refreshToken = req.cookies?.refresh_token as string | undefined;
if (!refreshToken) {
res.status(HttpStatus.UNAUTHORIZED).json({
message: 'Kein Refresh-Token vorhanden',
});
return;
}
const tokens = await this.authService.refreshTokens(refreshToken);
this.setRefreshTokenCookie(res, tokens.refreshToken);
return {
accessToken: tokens.accessToken,
};
}
/**
* POST /api/v1/auth/logout
* Logout: Tokens invalidieren, Cookie loeschen.
*/
@Post('logout')
@HttpCode(HttpStatus.OK)
@ApiBearerAuth('access-token')
@ApiOperation({ summary: 'Logout und Token-Invalidierung' })
async logout(
@CurrentUser() user: JwtPayload,
@Req() req: Request,
@Res({ passthrough: true }) res: Response,
) {
const refreshToken = req.cookies?.refresh_token as string | undefined;
await this.authService.logout(user, refreshToken);
// Refresh-Token Cookie loeschen
const isProduction = process.env.NODE_ENV === 'production';
res.clearCookie('refresh_token', {
httpOnly: true,
secure: isProduction,
sameSite: isProduction ? 'strict' : 'lax',
path: '/api/v1/auth',
});
return { message: 'Erfolgreich abgemeldet' };
}
/**
* POST /api/v1/auth/2fa/setup
* 2FA-Setup starten: Secret + QR-Code generieren.
*/
@Post('2fa/setup')
@HttpCode(HttpStatus.OK)
@ApiBearerAuth('access-token')
@ApiOperation({ summary: '2FA-Setup starten (QR-Code generieren)' })
async setup2fa(@CurrentUser('sub') userId: string) {
return this.authService.setup2fa(userId);
}
/**
* POST /api/v1/auth/2fa/enable
* 2FA aktivieren: TOTP-Code verifizieren.
*/
@Post('2fa/enable')
@HttpCode(HttpStatus.OK)
@ApiBearerAuth('access-token')
@ApiOperation({ summary: '2FA aktivieren (Code verifizieren)' })
async enable2fa(
@CurrentUser('sub') userId: string,
@Body() dto: Enable2faDto,
) {
await this.authService.enable2fa(userId, dto.totpCode);
return { message: '2FA wurde erfolgreich aktiviert' };
}
/**
* POST /api/v1/auth/2fa/disable
* 2FA deaktivieren (mit Passwort-Bestaetigung).
*/
@Post('2fa/disable')
@HttpCode(HttpStatus.OK)
@ApiBearerAuth('access-token')
@ApiOperation({ summary: '2FA deaktivieren (Passwort erforderlich)' })
async disable2fa(
@CurrentUser('sub') userId: string,
@Body() dto: Disable2faDto,
) {
await this.authService.disable2fa(userId, dto.password);
return { message: '2FA wurde erfolgreich deaktiviert' };
}
/**
* Setzt das Refresh-Token als HttpOnly Cookie.
* Secure + SameSite=Strict nur in Produktion (HTTPS).
* In Development (HTTP) wird Secure deaktiviert und SameSite=Lax gesetzt.
*/
private setRefreshTokenCookie(res: Response, refreshToken: string): void {
const isProduction = process.env.NODE_ENV === 'production';
res.cookie('refresh_token', refreshToken, {
httpOnly: true,
secure: isProduction,
sameSite: isProduction ? 'strict' : 'lax',
path: '/api/v1/auth',
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 Tage
});
}
}

View file

@ -0,0 +1,49 @@
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { ConfigModule, ConfigService } from '@nestjs/config';
import * as fs from 'fs';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { JwtStrategy } from './strategies/jwt.strategy';
import { TotpService } from './totp.service';
import { EntraIdService } from './sso/entra-id.service';
import { SsoController } from './sso/sso.controller';
@Module({
imports: [
PassportModule.register({ defaultStrategy: 'jwt' }),
JwtModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => {
const privateKeyPath = config.get<string>(
'JWT_PRIVATE_KEY_PATH',
'/app/keys/jwt-private.pem',
);
const publicKeyPath = config.get<string>(
'JWT_PUBLIC_KEY_PATH',
'/app/keys/jwt-public.pem',
);
return {
privateKey: fs.readFileSync(privateKeyPath, 'utf8'),
publicKey: fs.readFileSync(publicKeyPath, 'utf8'),
signOptions: {
algorithm: 'RS256',
issuer: config.get<string>('JWT_ISSUER', 'insight-platform'),
expiresIn: config.get<string>('JWT_ACCESS_TOKEN_EXPIRY', '15m'),
},
verifyOptions: {
algorithms: ['RS256'],
issuer: config.get<string>('JWT_ISSUER', 'insight-platform'),
},
};
},
}),
],
controllers: [AuthController, SsoController],
providers: [AuthService, JwtStrategy, TotpService, EntraIdService],
exports: [AuthService, JwtModule],
})
export class AuthModule {}

View file

@ -0,0 +1,527 @@
import {
Injectable,
UnauthorizedException,
ForbiddenException,
Logger,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import * as bcrypt from 'bcrypt';
import { v4 as uuidv4 } from 'uuid';
import { PrismaService } from '../../prisma/prisma.service';
import { RedisService } from '../../redis/redis.service';
import { TotpService } from './totp.service';
import { LoginDto } from './dto/login.dto';
import { JwtPayload } from '../../common/decorators/current-user.decorator';
interface TokenPair {
accessToken: string;
refreshToken: string;
}
interface LoginResponse {
accessToken: string;
refreshToken?: string;
user: {
id: string;
email: string;
firstName: string;
lastName: string;
role: string;
twoFactorEnabled: boolean;
};
requiresTwoFactor?: boolean;
}
@Injectable()
export class AuthService {
private readonly logger = new Logger(AuthService.name);
constructor(
private readonly prisma: PrismaService,
private readonly jwt: JwtService,
private readonly redis: RedisService,
private readonly config: ConfigService,
private readonly totp: TotpService,
) {}
/**
* Login mit E-Mail und Passwort.
* Gibt AccessToken + Refresh-Token (HttpOnly Cookie) zurueck.
*/
async login(dto: LoginDto): Promise<LoginResponse> {
// User finden
const user = await this.prisma.user.findUnique({
where: { email: dto.email.toLowerCase() },
include: {
authProvider: true,
tenantMemberships: {
include: { tenant: true },
where: { isActive: true },
take: 1,
},
},
});
if (!user || !user.isActive) {
// Generische Fehlermeldung (kein Hinweis ob User existiert)
throw new UnauthorizedException('Ungültige Anmeldedaten');
}
// Passwort pruefen (nur fuer lokale Auth)
const localAuth = user.authProvider.find((ap) => ap.provider === 'LOCAL');
if (!localAuth?.passwordHash) {
throw new UnauthorizedException('Ungültige Anmeldedaten');
}
const passwordValid = await bcrypt.compare(
dto.password,
localAuth.passwordHash,
);
if (!passwordValid) {
// Failed Login zaehlen
await this.prisma.user.update({
where: { id: user.id },
data: {
failedLoginAttempts: { increment: 1 },
lastFailedLogin: new Date(),
},
});
throw new UnauthorizedException('Ungültige Anmeldedaten');
}
// Account-Sperre pruefen (nach 5 Fehlversuchen)
if (user.failedLoginAttempts >= 5) {
const lockoutEnd = user.lastFailedLogin
? new Date(user.lastFailedLogin.getTime() + 15 * 60 * 1000) // 15 Min Sperre
: null;
if (lockoutEnd && lockoutEnd > new Date()) {
throw new ForbiddenException(
'Account temporaer gesperrt. Versuchen Sie es in 15 Minuten erneut.',
);
}
}
// 2FA pruefen
if (user.twoFactorEnabled) {
if (!dto.totpCode) {
return {
accessToken: '',
user: {
id: user.id,
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
role: user.role,
twoFactorEnabled: user.twoFactorEnabled,
},
requiresTwoFactor: true,
};
}
const totpValid = this.totp.verify(
dto.totpCode,
localAuth.totpSecret ?? '',
);
if (!totpValid) {
throw new UnauthorizedException('Ungültiger 2FA-Code');
}
}
// Erfolgreicher Login: Counter zurücksetzen
await this.prisma.user.update({
where: { id: user.id },
data: {
failedLoginAttempts: 0,
lastLogin: new Date(),
},
});
// Tenant-Info
const primaryMembership = user.tenantMemberships[0];
// Tokens generieren
const tokens = await this.generateTokenPair({
sub: user.id,
email: user.email,
role: user.role,
tenantId: primaryMembership?.tenant.id,
tenantSlug: primaryMembership?.tenant.slug,
});
this.logger.log(`Login erfolgreich: ${user.email}`);
return {
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken,
user: {
id: user.id,
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
role: user.role,
twoFactorEnabled: user.twoFactorEnabled,
},
};
}
/**
* Refresh-Token gegen neues Token-Paar tauschen.
*/
async refreshTokens(refreshToken: string): Promise<TokenPair> {
try {
const payload = this.jwt.verify<JwtPayload>(refreshToken);
// Refresh-Token-Familie pruefen (Token-Rotation)
const isValid = await this.redis.isRefreshTokenFamilyValid(
payload.sub,
payload.jti,
);
if (!isValid) {
// Möglicherweise Refresh-Token-Diebstahl: alle invalidieren
this.logger.warn(
`Verdächtiger Refresh-Token Wiederverwendung für User ${payload.sub}`,
);
await this.redis.invalidateAllRefreshTokens(payload.sub);
throw new UnauthorizedException('Refresh Token ungültig');
}
// Alten Refresh-Token invalidieren
await this.redis.blockToken(payload.jti, 7 * 24 * 60 * 60);
// Neue Tokens generieren
return this.generateTokenPair({
sub: payload.sub,
email: payload.email,
role: payload.role,
tenantId: payload.tenantId,
tenantSlug: payload.tenantSlug,
});
} catch (error) {
if (error instanceof UnauthorizedException) throw error;
throw new UnauthorizedException('Refresh Token ungültig oder abgelaufen');
}
}
/**
* Logout: Access- und Refresh-Token invalidieren.
*/
async logout(accessToken: JwtPayload, refreshToken?: string): Promise<void> {
// Access-Token blocken (Restlaufzeit)
const ttl = accessToken.exp - Math.floor(Date.now() / 1000);
if (ttl > 0) {
await this.redis.blockToken(accessToken.jti, ttl);
}
// Refresh-Token blocken
if (refreshToken) {
try {
const refreshPayload = this.jwt.verify<JwtPayload>(refreshToken);
const refreshTtl =
refreshPayload.exp - Math.floor(Date.now() / 1000);
if (refreshTtl > 0) {
await this.redis.blockToken(refreshPayload.jti, refreshTtl);
}
} catch {
// Refresh-Token ist bereits abgelaufen - ignorieren
}
}
this.logger.log(`Logout: User ${accessToken.sub}`);
}
/**
* 2FA-Setup: Neues TOTP-Secret generieren und QR-Code zurueckgeben.
* Secret wird temporaer in Redis gespeichert (5 Minuten TTL).
* Erst nach Verifizierung wird das Secret permanent in der DB gespeichert.
*/
async setup2fa(userId: string): Promise<{ qrCode: string; secret: string }> {
const user = await this.prisma.user.findUnique({
where: { id: userId },
});
if (!user) {
throw new UnauthorizedException('Benutzer nicht gefunden');
}
if (user.twoFactorEnabled) {
throw new ForbiddenException('2FA ist bereits aktiviert');
}
const secret = this.totp.generateSecret();
const qrCode = await this.totp.generateQrCode(user.email, secret);
// Secret temporaer in Redis speichern (5 Minuten zum Einrichten)
await this.redis.set(`2fa_setup:${userId}`, secret, 300);
this.logger.log(`2FA-Setup gestartet fuer User ${user.email}`);
return { qrCode, secret };
}
/**
* 2FA aktivieren: TOTP-Code verifizieren und Secret permanent speichern.
*/
async enable2fa(userId: string, totpCode: string): Promise<void> {
// Temporaeres Secret aus Redis holen
const secret = await this.redis.get(`2fa_setup:${userId}`);
if (!secret) {
throw new ForbiddenException(
'2FA-Setup abgelaufen. Bitte erneut starten.',
);
}
// TOTP-Code prüfen
const isValid = this.totp.verify(totpCode, secret);
if (!isValid) {
throw new UnauthorizedException('Ungültiger 2FA-Code');
}
// Secret permanent in AuthProvider speichern + 2FA aktivieren
await this.prisma.$transaction([
this.prisma.authProvider.updateMany({
where: { userId, provider: 'LOCAL' },
data: { totpSecret: secret },
}),
this.prisma.user.update({
where: { id: userId },
data: { twoFactorEnabled: true },
}),
]);
// Temporäres Secret aus Redis löschen
await this.redis.del(`2fa_setup:${userId}`);
this.logger.log(`2FA aktiviert für User ${userId}`);
}
/**
* 2FA deaktivieren: Passwort-Verifikation erforderlich.
*/
async disable2fa(userId: string, password: string): Promise<void> {
const user = await this.prisma.user.findUnique({
where: { id: userId },
include: { authProvider: true },
});
if (!user) {
throw new UnauthorizedException('Benutzer nicht gefunden');
}
if (!user.twoFactorEnabled) {
throw new ForbiddenException('2FA ist nicht aktiviert');
}
// Passwort prüfen
const localAuth = user.authProvider.find((ap) => ap.provider === 'LOCAL');
if (!localAuth?.passwordHash) {
throw new UnauthorizedException('Kein lokaler Auth-Provider gefunden');
}
const passwordValid = await bcrypt.compare(password, localAuth.passwordHash);
if (!passwordValid) {
throw new UnauthorizedException('Ungültiges Passwort');
}
// 2FA deaktivieren + Secret löschen
await this.prisma.$transaction([
this.prisma.authProvider.updateMany({
where: { userId, provider: 'LOCAL' },
data: { totpSecret: null },
}),
this.prisma.user.update({
where: { id: userId },
data: { twoFactorEnabled: false },
}),
]);
this.logger.log(`2FA deaktiviert für User ${userId}`);
}
/**
* SSO-Login: User via Microsoft Entra ID (Azure AD) anmelden.
*
* Logik:
* 1. User via AuthProvider(MS_SSO, providerId=oid) suchen
* 2. Falls nicht gefunden: User via E-Mail suchen
* Falls gefunden: MS_SSO AuthProvider verknuepfen (Auto-Link)
* Falls nicht gefunden: Neuen User + MS_SSO AuthProvider anlegen
* 3. User muss isActive sein
* 4. JWT-Tokens generieren (wie bei normalem Login)
*/
async loginViaSso(msUser: {
oid: string;
email: string;
firstName: string;
lastName: string;
}): Promise<LoginResponse & { refreshToken: string }> {
// 1. Bestehenden MS_SSO AuthProvider suchen (Provider ID = MS Object ID)
const existingAuth = await this.prisma.authProvider.findFirst({
where: {
provider: 'MS_SSO',
providerId: msUser.oid,
},
include: {
user: {
include: {
tenantMemberships: {
include: { tenant: true },
where: { isActive: true },
take: 1,
},
},
},
},
});
let user: {
id: string;
email: string;
firstName: string;
lastName: string;
role: string;
twoFactorEnabled: boolean;
tenantMemberships?: Array<{
tenant: { id: string; slug: string };
}>;
};
if (existingAuth) {
// Bekannter SSO-User
user = existingAuth.user;
this.logger.log(`SSO Login: Bekannter User ${user.email}`);
} else {
// 2. User via E-Mail suchen
const existingUser = await this.prisma.user.findUnique({
where: { email: msUser.email },
include: {
authProvider: true,
tenantMemberships: {
include: { tenant: true },
where: { isActive: true },
take: 1,
},
},
});
if (existingUser) {
// MS_SSO AuthProvider verknuepfen (Auto-Link)
await this.prisma.authProvider.create({
data: {
userId: existingUser.id,
provider: 'MS_SSO',
providerId: msUser.oid,
},
});
user = existingUser;
this.logger.log(
`SSO Auto-Link: MS_SSO Provider fuer ${user.email} verknuepft`,
);
} else {
// 3. Neuen User + MS_SSO AuthProvider anlegen
const newUser = await this.prisma.user.create({
data: {
email: msUser.email,
firstName: msUser.firstName || 'SSO',
lastName: msUser.lastName || 'User',
role: 'USER',
isActive: true,
authProvider: {
create: {
provider: 'MS_SSO',
providerId: msUser.oid,
},
},
},
include: {
tenantMemberships: {
include: { tenant: true },
where: { isActive: true },
take: 1,
},
},
});
user = newUser;
this.logger.log(`SSO Neuer User angelegt: ${user.email}`);
}
}
// User muss aktiv sein
if (!('isActive' in user) || !(user as { isActive: boolean }).isActive) {
throw new UnauthorizedException(
'Ihr Benutzerkonto ist deaktiviert. Bitte wenden Sie sich an den Administrator.',
);
}
// lastLogin aktualisieren
await this.prisma.user.update({
where: { id: user.id },
data: {
lastLogin: new Date(),
failedLoginAttempts: 0,
},
});
// Tenant-Info
const primaryMembership = user.tenantMemberships?.[0];
// Tokens generieren
const tokens = await this.generateTokenPair({
sub: user.id,
email: user.email,
role: user.role,
tenantId: primaryMembership?.tenant.id,
tenantSlug: primaryMembership?.tenant.slug,
});
return {
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken,
user: {
id: user.id,
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
role: user.role,
twoFactorEnabled: user.twoFactorEnabled,
},
};
}
/**
* Token-Paar generieren (Access + Refresh).
*/
private async generateTokenPair(
payload: Omit<JwtPayload, 'jti' | 'iat' | 'exp'>,
): Promise<TokenPair> {
const accessJti = uuidv4();
const refreshJti = uuidv4();
const accessToken = this.jwt.sign({
...payload,
jti: accessJti,
});
const refreshExpiry = this.config.get<string>(
'JWT_REFRESH_TOKEN_EXPIRY',
'7d',
);
const refreshToken = this.jwt.sign(
{
...payload,
jti: refreshJti,
},
{ expiresIn: refreshExpiry },
);
// Refresh-Token-Familie in Redis registrieren
await this.redis.setRefreshTokenFamily(
payload.sub,
refreshJti,
7 * 24 * 60 * 60, // 7 Tage
);
return { accessToken, refreshToken };
}
}

View file

@ -0,0 +1,13 @@
import { IsNotEmpty, IsString, MinLength } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class Disable2faDto {
@ApiProperty({
example: 'SicheresPasswort123!',
description: 'Aktuelles Passwort zur Bestaetigung',
})
@IsString()
@IsNotEmpty({ message: 'Passwort darf nicht leer sein' })
@MinLength(8, { message: 'Passwort muss mindestens 8 Zeichen lang sein' })
password!: string;
}

View file

@ -0,0 +1,13 @@
import { IsNotEmpty, IsString, Length } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class Enable2faDto {
@ApiProperty({
example: '123456',
description: 'TOTP-Code aus der Authenticator-App',
})
@IsString()
@IsNotEmpty({ message: '2FA-Code darf nicht leer sein' })
@Length(6, 6, { message: '2FA-Code muss genau 6 Zeichen lang sein' })
totpCode!: string;
}

View file

@ -0,0 +1,30 @@
import { IsEmail, IsNotEmpty, IsOptional, IsString, MinLength } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class LoginDto {
@ApiProperty({
example: 'admin@xinion.de',
description: 'E-Mail-Adresse des Benutzers',
})
@IsEmail({}, { message: 'Bitte gültige E-Mail-Adresse angeben' })
@IsNotEmpty({ message: 'E-Mail darf nicht leer sein' })
email!: string;
@ApiProperty({
example: 'SicheresPasswort123!',
description: 'Passwort (mindestens 8 Zeichen)',
})
@IsString()
@IsNotEmpty({ message: 'Passwort darf nicht leer sein' })
@MinLength(8, { message: 'Passwort muss mindestens 8 Zeichen lang sein' })
password!: string;
@ApiProperty({
example: '123456',
description: 'TOTP 2FA-Code (nur wenn 2FA aktiviert)',
required: false,
})
@IsOptional()
@IsString()
totpCode?: string;
}

View file

@ -0,0 +1,299 @@
import {
Injectable,
Logger,
OnModuleInit,
ServiceUnavailableException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import {
ConfidentialClientApplication,
type Configuration,
type AuthorizationUrlRequest,
type AuthorizationCodeRequest,
type AuthenticationResult,
} from '@azure/msal-node';
import { RedisService } from '../../../redis/redis.service';
/**
* Informationen aus dem Microsoft ID-Token.
*/
export interface MsUserInfo {
/** Microsoft Object ID (eindeutig pro Tenant) */
oid: string;
/** E-Mail-Adresse */
email: string;
/** Vorname */
firstName: string;
/** Nachname */
lastName: string;
}
/**
* SSO-Konfiguration die in Redis gespeichert wird.
*/
export interface SsoConfig {
tenantId: string;
clientId: string;
clientSecret: string;
redirectUri: string;
}
/** Redis-Key fuer die SSO-Konfiguration (persistent, kein TTL) */
const SSO_CONFIG_KEY = 'sso_config';
/**
* EntraIdService - Microsoft Entra ID (Azure AD) Integration.
*
* Nutzt MSAL ConfidentialClientApplication fuer den
* Authorization Code Flow (Server-seitig).
*
* Konfiguration wird aus Redis geladen (dynamisch via Admin-UI),
* mit Fallback auf Umgebungsvariablen.
*/
@Injectable()
export class EntraIdService implements OnModuleInit {
private readonly logger = new Logger(EntraIdService.name);
private msalClient: ConfidentialClientApplication | null = null;
private redirectUri = '';
private readonly scopes = ['openid', 'profile', 'email', 'User.Read'];
constructor(
private readonly config: ConfigService,
private readonly redis: RedisService,
) {}
/**
* Beim Start: Konfiguration aus Redis laden (Fallback: Env-Vars).
*/
async onModuleInit(): Promise<void> {
// Versuche Konfiguration aus Redis zu laden
const redisConfig = await this.loadConfigFromRedis();
if (redisConfig) {
this.initializeMsal(redisConfig);
this.logger.log(
'Microsoft Entra ID SSO aus Redis-Konfiguration initialisiert',
);
return;
}
// Fallback: Umgebungsvariablen
const clientId = this.config.get<string>('AZURE_CLIENT_ID');
const tenantId = this.config.get<string>('AZURE_TENANT_ID');
const clientSecret = this.config.get<string>('AZURE_CLIENT_SECRET');
const redirectUri = this.config.get<string>('AZURE_REDIRECT_URI');
if (clientId && tenantId && clientSecret) {
this.initializeMsal({
tenantId,
clientId,
clientSecret,
redirectUri:
redirectUri ||
'http://localhost/api/v1/auth/sso/microsoft/callback',
});
this.logger.log(
'Microsoft Entra ID SSO aus Umgebungsvariablen initialisiert',
);
} else {
this.logger.warn(
'Microsoft Entra ID SSO nicht konfiguriert (weder Redis noch Umgebungsvariablen)',
);
}
}
/**
* MSAL Client mit der gegebenen Konfiguration initialisieren.
*/
private initializeMsal(ssoConfig: SsoConfig): void {
const msalConfig: Configuration = {
auth: {
clientId: ssoConfig.clientId,
authority: `https://login.microsoftonline.com/${ssoConfig.tenantId}`,
clientSecret: ssoConfig.clientSecret,
},
system: {
loggerOptions: {
loggerCallback: (level, message) => {
this.logger.debug(`MSAL [${level}]: ${message}`);
},
},
},
};
this.msalClient = new ConfidentialClientApplication(msalConfig);
this.redirectUri = ssoConfig.redirectUri;
}
/**
* Konfiguration aus Redis laden.
*/
private async loadConfigFromRedis(): Promise<SsoConfig | null> {
try {
const raw = await this.redis.get(SSO_CONFIG_KEY);
if (!raw) return null;
const config = JSON.parse(raw) as SsoConfig;
if (config.tenantId && config.clientId && config.clientSecret) {
return config;
}
return null;
} catch (err) {
this.logger.warn(
`Fehler beim Laden der SSO-Config aus Redis: ${(err as Error).message}`,
);
return null;
}
}
/**
* Konfiguration speichern und MSAL Client neu initialisieren.
* Wird vom Admin-UI aufgerufen.
*/
async reconfigure(ssoConfig: SsoConfig): Promise<void> {
// In Redis speichern (persistent, kein TTL)
await this.redis.set(SSO_CONFIG_KEY, JSON.stringify(ssoConfig));
// MSAL Client neu initialisieren
this.initializeMsal(ssoConfig);
this.logger.log('Microsoft Entra ID SSO neu konfiguriert via Admin-UI');
}
/**
* Aktuelle Konfiguration lesen (Secret wird maskiert).
*/
async getConfig(): Promise<
| (Omit<SsoConfig, 'clientSecret'> & { clientSecretMasked: string })
| null
> {
// Zuerst aus Redis
const redisConfig = await this.loadConfigFromRedis();
if (redisConfig) {
return {
tenantId: redisConfig.tenantId,
clientId: redisConfig.clientId,
redirectUri: redisConfig.redirectUri,
clientSecretMasked: this.maskSecret(redisConfig.clientSecret),
};
}
// Fallback: Env-Vars
const clientId = this.config.get<string>('AZURE_CLIENT_ID');
const tenantId = this.config.get<string>('AZURE_TENANT_ID');
const clientSecret = this.config.get<string>('AZURE_CLIENT_SECRET');
const redirectUri = this.config.get<string>('AZURE_REDIRECT_URI');
if (clientId && tenantId && clientSecret) {
return {
tenantId,
clientId,
redirectUri:
redirectUri ||
'http://localhost/api/v1/auth/sso/microsoft/callback',
clientSecretMasked: this.maskSecret(clientSecret),
};
}
return null;
}
/**
* Secret maskieren: nur die letzten 4 Zeichen anzeigen.
*/
private maskSecret(secret: string): string {
if (secret.length <= 4) return '****';
return '****' + secret.slice(-4);
}
/**
* Ist Entra ID SSO konfiguriert?
*/
isConfigured(): boolean {
return !!this.msalClient;
}
/**
* Authorization-URL fuer den OAuth2 Flow generieren.
* @param state CSRF-Token (wird in Redis gespeichert)
*/
async getAuthUrl(state: string): Promise<string> {
if (!this.msalClient) {
throw new ServiceUnavailableException(
'Microsoft SSO ist nicht konfiguriert',
);
}
const authUrlRequest: AuthorizationUrlRequest = {
scopes: this.scopes,
redirectUri: this.redirectUri,
state,
prompt: 'select_account',
};
const authUrl = await this.msalClient.getAuthCodeUrl(authUrlRequest);
this.logger.debug('Authorization URL generiert');
return authUrl;
}
/**
* Authorization Code gegen Tokens tauschen und User-Info extrahieren.
* @param code Authorization Code von Microsoft
*/
async handleCallback(code: string): Promise<MsUserInfo> {
if (!this.msalClient) {
throw new ServiceUnavailableException(
'Microsoft SSO ist nicht konfiguriert',
);
}
const tokenRequest: AuthorizationCodeRequest = {
code,
scopes: this.scopes,
redirectUri: this.redirectUri,
};
const response: AuthenticationResult =
await this.msalClient.acquireTokenByCode(tokenRequest);
this.logger.debug('Token erfolgreich erhalten');
// User-Informationen aus ID-Token Claims extrahieren
const claims = response.idTokenClaims as Record<string, unknown>;
const oid = (claims.oid as string) || (claims.sub as string);
if (!oid) {
throw new Error('Keine Object ID (oid) im ID-Token gefunden');
}
// E-Mail: preferred_username, email, oder upn
const email =
(claims.preferred_username as string) ||
(claims.email as string) ||
(claims.upn as string) ||
'';
if (!email) {
throw new Error('Keine E-Mail-Adresse im ID-Token gefunden');
}
// Namen: given_name + family_name, oder name splitten
let firstName = (claims.given_name as string) || '';
let lastName = (claims.family_name as string) || '';
if (!firstName && !lastName && claims.name) {
const parts = (claims.name as string).split(' ');
firstName = parts[0] || '';
lastName = parts.slice(1).join(' ') || '';
}
this.logger.log(`MS SSO User: ${email} (OID: ${oid})`);
return {
oid,
email: email.toLowerCase(),
firstName,
lastName,
};
}
}

View file

@ -0,0 +1,251 @@
import {
Controller,
Get,
Post,
Body,
Query,
Res,
Logger,
ServiceUnavailableException,
UnauthorizedException,
UseGuards,
BadRequestException,
} from '@nestjs/common';
import { ApiTags, ApiOperation } from '@nestjs/swagger';
import { ConfigService } from '@nestjs/config';
import { Response } from 'express';
import { v4 as uuidv4 } from 'uuid';
import { Public } from '../../../common/decorators/public.decorator';
import { Roles } from '../../../common/decorators/roles.decorator';
import { RolesGuard } from '../../../common/guards/roles.guard';
import { RedisService } from '../../../redis/redis.service';
import { EntraIdService, type SsoConfig } from './entra-id.service';
import { AuthService } from '../auth.service';
/**
* SsoController - Microsoft Entra ID SSO Endpoints.
*
* Flow:
* 1. GET /auth/sso/microsoft Redirect zu Microsoft Login
* 2. GET /auth/sso/microsoft/callback Callback von Microsoft, User anlegen/verknuepfen, JWT generieren
*
* Admin:
* - GET /auth/sso/config Aktuelle Konfiguration lesen (Secret maskiert)
* - POST /auth/sso/config Konfiguration speichern und MSAL neu initialisieren
*/
@ApiTags('SSO')
@Controller('auth/sso')
export class SsoController {
private readonly logger = new Logger(SsoController.name);
constructor(
private readonly entraIdService: EntraIdService,
private readonly authService: AuthService,
private readonly redis: RedisService,
private readonly config: ConfigService,
) {}
/**
* GET /api/v1/auth/sso/config
* Aktuelle SSO-Konfiguration lesen (nur PLATFORM_ADMIN).
* Client Secret wird maskiert zurueckgegeben.
*/
@Get('config')
@Roles('PLATFORM_ADMIN')
@UseGuards(RolesGuard)
@ApiOperation({ summary: 'SSO-Konfiguration lesen (Admin)' })
async getConfig() {
const config = await this.entraIdService.getConfig();
return {
configured: this.entraIdService.isConfigured(),
config: config || null,
};
}
/**
* POST /api/v1/auth/sso/config
* SSO-Konfiguration speichern und MSAL Client neu initialisieren (nur PLATFORM_ADMIN).
*/
@Post('config')
@Roles('PLATFORM_ADMIN')
@UseGuards(RolesGuard)
@ApiOperation({ summary: 'SSO-Konfiguration speichern (Admin)' })
async saveConfig(
@Body()
body: {
tenantId: string;
clientId: string;
clientSecret?: string;
redirectUri: string;
},
) {
// Validierung
if (!body.tenantId || !body.clientId || !body.redirectUri) {
throw new BadRequestException(
'Tenant ID, Client ID und Redirect URI sind erforderlich',
);
}
// Wenn kein neues Secret: bestehendes aus Redis laden
let clientSecret = body.clientSecret;
if (!clientSecret) {
const existing = await this.entraIdService.getConfig();
// Wir brauchen das echte Secret aus Redis
const raw = await this.redis.get('sso_config');
if (raw) {
const parsed = JSON.parse(raw) as SsoConfig;
clientSecret = parsed.clientSecret;
}
}
if (!clientSecret) {
throw new BadRequestException(
'Client Secret ist erforderlich (kein bestehendes Secret vorhanden)',
);
}
const ssoConfig: SsoConfig = {
tenantId: body.tenantId.trim(),
clientId: body.clientId.trim(),
clientSecret: clientSecret.trim(),
redirectUri: body.redirectUri.trim(),
};
await this.entraIdService.reconfigure(ssoConfig);
this.logger.log('SSO-Konfiguration via Admin-UI aktualisiert');
return {
success: true,
message: 'SSO-Konfiguration gespeichert und aktiviert',
configured: true,
};
}
/**
* GET /api/v1/auth/sso/microsoft
* Initiiert den OAuth2 Authorization Code Flow.
* Redirectet den Browser zur Microsoft-Login-Seite.
*/
@Get('microsoft')
@Public()
@ApiOperation({ summary: 'Microsoft SSO Login starten' })
async initiate(@Res() res: Response): Promise<void> {
if (!this.entraIdService.isConfigured()) {
throw new ServiceUnavailableException(
'Microsoft SSO ist nicht konfiguriert',
);
}
// CSRF-State generieren und in Redis speichern (5 Minuten TTL)
const state = uuidv4();
await this.redis.set(`sso_state:${state}`, '1', 300);
// Authorization URL von MSAL holen
const authUrl = await this.entraIdService.getAuthUrl(state);
this.logger.log('SSO Flow gestartet, Redirect zu Microsoft');
res.redirect(authUrl);
}
/**
* GET /api/v1/auth/sso/microsoft/callback
* Callback von Microsoft nach erfolgreicher Authentifizierung.
* Erstellt/verknuepft User, generiert JWT, redirectet zum Frontend.
*/
@Get('microsoft/callback')
@Public()
@ApiOperation({ summary: 'Microsoft SSO Callback' })
async callback(
@Query('code') code: string,
@Query('state') state: string,
@Query('error') error: string,
@Query('error_description') errorDescription: string,
@Res() res: Response,
): Promise<void> {
const frontendUrl = this.config.get<string>(
'FRONTEND_URL',
'http://172.20.10.59',
);
// Fehler von Microsoft
if (error) {
this.logger.warn(`SSO Fehler von Microsoft: ${error} - ${errorDescription}`);
res.redirect(
`${frontendUrl}/login?sso_error=${encodeURIComponent(errorDescription || error)}`,
);
return;
}
// Code und State validieren
if (!code || !state) {
this.logger.warn('SSO Callback ohne Code oder State');
res.redirect(`${frontendUrl}/login?sso_error=Ungültige SSO-Antwort`);
return;
}
// CSRF-State aus Redis validieren
const storedState = await this.redis.get(`sso_state:${state}`);
if (!storedState) {
this.logger.warn('SSO State ungültig oder abgelaufen');
res.redirect(
`${frontendUrl}/login?sso_error=SSO-Sitzung abgelaufen. Bitte erneut versuchen.`,
);
return;
}
// State verbrauchen (einmalig verwendbar)
await this.redis.del(`sso_state:${state}`);
try {
// Code gegen Tokens tauschen und User-Info extrahieren
const msUser = await this.entraIdService.handleCallback(code);
// User finden oder anlegen + JWT generieren
const loginResult = await this.authService.loginViaSso(msUser);
// Refresh-Token als HttpOnly Cookie setzen (wie beim normalen Login)
const isProduction = process.env.NODE_ENV === 'production';
res.cookie('refresh_token', loginResult.refreshToken, {
httpOnly: true,
secure: isProduction,
sameSite: isProduction ? 'strict' : 'lax',
path: '/api/v1/auth',
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 Tage
});
// Redirect zum Frontend mit Access-Token
this.logger.log(`SSO Login erfolgreich: ${msUser.email}`);
res.redirect(
`${frontendUrl}/auth/sso/callback?token=${loginResult.accessToken}`,
);
} catch (err) {
this.logger.error(`SSO Callback Fehler: ${(err as Error).message}`);
if (err instanceof UnauthorizedException) {
res.redirect(
`${frontendUrl}/login?sso_error=${encodeURIComponent((err as Error).message)}`,
);
return;
}
res.redirect(
`${frontendUrl}/login?sso_error=SSO-Anmeldung fehlgeschlagen. Bitte erneut versuchen.`,
);
}
}
/**
* GET /api/v1/auth/sso/status
* Pruefen ob Microsoft SSO konfiguriert ist.
* Wird vom Frontend genutzt um den SSO-Button anzuzeigen.
*/
@Get('status')
@Public()
@ApiOperation({ summary: 'SSO Status abfragen' })
async getStatus() {
return {
microsoft: this.entraIdService.isConfigured(),
};
}
}

View file

@ -0,0 +1,47 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import * as fs from 'fs';
import { JwtPayload } from '../../../common/decorators/current-user.decorator';
/**
* JwtStrategy - Passport-Strategy fuer RS256 JWT-Validierung.
*
* Extrahiert den Token aus dem Authorization-Header (Bearer Token).
* Validiert Signatur (RS256), Issuer und Expiration automatisch.
*/
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(config: ConfigService) {
const publicKeyPath = config.get<string>(
'JWT_PUBLIC_KEY_PATH',
'/app/keys/jwt-public.pem',
);
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: fs.readFileSync(publicKeyPath, 'utf8'),
algorithms: ['RS256'],
issuer: config.get<string>('JWT_ISSUER', 'insight-platform'),
});
}
/**
* Wird nach erfolgreicher JWT-Validierung aufgerufen.
* Der Return-Wert landet in request.user.
*/
validate(payload: JwtPayload): JwtPayload {
return {
sub: payload.sub,
email: payload.email,
role: payload.role,
tenantId: payload.tenantId,
tenantSlug: payload.tenantSlug,
jti: payload.jti,
iat: payload.iat,
exp: payload.exp,
};
}
}

View file

@ -0,0 +1,54 @@
import { Injectable, Logger } from '@nestjs/common';
import { authenticator } from 'otplib';
import * as QRCode from 'qrcode';
/**
* TotpService - TOTP 2FA (Time-based One-Time Password).
*
* Verwendet den Google Authenticator kompatiblen TOTP-Algorithmus.
* Secrets werden verschluesselt in der Datenbank gespeichert.
*/
@Injectable()
export class TotpService {
private readonly logger = new Logger(TotpService.name);
constructor() {
// TOTP Konfiguration
authenticator.options = {
step: 30, // 30 Sekunden
window: 1, // +/- 1 Schritt Toleranz
digits: 6,
};
}
/**
* Neues TOTP-Secret generieren.
*/
generateSecret(): string {
return authenticator.generateSecret();
}
/**
* QR-Code als Data-URL generieren (fuer Authenticator-App Setup).
*/
async generateQrCode(email: string, secret: string): Promise<string> {
const otpauthUrl = authenticator.keyuri(
email,
'INSIGHT Platform',
secret,
);
return QRCode.toDataURL(otpauthUrl);
}
/**
* TOTP-Code verifizieren.
*/
verify(token: string, secret: string): boolean {
try {
return authenticator.verify({ token, secret });
} catch {
this.logger.warn('TOTP-Verifizierung fehlgeschlagen');
return false;
}
}
}

View file

@ -0,0 +1,29 @@
import { IsString, IsInt, IsOptional, IsNotEmpty, MaxLength, Min, Max, IsUrl } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class CreateCertificationDto {
@ApiProperty({ example: 'AWS Solutions Architect Professional' })
@IsString()
@IsNotEmpty({ message: 'Titel ist erforderlich' })
@MaxLength(300)
title!: string;
@ApiProperty({ example: 'Amazon Web Services' })
@IsString()
@IsNotEmpty({ message: 'Zertifizierungsstelle ist erforderlich' })
@MaxLength(300)
issuingBody!: string;
@ApiProperty({ example: 'https://aws.amazon.com/certification/', required: false })
@IsOptional()
@IsString()
@MaxLength(500)
@IsUrl({}, { message: 'Bitte eine gültige URL angeben' })
website?: string;
@ApiProperty({ example: 2024, description: 'Ausstellungsjahr' })
@IsInt()
@Min(1970)
@Max(2100)
issueYear!: number;
}

View file

@ -0,0 +1,25 @@
import { IsString, IsInt, IsOptional, MaxLength, Min, Max, IsIn } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class CreateExperienceDto {
@ApiProperty({ example: 'IT Infrastruktur' })
@IsString()
@MaxLength(200)
area!: string;
@ApiProperty({ example: 10 })
@IsInt()
@Min(0, { message: 'Jahre dürfen nicht negativ sein' })
@Max(60, { message: 'Maximal 60 Jahre Erfahrung' })
years!: number;
@ApiProperty({
example: 'Experte',
required: false,
enum: ['Experte', 'Fortgeschritten', 'Grundkenntnisse'],
})
@IsOptional()
@IsString()
@IsIn(['Experte', 'Fortgeschritten', 'Grundkenntnisse'])
level?: string;
}

View file

@ -0,0 +1,17 @@
import { IsString, MaxLength, IsIn } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class CreateLanguageDto {
@ApiProperty({ example: 'Deutsch' })
@IsString()
@MaxLength(100)
language!: string;
@ApiProperty({
example: 'Muttersprache',
enum: ['Muttersprache', 'C2', 'C1', 'B2', 'B1', 'A2', 'A1'],
})
@IsString()
@IsIn(['Muttersprache', 'C2', 'C1', 'B2', 'B1', 'A2', 'A1'])
level!: string;
}

View file

@ -0,0 +1,82 @@
import {
IsString,
IsInt,
IsOptional,
IsBoolean,
IsNotEmpty,
MaxLength,
Min,
Max,
IsIn,
ValidateIf,
} from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class CreateProjectDto {
@ApiProperty({ example: 3, description: 'Startmonat (1-12)' })
@IsInt()
@Min(1)
@Max(12)
fromMonth!: number;
@ApiProperty({ example: 2023, description: 'Startjahr' })
@IsInt()
@Min(1970)
@Max(2100)
fromYear!: number;
@ApiProperty({ example: 6, required: false, description: 'Endmonat (1-12)' })
@IsOptional()
@ValidateIf((o: CreateProjectDto) => !o.isCurrent)
@IsInt()
@Min(1)
@Max(12)
toMonth?: number;
@ApiProperty({ example: 2024, required: false, description: 'Endjahr' })
@IsOptional()
@ValidateIf((o: CreateProjectDto) => !o.isCurrent)
@IsInt()
@Min(1970)
@Max(2100)
toYear?: number;
@ApiProperty({ example: false, required: false, description: 'Projekt läuft noch' })
@IsOptional()
@IsBoolean()
isCurrent?: boolean;
@ApiProperty({ example: 'Senior DevOps Engineer' })
@IsString()
@IsNotEmpty({ message: 'Tätigkeit ist erforderlich' })
@MaxLength(200)
role!: string;
@ApiProperty({ example: 'Aufbau und Betrieb der Kubernetes-Infrastruktur', required: false })
@IsOptional()
@IsString()
@MaxLength(1500, { message: 'Aufgaben dürfen maximal 1500 Zeichen lang sein' })
tasks?: string;
@ApiProperty({ example: 'Xinion GmbH', required: false })
@IsOptional()
@IsString()
@MaxLength(200)
company?: string;
@ApiProperty({
example: '51-200',
required: false,
enum: ['1-10', '11-50', '51-200', '201-500', '501-1000', '1001-5000', '5000+'],
})
@IsOptional()
@IsString()
@IsIn(['1-10', '11-50', '51-200', '201-500', '501-1000', '1001-5000', '5000+'])
companySize?: string;
@ApiProperty({ example: 'IT-Dienstleistung', required: false })
@IsOptional()
@IsString()
@MaxLength(200)
industry?: string;
}

View file

@ -0,0 +1,30 @@
import { IsString, IsInt, IsOptional, MaxLength, Min, Max, IsUrl } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class UpdateCertificationDto {
@ApiProperty({ example: 'AWS Solutions Architect Professional', required: false })
@IsOptional()
@IsString()
@MaxLength(300)
title?: string;
@ApiProperty({ example: 'Amazon Web Services', required: false })
@IsOptional()
@IsString()
@MaxLength(300)
issuingBody?: string;
@ApiProperty({ example: 'https://aws.amazon.com/certification/', required: false })
@IsOptional()
@IsString()
@MaxLength(500)
@IsUrl({}, { message: 'Bitte eine gültige URL angeben' })
website?: string;
@ApiProperty({ example: 2024, required: false })
@IsOptional()
@IsInt()
@Min(1970)
@Max(2100)
issueYear?: number;
}

View file

@ -0,0 +1,79 @@
import {
IsString,
IsInt,
IsOptional,
IsBoolean,
MaxLength,
Min,
Max,
IsIn,
ValidateIf,
} from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class UpdateProjectDto {
@ApiProperty({ example: 3, required: false })
@IsOptional()
@IsInt()
@Min(1)
@Max(12)
fromMonth?: number;
@ApiProperty({ example: 2023, required: false })
@IsOptional()
@IsInt()
@Min(1970)
@Max(2100)
fromYear?: number;
@ApiProperty({ example: 6, required: false })
@IsOptional()
@ValidateIf((o: UpdateProjectDto) => !o.isCurrent)
@IsInt()
@Min(1)
@Max(12)
toMonth?: number;
@ApiProperty({ example: 2024, required: false })
@IsOptional()
@ValidateIf((o: UpdateProjectDto) => !o.isCurrent)
@IsInt()
@Min(1970)
@Max(2100)
toYear?: number;
@ApiProperty({ example: false, required: false })
@IsOptional()
@IsBoolean()
isCurrent?: boolean;
@ApiProperty({ example: 'Senior DevOps Engineer', required: false })
@IsOptional()
@IsString()
@MaxLength(200)
role?: string;
@ApiProperty({ example: 'Aufbau der K8s-Infrastruktur', required: false })
@IsOptional()
@IsString()
@MaxLength(1500, { message: 'Aufgaben dürfen maximal 1500 Zeichen lang sein' })
tasks?: string;
@ApiProperty({ example: 'Xinion GmbH', required: false })
@IsOptional()
@IsString()
@MaxLength(200)
company?: string;
@ApiProperty({ example: '51-200', required: false })
@IsOptional()
@IsString()
@IsIn(['1-10', '11-50', '51-200', '201-500', '501-1000', '1001-5000', '5000+'])
companySize?: string;
@ApiProperty({ example: 'IT-Dienstleistung', required: false })
@IsOptional()
@IsString()
@MaxLength(200)
industry?: string;
}

View file

@ -0,0 +1,14 @@
import { IsArray, IsString, MaxLength, ArrayMaxSize } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class UpdateSkillsDto {
@ApiProperty({
example: ['Kubernetes', 'Docker', 'AWS', 'Terraform'],
description: 'Komplettes Skills-Array (ersetzt vorhandene Skills)',
})
@IsArray()
@IsString({ each: true })
@MaxLength(100, { each: true, message: 'Jeder Skill darf maximal 100 Zeichen lang sein' })
@ArrayMaxSize(50, { message: 'Maximal 50 Skills erlaubt' })
skills!: string[];
}

View file

@ -0,0 +1,28 @@
import { IsString, IsInt, IsNotEmpty, MaxLength, Min, Max } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class UploadAttachmentDto {
@ApiProperty({ example: 'AWS-Zertifikat.pdf' })
@IsString()
@IsNotEmpty({ message: 'Dateiname ist erforderlich' })
@MaxLength(255)
filename!: string;
@ApiProperty({ example: 'application/pdf' })
@IsString()
@IsNotEmpty()
@MaxLength(100)
mimetype!: string;
@ApiProperty({ example: 524288, description: 'Dateigröße in Bytes' })
@IsInt()
@Min(1, { message: 'Datei darf nicht leer sein' })
@Max(10485760, { message: 'Datei darf maximal 10MB groß sein' })
size!: number;
@ApiProperty({ description: 'Base64-kodierte Dateidaten' })
@IsString()
@IsNotEmpty()
@MaxLength(14000000, { message: 'Base64-Daten dürfen maximal ~10MB betragen' })
data!: string;
}

View file

@ -0,0 +1,229 @@
import {
Controller,
Get,
Patch,
Post,
Delete,
Param,
Body,
Res,
ParseUUIDPipe,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
import type { Response } from 'express';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { ExpertProfileService } from './expert-profile.service';
import { ProfileExportService } from './profile-export.service';
import { UpdateSkillsDto } from './dto/update-skills.dto';
import { CreateExperienceDto } from './dto/create-experience.dto';
import { CreateLanguageDto } from './dto/create-language.dto';
import { CreateProjectDto } from './dto/create-project.dto';
import { UpdateProjectDto } from './dto/update-project.dto';
import { CreateCertificationDto } from './dto/create-certification.dto';
import { UpdateCertificationDto } from './dto/update-certification.dto';
import { UploadAttachmentDto } from './dto/upload-attachment.dto';
@ApiTags('Experten-Profil')
@ApiBearerAuth('access-token')
@Controller('expert-profile')
export class ExpertProfileController {
constructor(
private readonly expertProfileService: ExpertProfileService,
private readonly profileExportService: ProfileExportService,
) {}
// ============================================================
// Profil
// ============================================================
@Get('me')
@ApiOperation({ summary: 'Eigenes Experten-Profil abrufen' })
async getProfile(@CurrentUser('sub') userId: string) {
return this.expertProfileService.getOrCreateProfile(userId);
}
// ============================================================
// Skills
// ============================================================
@Patch('me/skills')
@ApiOperation({ summary: 'Skills aktualisieren' })
async updateSkills(
@CurrentUser('sub') userId: string,
@Body() dto: UpdateSkillsDto,
) {
return this.expertProfileService.updateSkills(userId, dto);
}
// ============================================================
// Erfahrung
// ============================================================
@Post('me/experiences')
@ApiOperation({ summary: 'Erfahrung hinzufügen' })
async addExperience(
@CurrentUser('sub') userId: string,
@Body() dto: CreateExperienceDto,
) {
return this.expertProfileService.addExperience(userId, dto);
}
@Delete('me/experiences/:id')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Erfahrung löschen' })
async deleteExperience(
@CurrentUser('sub') userId: string,
@Param('id', ParseUUIDPipe) id: string,
) {
await this.expertProfileService.deleteExperience(userId, id);
}
// ============================================================
// Sprachen
// ============================================================
@Post('me/languages')
@ApiOperation({ summary: 'Sprache hinzufügen' })
async addLanguage(
@CurrentUser('sub') userId: string,
@Body() dto: CreateLanguageDto,
) {
return this.expertProfileService.addLanguage(userId, dto);
}
@Delete('me/languages/:id')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Sprache löschen' })
async deleteLanguage(
@CurrentUser('sub') userId: string,
@Param('id', ParseUUIDPipe) id: string,
) {
await this.expertProfileService.deleteLanguage(userId, id);
}
// ============================================================
// Projekte
// ============================================================
@Post('me/projects')
@ApiOperation({ summary: 'Projekt hinzufügen' })
async addProject(
@CurrentUser('sub') userId: string,
@Body() dto: CreateProjectDto,
) {
return this.expertProfileService.addProject(userId, dto);
}
@Patch('me/projects/:id')
@ApiOperation({ summary: 'Projekt bearbeiten' })
async updateProject(
@CurrentUser('sub') userId: string,
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateProjectDto,
) {
return this.expertProfileService.updateProject(userId, id, dto);
}
@Delete('me/projects/:id')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Projekt löschen' })
async deleteProject(
@CurrentUser('sub') userId: string,
@Param('id', ParseUUIDPipe) id: string,
) {
await this.expertProfileService.deleteProject(userId, id);
}
// ============================================================
// Zertifizierungen
// ============================================================
@Post('me/certifications')
@ApiOperation({ summary: 'Zertifizierung hinzufügen' })
async addCertification(
@CurrentUser('sub') userId: string,
@Body() dto: CreateCertificationDto,
) {
return this.expertProfileService.addCertification(userId, dto);
}
@Patch('me/certifications/:id')
@ApiOperation({ summary: 'Zertifizierung bearbeiten' })
async updateCertification(
@CurrentUser('sub') userId: string,
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateCertificationDto,
) {
return this.expertProfileService.updateCertification(userId, id, dto);
}
@Delete('me/certifications/:id')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Zertifizierung löschen' })
async deleteCertification(
@CurrentUser('sub') userId: string,
@Param('id', ParseUUIDPipe) id: string,
) {
await this.expertProfileService.deleteCertification(userId, id);
}
// ============================================================
// Export (PDF / DOCX)
// ============================================================
@Get('me/export/pdf')
@ApiOperation({ summary: 'Profil als PDF exportieren' })
async exportPdf(
@CurrentUser('sub') userId: string,
@Res() res: Response,
) {
const buffer = await this.profileExportService.generatePdf(userId);
res.set({
'Content-Type': 'application/pdf',
'Content-Disposition': 'attachment; filename="Profil.pdf"',
'Content-Length': String(buffer.length),
});
res.end(buffer);
}
@Get('me/export/docx')
@ApiOperation({ summary: 'Profil als Word-Dokument exportieren' })
async exportDocx(
@CurrentUser('sub') userId: string,
@Res() res: Response,
) {
const buffer = await this.profileExportService.generateDocx(userId);
res.set({
'Content-Type': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'Content-Disposition': 'attachment; filename="Profil.docx"',
'Content-Length': String(buffer.length),
});
res.end(buffer);
}
// ============================================================
// Profilanlagen
// ============================================================
@Post('me/attachments')
@ApiOperation({ summary: 'Datei hochladen' })
async uploadAttachment(
@CurrentUser('sub') userId: string,
@Body() dto: UploadAttachmentDto,
) {
return this.expertProfileService.uploadAttachment(userId, dto);
}
@Get('me/attachments/:id')
@ApiOperation({ summary: 'Datei herunterladen' })
async downloadAttachment(
@CurrentUser('sub') userId: string,
@Param('id', ParseUUIDPipe) id: string,
) {
return this.expertProfileService.downloadAttachment(userId, id);
}
@Delete('me/attachments/:id')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Datei löschen' })
async deleteAttachment(
@CurrentUser('sub') userId: string,
@Param('id', ParseUUIDPipe) id: string,
) {
await this.expertProfileService.deleteAttachment(userId, id);
}
}

View file

@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { ExpertProfileController } from './expert-profile.controller';
import { ExpertProfileService } from './expert-profile.service';
import { ProfileExportService } from './profile-export.service';
@Module({
controllers: [ExpertProfileController],
providers: [ExpertProfileService, ProfileExportService],
exports: [ExpertProfileService],
})
export class ExpertProfileModule {}

View file

@ -0,0 +1,349 @@
import {
Injectable,
NotFoundException,
ForbiddenException,
Logger,
} from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { UpdateSkillsDto } from './dto/update-skills.dto';
import { CreateExperienceDto } from './dto/create-experience.dto';
import { CreateLanguageDto } from './dto/create-language.dto';
import { CreateProjectDto } from './dto/create-project.dto';
import { UpdateProjectDto } from './dto/update-project.dto';
import { CreateCertificationDto } from './dto/create-certification.dto';
import { UpdateCertificationDto } from './dto/update-certification.dto';
import { UploadAttachmentDto } from './dto/upload-attachment.dto';
@Injectable()
export class ExpertProfileService {
private readonly logger = new Logger(ExpertProfileService.name);
constructor(private readonly prisma: PrismaService) {}
// ============================================================
// Profil laden / auto-erstellen
// ============================================================
async getOrCreateProfile(userId: string) {
const profile = await this.prisma.expertProfile.upsert({
where: { userId },
create: { userId },
update: {},
include: {
experiences: { orderBy: { createdAt: 'desc' } },
languages: { orderBy: { language: 'asc' } },
projects: { orderBy: [{ fromYear: 'desc' }, { fromMonth: 'desc' }] },
certifications: { orderBy: { issueYear: 'desc' } },
attachments: {
select: { id: true, filename: true, mimetype: true, size: true, createdAt: true },
orderBy: { createdAt: 'desc' },
},
},
});
return profile;
}
/**
* Profil-ID für einen User ermitteln (erstellt bei Bedarf).
*/
private async ensureProfileId(userId: string): Promise<string> {
const profile = await this.prisma.expertProfile.upsert({
where: { userId },
create: { userId },
update: {},
select: { id: true },
});
return profile.id;
}
// ============================================================
// Skills
// ============================================================
async updateSkills(userId: string, dto: UpdateSkillsDto) {
const profileId = await this.ensureProfileId(userId);
const updated = await this.prisma.expertProfile.update({
where: { id: profileId },
data: { skills: dto.skills },
select: { skills: true },
});
this.logger.log(`Skills aktualisiert für User ${userId}: ${dto.skills.length} Skills`);
return updated;
}
// ============================================================
// Erfahrung (Experience)
// ============================================================
async addExperience(userId: string, dto: CreateExperienceDto) {
const profileId = await this.ensureProfileId(userId);
const experience = await this.prisma.expertExperience.create({
data: {
expertProfileId: profileId,
area: dto.area,
years: dto.years,
...(dto.level !== undefined && { level: dto.level }),
},
});
this.logger.log(`Erfahrung hinzugefügt: ${dto.area} (${dto.years} Jahre)`);
return experience;
}
async deleteExperience(userId: string, experienceId: string) {
await this.verifyOwnership(userId, 'expertExperience', experienceId);
await this.prisma.expertExperience.delete({
where: { id: experienceId },
});
}
// ============================================================
// Sprachen (Languages)
// ============================================================
async addLanguage(userId: string, dto: CreateLanguageDto) {
const profileId = await this.ensureProfileId(userId);
const language = await this.prisma.expertLanguage.create({
data: {
expertProfileId: profileId,
language: dto.language,
level: dto.level,
},
});
this.logger.log(`Sprache hinzugefügt: ${dto.language} (${dto.level})`);
return language;
}
async deleteLanguage(userId: string, languageId: string) {
await this.verifyOwnership(userId, 'expertLanguage', languageId);
await this.prisma.expertLanguage.delete({
where: { id: languageId },
});
}
// ============================================================
// Projekte (Projects)
// ============================================================
async addProject(userId: string, dto: CreateProjectDto) {
const profileId = await this.ensureProfileId(userId);
const project = await this.prisma.expertProject.create({
data: {
expertProfileId: profileId,
fromMonth: dto.fromMonth,
fromYear: dto.fromYear,
toMonth: dto.isCurrent ? null : (dto.toMonth ?? null),
toYear: dto.isCurrent ? null : (dto.toYear ?? null),
isCurrent: dto.isCurrent ?? false,
role: dto.role,
...(dto.tasks !== undefined && { tasks: dto.tasks }),
...(dto.company !== undefined && { company: dto.company }),
...(dto.companySize !== undefined && { companySize: dto.companySize }),
...(dto.industry !== undefined && { industry: dto.industry }),
},
});
this.logger.log(`Projekt hinzugefügt: ${dto.role} (${dto.fromMonth}/${dto.fromYear})`);
return project;
}
async updateProject(userId: string, projectId: string, dto: UpdateProjectDto) {
await this.verifyOwnership(userId, 'expertProject', projectId);
const project = await this.prisma.expertProject.update({
where: { id: projectId },
data: {
...(dto.fromMonth !== undefined && { fromMonth: dto.fromMonth }),
...(dto.fromYear !== undefined && { fromYear: dto.fromYear }),
...(dto.isCurrent !== undefined && {
isCurrent: dto.isCurrent,
toMonth: dto.isCurrent ? null : (dto.toMonth ?? undefined),
toYear: dto.isCurrent ? null : (dto.toYear ?? undefined),
}),
...(!dto.isCurrent && dto.toMonth !== undefined && { toMonth: dto.toMonth }),
...(!dto.isCurrent && dto.toYear !== undefined && { toYear: dto.toYear }),
...(dto.role !== undefined && { role: dto.role }),
...(dto.tasks !== undefined && { tasks: dto.tasks }),
...(dto.company !== undefined && { company: dto.company }),
...(dto.companySize !== undefined && { companySize: dto.companySize }),
...(dto.industry !== undefined && { industry: dto.industry }),
},
});
return project;
}
async deleteProject(userId: string, projectId: string) {
await this.verifyOwnership(userId, 'expertProject', projectId);
await this.prisma.expertProject.delete({
where: { id: projectId },
});
}
// ============================================================
// Zertifizierungen (Certifications)
// ============================================================
async addCertification(userId: string, dto: CreateCertificationDto) {
const profileId = await this.ensureProfileId(userId);
const certification = await this.prisma.expertCertification.create({
data: {
expertProfileId: profileId,
title: dto.title,
issuingBody: dto.issuingBody,
...(dto.website !== undefined && { website: dto.website }),
issueYear: dto.issueYear,
},
});
this.logger.log(`Zertifizierung hinzugefügt: ${dto.title}`);
return certification;
}
async updateCertification(userId: string, certificationId: string, dto: UpdateCertificationDto) {
await this.verifyOwnership(userId, 'expertCertification', certificationId);
const certification = await this.prisma.expertCertification.update({
where: { id: certificationId },
data: {
...(dto.title !== undefined && { title: dto.title }),
...(dto.issuingBody !== undefined && { issuingBody: dto.issuingBody }),
...(dto.website !== undefined && { website: dto.website }),
...(dto.issueYear !== undefined && { issueYear: dto.issueYear }),
},
});
return certification;
}
async deleteCertification(userId: string, certificationId: string) {
await this.verifyOwnership(userId, 'expertCertification', certificationId);
await this.prisma.expertCertification.delete({
where: { id: certificationId },
});
}
// ============================================================
// Profilanlagen (Attachments)
// ============================================================
async uploadAttachment(userId: string, dto: UploadAttachmentDto) {
const profileId = await this.ensureProfileId(userId);
const attachment = await this.prisma.expertAttachment.create({
data: {
expertProfileId: profileId,
filename: dto.filename,
mimetype: dto.mimetype,
size: dto.size,
data: dto.data,
},
select: { id: true, filename: true, mimetype: true, size: true, createdAt: true },
});
this.logger.log(`Anhang hochgeladen: ${dto.filename} (${dto.size} Bytes)`);
return attachment;
}
async downloadAttachment(userId: string, attachmentId: string) {
const attachment = await this.prisma.expertAttachment.findUnique({
where: { id: attachmentId },
include: { expertProfile: { select: { userId: true } } },
});
if (!attachment) {
throw new NotFoundException('Anhang nicht gefunden');
}
if (attachment.expertProfile.userId !== userId) {
throw new ForbiddenException('Kein Zugriff auf diesen Anhang');
}
return {
id: attachment.id,
filename: attachment.filename,
mimetype: attachment.mimetype,
size: attachment.size,
data: attachment.data,
};
}
async deleteAttachment(userId: string, attachmentId: string) {
const attachment = await this.prisma.expertAttachment.findUnique({
where: { id: attachmentId },
include: { expertProfile: { select: { userId: true } } },
});
if (!attachment) {
throw new NotFoundException('Anhang nicht gefunden');
}
if (attachment.expertProfile.userId !== userId) {
throw new ForbiddenException('Kein Zugriff auf diesen Anhang');
}
await this.prisma.expertAttachment.delete({
where: { id: attachmentId },
});
this.logger.log(`Anhang gelöscht: ${attachment.filename}`);
}
// ============================================================
// Export-Daten (User + Profil-Daten komplett)
// ============================================================
async getExportData(userId: string) {
const user = await this.prisma.user.findUniqueOrThrow({
where: { id: userId },
select: {
firstName: true,
lastName: true,
email: true,
phone: true,
mobile: true,
street: true,
postalCode: true,
city: true,
avatar: true,
expertProfile: {
include: {
experiences: { orderBy: { createdAt: 'desc' } },
languages: { orderBy: { language: 'asc' } },
projects: { orderBy: [{ fromYear: 'desc' }, { fromMonth: 'desc' }] },
certifications: { orderBy: { issueYear: 'desc' } },
},
},
},
});
return user;
}
// ============================================================
// Hilfsfunktion: Ownership-Check
// ============================================================
private async verifyOwnership(
userId: string,
model: 'expertExperience' | 'expertLanguage' | 'expertProject' | 'expertCertification',
entityId: string,
): Promise<void> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const entity = await (this.prisma[model] as any).findUnique({
where: { id: entityId },
include: { expertProfile: { select: { userId: true } } },
});
if (!entity) {
throw new NotFoundException('Eintrag nicht gefunden');
}
if (entity.expertProfile.userId !== userId) {
throw new ForbiddenException('Kein Zugriff auf diesen Eintrag');
}
}
}

View file

@ -0,0 +1,861 @@
import { Injectable, Logger } from '@nestjs/common';
import { ExpertProfileService } from './expert-profile.service';
import PDFDocument from 'pdfkit';
import * as fs from 'fs';
import * as path from 'path';
import { PNG } from 'pngjs';
import sharp from 'sharp';
import {
Document,
Packer,
Paragraph,
TextRun,
Table,
TableRow,
TableCell,
WidthType,
BorderStyle,
AlignmentType,
ImageRun,
HeadingLevel,
ShadingType,
} from 'docx';
// ============================================================
// Typen für Export-Daten
// ============================================================
interface ExportProject {
fromMonth: number;
fromYear: number;
toMonth: number | null;
toYear: number | null;
isCurrent: boolean;
role: string;
tasks: string | null;
company: string | null;
companySize: string | null;
industry: string | null;
}
interface ExportCertification {
title: string;
issuingBody: string;
website: string | null;
issueYear: number;
}
interface ExportExperience {
area: string;
years: number;
level: string | null;
}
interface ExportLanguage {
language: string;
level: string;
}
interface ExportData {
firstName: string;
lastName: string;
email: string;
phone: string | null;
mobile: string | null;
street: string | null;
postalCode: string | null;
city: string | null;
avatar: string | null;
expertProfile: {
skills: string[];
experiences: ExportExperience[];
languages: ExportLanguage[];
projects: ExportProject[];
certifications: ExportCertification[];
} | null;
}
// ============================================================
// Farb-Hilfsfunktionen
// ============================================================
function hexToRgb(hex: string): { r: number; g: number; b: number } {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
return result
? { r: parseInt(result[1], 16), g: parseInt(result[2], 16), b: parseInt(result[3], 16) }
: { r: 0, g: 150, b: 136 };
}
function lightenColor(hex: string, factor: number): string {
const { r, g, b } = hexToRgb(hex);
const lr = Math.round(r + (255 - r) * factor);
const lg = Math.round(g + (255 - g) * factor);
const lb = Math.round(b + (255 - b) * factor);
return `#${lr.toString(16).padStart(2, '0')}${lg.toString(16).padStart(2, '0')}${lb.toString(16).padStart(2, '0')}`;
}
// ============================================================
// ProfileExportService
// ============================================================
@Injectable()
export class ProfileExportService {
private readonly logger = new Logger(ProfileExportService.name);
// Pfad zu den Icon-Assets (relativ zum Working Directory /app im Container)
private readonly iconsDir = path.resolve(process.cwd(), 'assets', 'icons');
constructor(private readonly expertProfileService: ExpertProfileService) {}
private loadIcon(name: string, color?: string): Buffer | null {
try {
const iconPath = path.join(this.iconsDir, name);
if (fs.existsSync(iconPath)) {
const raw = fs.readFileSync(iconPath);
if (color) {
return this.recolorPng(raw, color);
}
return raw;
}
this.logger.warn(`Icon nicht gefunden: ${iconPath}`);
} catch (err) {
this.logger.warn(`Icon konnte nicht geladen werden: ${name}`, err);
}
return null;
}
/** Ersetzt alle sichtbaren Pixel eines PNG durch die angegebene Farbe (Alpha bleibt erhalten) */
private recolorPng(pngBuffer: Buffer, hexColor: string): Buffer {
const { r, g, b } = hexToRgb(hexColor);
const png = PNG.sync.read(pngBuffer);
for (let i = 0; i < png.data.length; i += 4) {
const alpha = png.data[i + 3];
if (alpha > 0) {
png.data[i] = r;
png.data[i + 1] = g;
png.data[i + 2] = b;
// Alpha bleibt unverändert
}
}
return PNG.sync.write(png);
}
/** Erzeugt ein rundes PNG aus einem beliebigen Bild-Buffer (JPEG, PNG, etc.) */
private async makeCircularAvatar(imageBuffer: Buffer, size: number): Promise<Buffer> {
const circleSvg = Buffer.from(
`<svg width="${size}" height="${size}"><circle cx="${size / 2}" cy="${size / 2}" r="${size / 2}" fill="white"/></svg>`,
);
return sharp(imageBuffer)
.resize(size, size, { fit: 'cover' })
.composite([{ input: circleSvg, blend: 'dest-in' }])
.png()
.toBuffer();
}
// ============================================================
// PDF Export
// ============================================================
async generatePdf(userId: string, accentColor = '#009688'): Promise<Buffer> {
const data = await this.expertProfileService.getExportData(userId) as ExportData;
const profile = data.expertProfile;
const fullName = `${data.firstName} ${data.lastName}`;
return new Promise<Buffer>((resolve, reject) => {
const chunks: Buffer[] = [];
const doc = new PDFDocument({
size: 'A4',
margins: { top: 40, bottom: 40, left: 40, right: 40 },
bufferPages: true,
});
doc.on('data', (chunk: Buffer) => chunks.push(chunk));
doc.on('end', () => resolve(Buffer.concat(chunks)));
doc.on('error', reject);
// --- Konstanten ---
const pageWidth = 595.28;
const leftColWidth = 180;
const leftColX = 40;
const rightColX = leftColX + leftColWidth + 20;
const rightColWidth = pageWidth - rightColX - 40;
const pageBottom = 800;
let yLeft = 40;
let yRight = 40;
// --- SEITE 1: Linke Spalte ---
// Avatar (rundes Bild)
if (data.avatar) {
try {
const avatarBuffer = this.base64ToBuffer(data.avatar);
const avatarSize = 110;
const centerX = leftColX + leftColWidth / 2;
const centerY = yLeft + avatarSize / 2;
doc.save();
doc.circle(centerX, centerY, avatarSize / 2).clip();
doc.image(avatarBuffer, centerX - avatarSize / 2, yLeft, {
width: avatarSize,
height: avatarSize,
});
doc.restore();
yLeft += avatarSize + 15;
} catch (err) {
this.logger.warn('Avatar konnte nicht geladen werden', err);
yLeft += 10;
}
}
// Name
doc.font('Helvetica-Bold').fontSize(16).fillColor('#333333');
doc.text(fullName, leftColX, yLeft, { width: leftColWidth, align: 'center' });
yLeft += doc.heightOfString(fullName, { width: leftColWidth }) + 5;
// Rolle (erste Erfahrung als Titel)
if (profile && profile.experiences.length > 0) {
const mainRole = profile.experiences[0].area;
doc.font('Helvetica').fontSize(10).fillColor(accentColor);
doc.text(mainRole, leftColX, yLeft, { width: leftColWidth, align: 'center' });
yLeft += doc.heightOfString(mainRole, { width: leftColWidth }) + 8;
}
// Akzentlinie
doc.moveTo(leftColX + 20, yLeft).lineTo(leftColX + leftColWidth - 20, yLeft)
.strokeColor(accentColor).lineWidth(2).stroke();
yLeft += 15;
// --- KONTAKT ---
yLeft = this.pdfSectionTitle(doc, 'KONTAKT', leftColX, yLeft, leftColWidth, accentColor);
// Icons laden (eingefärbt in Akzentfarbe)
const phoneIcon = this.loadIcon('Phone.png', accentColor);
const mobileIcon = this.loadIcon('Mobile.png', accentColor);
const mailIcon = this.loadIcon('Mail.png', accentColor);
const addressIcon = this.loadIcon('Address.png', accentColor);
const iconSize = 12;
const iconTextOffset = 20; // Abstand Icon → Text
if (data.phone) {
if (phoneIcon) doc.image(phoneIcon, leftColX, yLeft - 1, { width: iconSize, height: iconSize });
yLeft = this.pdfContactText(doc, data.phone, leftColX + iconTextOffset, yLeft, leftColWidth - iconTextOffset);
}
if (data.mobile) {
if (mobileIcon) doc.image(mobileIcon, leftColX + 1, yLeft - 1, { width: iconSize, height: iconSize });
yLeft = this.pdfContactText(doc, data.mobile, leftColX + iconTextOffset, yLeft, leftColWidth - iconTextOffset);
}
if (data.email) {
if (mailIcon) doc.image(mailIcon, leftColX, yLeft - 1, { width: iconSize, height: iconSize });
yLeft = this.pdfContactText(doc, data.email, leftColX + iconTextOffset, yLeft, leftColWidth - iconTextOffset);
}
if (data.street || data.city) {
if (addressIcon) doc.image(addressIcon, leftColX + 1, yLeft - 2, { width: iconSize, height: iconSize + 2 });
const line1 = data.street || '';
const line2 = [data.postalCode, data.city].filter(Boolean).join(' ');
const addressText = [line1, line2].filter(Boolean).join('\n');
yLeft = this.pdfContactText(doc, addressText, leftColX + iconTextOffset, yLeft, leftColWidth - iconTextOffset);
}
yLeft += 10;
// --- SPRACHEN ---
if (profile && profile.languages.length > 0) {
yLeft = this.pdfSectionTitle(doc, 'SPRACHEN', leftColX, yLeft, leftColWidth, accentColor);
for (const lang of profile.languages) {
doc.font('Helvetica').fontSize(9).fillColor('#333333');
doc.text(lang.language, leftColX, yLeft, { width: leftColWidth * 0.55, continued: false });
doc.font('Helvetica').fontSize(8).fillColor('#777777');
doc.text(lang.level, leftColX + leftColWidth * 0.6, yLeft, { width: leftColWidth * 0.4 });
yLeft += 14;
}
yLeft += 8;
}
// --- ERFAHRUNG (Expertise-Bereiche) ---
if (profile && profile.experiences.length > 0) {
yLeft = this.pdfSectionTitle(doc, 'ERFAHRUNG', leftColX, yLeft, leftColWidth, accentColor);
for (const exp of profile.experiences) {
doc.font('Helvetica').fontSize(9).fillColor('#333333');
doc.text(exp.area, leftColX, yLeft, { width: leftColWidth * 0.65 });
doc.font('Helvetica').fontSize(8).fillColor('#777777');
const expDetail = `${exp.years}J.${exp.level ? ' · ' + exp.level : ''}`;
doc.text(expDetail, leftColX + leftColWidth * 0.65, yLeft, { width: leftColWidth * 0.35 });
yLeft += 14;
}
}
// --- SEITE 1: Rechte Spalte — BERUFSERFAHRUNG ---
if (profile && profile.projects.length > 0) {
yRight = this.pdfSectionTitle(doc, 'BERUFSERFAHRUNG', rightColX, yRight, rightColWidth, accentColor);
const timelineX = rightColX + 6;
const contentX = rightColX + 18;
const contentWidth = rightColWidth - 18;
for (let i = 0; i < profile.projects.length; i++) {
const proj = profile.projects[i];
// Seitenumbruch prüfen
if (yRight > pageBottom) {
doc.addPage();
yRight = 40;
yRight = this.pdfSectionTitle(doc, 'BERUFSERFAHRUNG (Forts.)', rightColX, yRight, rightColWidth, accentColor);
}
// Timeline-Punkt
doc.circle(timelineX, yRight + 4, 3.5).fill(accentColor);
// Timeline-Linie (bis zum nächsten Eintrag)
if (i < profile.projects.length - 1) {
doc.moveTo(timelineX, yRight + 8).lineTo(timelineX, yRight + 70)
.strokeColor(accentColor).lineWidth(1).stroke();
}
// Zeitraum
const dateRange = this.formatDateRange(proj.fromMonth, proj.fromYear, proj.toMonth, proj.toYear, proj.isCurrent);
doc.font('Helvetica').fontSize(8).fillColor('#888888');
doc.text(dateRange, contentX, yRight, { width: contentWidth });
yRight += 12;
// Rolle
doc.font('Helvetica-Bold').fontSize(10).fillColor(accentColor);
doc.text(proj.role, contentX, yRight, { width: contentWidth });
yRight += doc.heightOfString(proj.role, { width: contentWidth }) + 2;
// Firma + Branche
if (proj.company) {
const companyLine = [proj.company, proj.industry].filter(Boolean).join(' · ');
doc.font('Helvetica').fontSize(9).fillColor('#555555');
doc.text(companyLine, contentX, yRight, { width: contentWidth });
yRight += doc.heightOfString(companyLine, { width: contentWidth }) + 2;
}
// Aufgaben
if (proj.tasks) {
const taskLines = proj.tasks.split('\n').filter((l: string) => l.trim());
doc.font('Helvetica').fontSize(8).fillColor('#444444');
for (const task of taskLines) {
if (yRight > pageBottom) {
doc.addPage();
yRight = 40;
}
const bulletText = `\u2022 ${task.trim()}`;
doc.text(bulletText, contentX + 4, yRight, { width: contentWidth - 8 });
yRight += doc.heightOfString(bulletText, { width: contentWidth - 8 }) + 1;
}
}
yRight += 12;
// Aktualisiere Timeline-Linie (Länge basiert auf tatsächlicher Position)
}
}
// --- FOLGESEITEN: ZERTIFIZIERUNGEN ---
if (profile && profile.certifications.length > 0) {
const certY = Math.max(yLeft, yRight);
let y = certY > pageBottom - 80 ? 40 : certY + 20;
if (certY > pageBottom - 80) {
doc.addPage();
}
y = this.pdfSectionTitle(doc, 'ZERTIFIZIERUNGEN', 40, y, pageWidth - 80, accentColor);
const timelineX = 46;
const contentX = 58;
const contentWidth = pageWidth - 58 - 40;
for (let i = 0; i < profile.certifications.length; i++) {
const cert = profile.certifications[i];
if (y > pageBottom) {
doc.addPage();
y = 40;
}
// Timeline-Punkt
doc.circle(timelineX, y + 4, 3.5).fill(accentColor);
if (i < profile.certifications.length - 1) {
doc.moveTo(timelineX, y + 8).lineTo(timelineX, y + 40)
.strokeColor(accentColor).lineWidth(1).stroke();
}
// Jahr
doc.font('Helvetica').fontSize(8).fillColor('#888888');
doc.text(String(cert.issueYear), contentX, y, { width: contentWidth });
y += 12;
// Titel
doc.font('Helvetica-Bold').fontSize(10).fillColor(accentColor);
doc.text(cert.title, contentX, y, { width: contentWidth });
y += doc.heightOfString(cert.title, { width: contentWidth }) + 2;
// Zertifizierungsstelle
doc.font('Helvetica').fontSize(9).fillColor('#555555');
doc.text(cert.issuingBody, contentX, y, { width: contentWidth });
y += 14;
y += 8;
}
yRight = y;
yLeft = y;
}
// --- FÄHIGKEITEN (Skills als Chips) ---
if (profile && profile.skills.length > 0) {
let y = Math.max(yLeft, yRight);
if (y > pageBottom - 60) {
doc.addPage();
y = 40;
} else {
y += 10;
}
y = this.pdfSectionTitle(doc, 'FÄHIGKEITEN', 40, y, pageWidth - 80, accentColor);
const chipStartX = 40;
const maxX = pageWidth - 40;
let chipX = chipStartX;
const chipHeight = 20;
const chipPadding = 10;
const chipGap = 6;
const lightBg = lightenColor(accentColor, 0.85);
for (const skill of profile.skills) {
doc.font('Helvetica').fontSize(8);
const textWidth = doc.widthOfString(skill);
const chipWidth = textWidth + chipPadding * 2;
if (chipX + chipWidth > maxX) {
chipX = chipStartX;
y += chipHeight + chipGap;
if (y > pageBottom) {
doc.addPage();
y = 40;
}
}
// Chip-Hintergrund (abgerundetes Rechteck)
doc.roundedRect(chipX, y, chipWidth, chipHeight, 10).fill(lightBg);
// Chip-Text
doc.font('Helvetica').fontSize(8).fillColor(accentColor);
doc.text(skill, chipX + chipPadding, y + 5.5, { width: textWidth, lineBreak: false });
chipX += chipWidth + chipGap;
}
}
doc.end();
});
}
// ============================================================
// DOCX Export
// ============================================================
async generateDocx(userId: string, accentColor = '#009688'): Promise<Buffer> {
const data = await this.expertProfileService.getExportData(userId) as ExportData;
const profile = data.expertProfile;
const fullName = `${data.firstName} ${data.lastName}`;
const accentHex = accentColor.replace('#', '');
const lightAccent = lightenColor(accentColor, 0.85).replace('#', '');
const sections: Paragraph[] = [];
// --- Kontakt-Infos für linke Spalte ---
const leftParagraphs: Paragraph[] = [];
// Avatar (rund zugeschnitten)
let avatarImageRun: ImageRun | null = null;
if (data.avatar) {
try {
const avatarRaw = this.base64ToBuffer(data.avatar);
const avatarBuffer = await this.makeCircularAvatar(avatarRaw, 240);
avatarImageRun = new ImageRun({
data: avatarBuffer,
transformation: { width: 120, height: 120 },
type: 'png',
});
} catch (err) {
this.logger.warn('Avatar für DOCX konnte nicht geladen werden', err);
}
}
if (avatarImageRun) {
leftParagraphs.push(
new Paragraph({
children: [avatarImageRun],
alignment: AlignmentType.CENTER,
spacing: { after: 200 },
}),
);
}
// Name
leftParagraphs.push(
new Paragraph({
children: [
new TextRun({
text: fullName,
bold: true,
size: 28,
color: '333333',
}),
],
alignment: AlignmentType.CENTER,
spacing: { after: 100 },
}),
);
// Rolle
if (profile && profile.experiences.length > 0) {
leftParagraphs.push(
new Paragraph({
children: [
new TextRun({
text: profile.experiences[0].area,
size: 20,
color: accentHex,
}),
],
alignment: AlignmentType.CENTER,
spacing: { after: 200 },
}),
);
}
// Kontakt-Sektion
leftParagraphs.push(this.docxSectionHeading('KONTAKT', accentHex));
// Icons für DOCX laden (eingefärbt in Akzentfarbe)
const docxPhoneIcon = this.loadIcon('Phone.png', accentColor);
const docxMobileIcon = this.loadIcon('Mobile.png', accentColor);
const docxMailIcon = this.loadIcon('Mail.png', accentColor);
const docxAddressIcon = this.loadIcon('Address.png', accentColor);
const docxIconSize = 12;
if (data.phone) {
const children: (ImageRun | TextRun)[] = [];
if (docxPhoneIcon) {
children.push(new ImageRun({ data: docxPhoneIcon, transformation: { width: docxIconSize, height: docxIconSize }, type: 'png' }));
}
children.push(new TextRun({ text: ' ' + data.phone, size: 16, color: '555555' }));
leftParagraphs.push(new Paragraph({ children, spacing: { after: 60 } }));
}
if (data.mobile) {
const children: (ImageRun | TextRun)[] = [];
if (docxMobileIcon) {
children.push(new ImageRun({ data: docxMobileIcon, transformation: { width: docxIconSize, height: docxIconSize }, type: 'png' }));
}
children.push(new TextRun({ text: ' ' + data.mobile, size: 16, color: '555555' }));
leftParagraphs.push(new Paragraph({ children, spacing: { after: 60 } }));
}
if (data.email) {
const children: (ImageRun | TextRun)[] = [];
if (docxMailIcon) {
children.push(new ImageRun({ data: docxMailIcon, transformation: { width: docxIconSize, height: docxIconSize }, type: 'png' }));
}
children.push(new TextRun({ text: ' ' + data.email, size: 16, color: '555555' }));
leftParagraphs.push(new Paragraph({ children, spacing: { after: 60 } }));
}
if (data.street || data.city) {
const children: (ImageRun | TextRun)[] = [];
if (docxAddressIcon) {
children.push(new ImageRun({ data: docxAddressIcon, transformation: { width: docxIconSize, height: docxIconSize + 2 }, type: 'png' }));
}
if (data.street) {
children.push(new TextRun({ text: ' ' + data.street, size: 16, color: '555555' }));
}
const cityLine = [data.postalCode, data.city].filter(Boolean).join(' ');
if (cityLine) {
children.push(new TextRun({ text: ' ' + cityLine, size: 16, color: '555555', break: 1 }));
}
leftParagraphs.push(new Paragraph({ children, spacing: { after: 60 } }));
}
// Sprachen
if (profile && profile.languages.length > 0) {
leftParagraphs.push(this.docxSectionHeading('SPRACHEN', accentHex));
for (const lang of profile.languages) {
leftParagraphs.push(
new Paragraph({
children: [
new TextRun({ text: lang.language, size: 18, color: '333333' }),
new TextRun({ text: ` ${lang.level}`, size: 16, color: '777777' }),
],
spacing: { after: 40 },
}),
);
}
}
// Erfahrung (Expertise-Bereiche)
if (profile && profile.experiences.length > 0) {
leftParagraphs.push(this.docxSectionHeading('ERFAHRUNG', accentHex));
for (const exp of profile.experiences) {
const detail = `${exp.years} J.${exp.level ? ' · ' + exp.level : ''}`;
leftParagraphs.push(
new Paragraph({
children: [
new TextRun({ text: exp.area, size: 18, color: '333333' }),
new TextRun({ text: ` ${detail}`, size: 16, color: '777777' }),
],
spacing: { after: 40 },
}),
);
}
}
// --- Rechte Spalte: Berufserfahrung ---
const rightParagraphs: Paragraph[] = [];
if (profile && profile.projects.length > 0) {
rightParagraphs.push(this.docxSectionHeading('BERUFSERFAHRUNG', accentHex));
for (const proj of profile.projects) {
const dateRange = this.formatDateRange(proj.fromMonth, proj.fromYear, proj.toMonth, proj.toYear, proj.isCurrent);
rightParagraphs.push(
new Paragraph({
children: [
new TextRun({ text: dateRange, size: 16, color: '888888', italics: true }),
],
spacing: { before: 160, after: 40 },
}),
);
rightParagraphs.push(
new Paragraph({
children: [
new TextRun({ text: proj.role, bold: true, size: 20, color: accentHex }),
],
spacing: { after: 30 },
}),
);
if (proj.company) {
const companyLine = [proj.company, proj.industry].filter(Boolean).join(' · ');
rightParagraphs.push(
new Paragraph({
children: [
new TextRun({ text: companyLine, size: 18, color: '555555' }),
],
spacing: { after: 40 },
}),
);
}
if (proj.tasks) {
const taskLines = proj.tasks.split('\n').filter((l: string) => l.trim());
for (const task of taskLines) {
rightParagraphs.push(
new Paragraph({
children: [
new TextRun({ text: `\u2022 ${task.trim()}`, size: 16, color: '444444' }),
],
spacing: { after: 20 },
}),
);
}
}
}
}
// --- Tabelle (Zwei-Spalten-Layout) ---
const noBorders = {
top: { style: BorderStyle.NONE, size: 0, color: 'FFFFFF' },
bottom: { style: BorderStyle.NONE, size: 0, color: 'FFFFFF' },
left: { style: BorderStyle.NONE, size: 0, color: 'FFFFFF' },
right: { style: BorderStyle.NONE, size: 0, color: 'FFFFFF' },
};
const layoutTable = new Table({
rows: [
new TableRow({
children: [
new TableCell({
children: leftParagraphs,
width: { size: 30, type: WidthType.PERCENTAGE },
borders: noBorders,
}),
new TableCell({
children: rightParagraphs.length > 0 ? rightParagraphs : [new Paragraph('')],
width: { size: 70, type: WidthType.PERCENTAGE },
borders: noBorders,
}),
],
}),
],
width: { size: 100, type: WidthType.PERCENTAGE },
});
// --- Volle Breite: Zertifizierungen ---
if (profile && profile.certifications.length > 0) {
sections.push(this.docxSectionHeading('ZERTIFIZIERUNGEN', accentHex));
for (const cert of profile.certifications) {
sections.push(
new Paragraph({
children: [
new TextRun({ text: String(cert.issueYear), size: 16, color: '888888', italics: true }),
],
spacing: { before: 120, after: 30 },
}),
);
sections.push(
new Paragraph({
children: [
new TextRun({ text: cert.title, bold: true, size: 20, color: accentHex }),
],
spacing: { after: 20 },
}),
);
sections.push(
new Paragraph({
children: [
new TextRun({ text: cert.issuingBody, size: 18, color: '555555' }),
],
spacing: { after: 60 },
}),
);
}
}
// --- Volle Breite: Fähigkeiten (Skills als Chips) ---
if (profile && profile.skills.length > 0) {
sections.push(this.docxSectionHeading('FÄHIGKEITEN', accentHex));
// Skills als inline TextRuns mit Shading
const skillRuns: TextRun[] = [];
for (let i = 0; i < profile.skills.length; i++) {
skillRuns.push(
new TextRun({
text: ` ${profile.skills[i]} `,
size: 16,
color: accentHex,
shading: { type: ShadingType.CLEAR, color: 'auto', fill: lightAccent },
}),
);
if (i < profile.skills.length - 1) {
skillRuns.push(new TextRun({ text: ' ', size: 16 }));
}
}
sections.push(
new Paragraph({
children: skillRuns,
spacing: { before: 100, after: 100 },
}),
);
}
// --- Dokument zusammenstellen ---
const document = new Document({
styles: {
default: {
document: {
run: {
font: 'Arial',
},
},
},
},
sections: [
{
properties: {
page: {
margin: { top: 720, bottom: 720, left: 720, right: 720 },
},
},
children: [layoutTable, ...sections],
},
],
});
const buffer = await Packer.toBuffer(document);
this.logger.log(`DOCX generiert für User ${userId}: ${buffer.length} Bytes`);
return buffer;
}
// ============================================================
// PDF-Hilfsfunktionen
// ============================================================
private pdfSectionTitle(
doc: PDFKit.PDFDocument,
title: string,
x: number,
y: number,
_width: number,
accentColor: string,
): number {
doc.font('Helvetica-Bold').fontSize(10).fillColor('#333333');
doc.text(title, x, y);
const titleWidth = doc.widthOfString(title);
y += 14;
doc.moveTo(x, y).lineTo(x + titleWidth + 4, y)
.strokeColor(accentColor).lineWidth(2).stroke();
y += 8;
return y;
}
private pdfContactText(
doc: PDFKit.PDFDocument,
text: string,
x: number,
y: number,
width: number,
): number {
doc.font('Helvetica').fontSize(8).fillColor('#555555');
doc.text(text, x, y, { width });
const textHeight = doc.heightOfString(text, { width });
return y + Math.max(textHeight, 10) + 3;
}
// ============================================================
// DOCX-Hilfsfunktionen
// ============================================================
private docxSectionHeading(title: string, accentHex: string): Paragraph {
return new Paragraph({
children: [
new TextRun({
text: title,
bold: true,
size: 22,
color: '333333',
}),
],
heading: HeadingLevel.HEADING_2,
spacing: { before: 300, after: 80 },
border: {
bottom: { style: BorderStyle.SINGLE, size: 3, color: accentHex, space: 4 },
},
});
}
// ============================================================
// Utility
// ============================================================
private formatDateRange(
fromMonth: number,
fromYear: number,
toMonth: number | null,
toYear: number | null,
isCurrent: boolean,
): string {
const from = `${String(fromMonth).padStart(2, '0')}/${fromYear}`;
if (isCurrent) return `${from} - heute`;
if (toMonth && toYear) return `${from} - ${String(toMonth).padStart(2, '0')}/${toYear}`;
return from;
}
private base64ToBuffer(dataUrl: string): Buffer {
// Entferne Data-URL-Prefix (z.B. "data:image/png;base64,")
const base64Data = dataUrl.includes(',') ? dataUrl.split(',')[1] : dataUrl;
return Buffer.from(base64Data, 'base64');
}
}

View file

@ -0,0 +1,298 @@
import {
Controller,
Get,
Post,
Body,
Query,
Logger,
UseGuards,
BadRequestException,
} from '@nestjs/common';
import { ApiTags, ApiOperation } from '@nestjs/swagger';
import { Roles } from '../../common/decorators/roles.decorator';
import { RolesGuard } from '../../common/guards/roles.guard';
import { randomUUID } from 'crypto';
import { RedisService } from '../../redis/redis.service';
/**
* Ein externer Link fuer die Sidebar-Navigation.
* Icons werden im Frontend automatisch als Favicon der URL geladen.
*/
interface ExternalLink {
id: string;
label: string;
url: string;
sortOrder: number;
customIcon?: string; // Optional: Base64-encoded custom icon
}
const EXTERNAL_LINKS_KEY = 'platform_external_links';
const BRANDING_LOGO_KEY = 'platform_branding_logo';
@ApiTags('Settings')
@Controller('settings')
export class SettingsController {
private readonly logger = new Logger(SettingsController.name);
constructor(private readonly redis: RedisService) {}
/**
* GET /api/v1/settings/external-links
* Externe Links fuer die Sidebar lesen (jeder authentifizierte User).
*/
@Get('external-links')
@ApiOperation({ summary: 'Externe Links lesen' })
async getExternalLinks(): Promise<ExternalLink[]> {
const raw = await this.redis.get(EXTERNAL_LINKS_KEY);
if (!raw) return [];
try {
return JSON.parse(raw) as ExternalLink[];
} catch {
return [];
}
}
/**
* POST /api/v1/settings/external-links
* Externe Links speichern (nur PLATFORM_ADMIN).
* Erwartet ein Array von Links.
*/
@Post('external-links')
@Roles('PLATFORM_ADMIN')
@UseGuards(RolesGuard)
@ApiOperation({ summary: 'Externe Links speichern (Admin)' })
async saveExternalLinks(
@Body() body: { links: ExternalLink[] },
): Promise<{ success: boolean; count: number }> {
if (!Array.isArray(body.links)) {
throw new BadRequestException('links muss ein Array sein');
}
// Validierung
for (const link of body.links) {
if (!link.label?.trim()) {
throw new BadRequestException('Jeder Link braucht ein Label');
}
if (!link.url?.trim()) {
throw new BadRequestException('Jeder Link braucht eine URL');
}
}
// Sortierung sicherstellen
const sorted = body.links.map((link, index) => ({
id: link.id || randomUUID(),
label: link.label.trim(),
url: link.url.trim(),
sortOrder: link.sortOrder ?? index,
...(link.customIcon && { customIcon: link.customIcon }),
}));
sorted.sort((a, b) => a.sortOrder - b.sortOrder);
await this.redis.set(EXTERNAL_LINKS_KEY, JSON.stringify(sorted));
this.logger.log(`${sorted.length} externe Links gespeichert`);
return { success: true, count: sorted.length };
}
/**
* GET /api/v1/settings/branding
* Branding-Einstellungen lesen (Logo, Sidebar-Farbe etc.).
*/
@Get('branding')
@ApiOperation({ summary: 'Branding-Einstellungen lesen' })
async getBranding(): Promise<{
logo: string | null;
sidebarColor: string | null;
}> {
const raw = await this.redis.get(BRANDING_LOGO_KEY);
if (!raw) return { logo: null, sidebarColor: null };
try {
const data = JSON.parse(raw);
return {
logo: data.logo || null,
sidebarColor: data.sidebarColor || null,
};
} catch {
// Legacy: nur Logo als String
return { logo: raw, sidebarColor: null };
}
}
/**
* POST /api/v1/settings/branding
* Branding-Einstellungen speichern (nur PLATFORM_ADMIN).
*/
@Post('branding')
@Roles('PLATFORM_ADMIN')
@UseGuards(RolesGuard)
@ApiOperation({ summary: 'Branding-Einstellungen speichern (Admin)' })
async saveBranding(
@Body() body: { logo?: string | null; sidebarColor?: string | null },
): Promise<{ success: boolean }> {
if (body.logo && body.logo.length > 500_000) {
throw new BadRequestException('Logo darf maximal 500KB gross sein');
}
const data = {
logo: body.logo || null,
sidebarColor: body.sidebarColor || null,
};
await this.redis.set(BRANDING_LOGO_KEY, JSON.stringify(data));
this.logger.log('Branding aktualisiert');
return { success: true };
}
/**
* GET /api/v1/settings/favicon?url=https://example.com
* Favicon-URL fuer eine beliebige Webseite ermitteln.
* Parst die HTML-Seite nach <link rel="icon"> Tags und cached das Ergebnis.
*/
@Get('favicon')
@ApiOperation({ summary: 'Favicon-URL fuer eine Webseite ermitteln' })
async getFavicon(
@Query('url') url: string,
): Promise<{ faviconUrl: string | null }> {
if (!url) {
throw new BadRequestException('url Parameter fehlt');
}
// Cache pruefen (24h)
const cacheKey = `favicon:${url}`;
const cached = await this.redis.get(cacheKey);
if (cached !== null) {
return { faviconUrl: cached || null };
}
const faviconUrl = await this.discoverFavicon(url);
// In Redis cachen (24h), auch leeres Ergebnis cachen
await this.redis.set(cacheKey, faviconUrl || '', 86400);
return { faviconUrl };
}
/**
* Versucht das Favicon einer Webseite zu finden:
* 1. HTML der Seite laden und <link rel="icon"> parsen
* 2. Fallback: /favicon.ico pruefen
*/
private async discoverFavicon(urlStr: string): Promise<string | null> {
try {
const parsed = new URL(urlStr);
const origin = parsed.origin;
// 1. HTML laden und <link rel="icon"> suchen
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
const res = await fetch(urlStr, {
signal: controller.signal,
headers: {
'User-Agent': 'Mozilla/5.0 (compatible; INSIGHT/1.0)',
Accept: 'text/html',
},
redirect: 'follow',
});
clearTimeout(timeout);
if (res.ok) {
const html = await res.text();
const iconUrl = this.parseFaviconFromHtml(html, origin);
if (iconUrl) {
return iconUrl;
}
}
} catch (e) {
this.logger.debug(`HTML fetch fehlgeschlagen fuer ${urlStr}: ${e}`);
}
// 2. Fallback: /favicon.ico direkt pruefen
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 3000);
const icoUrl = `${origin}/favicon.ico`;
const res = await fetch(icoUrl, {
method: 'HEAD',
signal: controller.signal,
redirect: 'follow',
});
clearTimeout(timeout);
if (res.ok) {
const contentType = res.headers.get('content-type') || '';
if (
contentType.includes('image') ||
contentType.includes('icon') ||
contentType.includes('octet-stream')
) {
return icoUrl;
}
}
} catch (e) {
this.logger.debug(`favicon.ico check fehlgeschlagen fuer ${origin}: ${e}`);
}
return null;
} catch {
return null;
}
}
/**
* Parst HTML nach <link rel="icon"> oder <link rel="shortcut icon"> Tags.
*/
private parseFaviconFromHtml(html: string, origin: string): string | null {
// Nur den <head> Bereich betrachten (Performance)
const headMatch = html.match(/<head[\s>]([\s\S]*?)<\/head>/i);
const headHtml = headMatch ? headMatch[1] : html.slice(0, 10000);
// Alle <link> Tags mit rel="icon" oder rel="shortcut icon" finden
const linkRegex =
/<link\s[^>]*rel\s*=\s*["'](?:shortcut\s+)?icon["'][^>]*>/gi;
const matches = headHtml.match(linkRegex);
if (!matches || matches.length === 0) return null;
// href aus dem besten Match extrahieren
// Bevorzuge groessere Icons (sizes Attribut)
let bestHref: string | null = null;
let bestSize = 0;
for (const tag of matches) {
const hrefMatch = tag.match(/href\s*=\s*["']([^"']+)["']/i);
if (!hrefMatch) continue;
const href = hrefMatch[1];
// Groesse parsen
const sizeMatch = tag.match(/sizes\s*=\s*["'](\d+)x\d+["']/i);
const size = sizeMatch ? parseInt(sizeMatch[1], 10) : 16;
if (!bestHref || size > bestSize) {
bestHref = href;
bestSize = size;
}
}
if (!bestHref) return null;
// Relative URLs aufloesen
if (bestHref.startsWith('//')) {
return `https:${bestHref}`;
}
if (bestHref.startsWith('/')) {
return `${origin}${bestHref}`;
}
if (bestHref.startsWith('http')) {
return bestHref;
}
return `${origin}/${bestHref}`;
}
}

View file

@ -0,0 +1,7 @@
import { Module } from '@nestjs/common';
import { SettingsController } from './settings.controller';
@Module({
controllers: [SettingsController],
})
export class SettingsModule {}

View file

@ -0,0 +1,19 @@
import { IsNotEmpty, IsOptional, IsString, IsUUID, IsIn } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class AddMemberDto {
@ApiProperty({ example: '550e8400-e29b-41d4-a716-446655440000' })
@IsUUID()
@IsNotEmpty()
userId!: string;
@ApiProperty({
example: 'MEMBER',
enum: ['ADMIN', 'MEMBER', 'VIEWER'],
required: false,
})
@IsOptional()
@IsString()
@IsIn(['ADMIN', 'MEMBER', 'VIEWER'])
role?: string;
}

View file

@ -0,0 +1,37 @@
import {
IsNotEmpty,
IsOptional,
IsString,
Matches,
MaxLength,
IsObject,
} from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class CreateTenantDto {
@ApiProperty({ example: 'ACME Corporation' })
@IsString()
@IsNotEmpty()
@MaxLength(200)
name!: string;
@ApiProperty({
example: 'acme-corp',
description: 'URL-freundlicher Kurzname (a-z, 0-9, Bindestrich)',
})
@IsString()
@IsNotEmpty()
@MaxLength(50)
@Matches(/^[a-z0-9][a-z0-9-]*[a-z0-9]$/, {
message: 'Slug: nur Kleinbuchstaben, Zahlen und Bindestriche erlaubt',
})
slug!: string;
@ApiProperty({
example: { locale: 'de-DE', timezone: 'Europe/Berlin' },
required: false,
})
@IsOptional()
@IsObject()
settings?: Record<string, unknown>;
}

View file

@ -0,0 +1,29 @@
import {
IsBoolean,
IsObject,
IsOptional,
IsString,
MaxLength,
} from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class UpdateTenantDto {
@ApiProperty({ example: 'ACME Corp. GmbH', required: false })
@IsOptional()
@IsString()
@MaxLength(200)
name?: string;
@ApiProperty({ example: true, required: false })
@IsOptional()
@IsBoolean()
isActive?: boolean;
@ApiProperty({
example: { locale: 'de-DE', timezone: 'Europe/Berlin' },
required: false,
})
@IsOptional()
@IsObject()
settings?: Record<string, unknown>;
}

View file

@ -0,0 +1,113 @@
import {
Controller,
Get,
Post,
Patch,
Delete,
Body,
Param,
Query,
UseGuards,
ParseUUIDPipe,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { TenantsService } from './tenants.service';
import { CreateTenantDto } from './dto/create-tenant.dto';
import { UpdateTenantDto } from './dto/update-tenant.dto';
import { AddMemberDto } from './dto/add-member.dto';
import { Roles } from '../../common/decorators/roles.decorator';
import { RolesGuard } from '../../common/guards/roles.guard';
@ApiTags('Mandanten')
@ApiBearerAuth('access-token')
@Controller('tenants')
export class TenantsController {
constructor(private readonly tenantsService: TenantsService) {}
/**
* GET /api/v1/tenants
* Alle Mandanten auflisten (nur PLATFORM_ADMIN).
*/
@Get()
@Roles('PLATFORM_ADMIN')
@UseGuards(RolesGuard)
@ApiOperation({ summary: 'Alle Mandanten auflisten (Admin)' })
async findAll(
@Query('page') page?: string,
@Query('limit') limit?: string,
) {
return this.tenantsService.findAll(
Number(page) || 1,
Number(limit) || 20,
);
}
/**
* GET /api/v1/tenants/:id
* Mandant nach ID (nur PLATFORM_ADMIN).
*/
@Get(':id')
@Roles('PLATFORM_ADMIN')
@UseGuards(RolesGuard)
@ApiOperation({ summary: 'Mandant nach ID abrufen (Admin)' })
async findById(@Param('id', ParseUUIDPipe) id: string) {
return this.tenantsService.findById(id);
}
/**
* POST /api/v1/tenants
* Neuen Mandant anlegen (nur PLATFORM_ADMIN).
*/
@Post()
@Roles('PLATFORM_ADMIN')
@UseGuards(RolesGuard)
@ApiOperation({ summary: 'Neuen Mandanten anlegen (Admin)' })
async create(@Body() dto: CreateTenantDto) {
return this.tenantsService.create(dto);
}
/**
* PATCH /api/v1/tenants/:id
* Mandant aktualisieren (nur PLATFORM_ADMIN).
*/
@Patch(':id')
@Roles('PLATFORM_ADMIN')
@UseGuards(RolesGuard)
@ApiOperation({ summary: 'Mandant aktualisieren (Admin)' })
async update(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateTenantDto,
) {
return this.tenantsService.update(id, dto);
}
/**
* POST /api/v1/tenants/:id/members
* User einem Mandant zuweisen (nur PLATFORM_ADMIN).
*/
@Post(':id/members')
@Roles('PLATFORM_ADMIN')
@UseGuards(RolesGuard)
@ApiOperation({ summary: 'Benutzer zu Mandant hinzufuegen (Admin)' })
async addMember(
@Param('id', ParseUUIDPipe) tenantId: string,
@Body() dto: AddMemberDto,
) {
return this.tenantsService.addMember(tenantId, dto.userId, dto.role);
}
/**
* DELETE /api/v1/tenants/:id/members/:userId
* User aus Mandant entfernen (nur PLATFORM_ADMIN).
*/
@Delete(':id/members/:userId')
@Roles('PLATFORM_ADMIN')
@UseGuards(RolesGuard)
@ApiOperation({ summary: 'Benutzer aus Mandant entfernen (Admin)' })
async removeMember(
@Param('id', ParseUUIDPipe) tenantId: string,
@Param('userId', ParseUUIDPipe) userId: string,
) {
return this.tenantsService.removeMember(tenantId, userId);
}
}

View file

@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { TenantsController } from './tenants.controller';
import { TenantsService } from './tenants.service';
@Module({
controllers: [TenantsController],
providers: [TenantsService],
exports: [TenantsService],
})
export class TenantsModule {}

View file

@ -0,0 +1,167 @@
import {
Injectable,
ConflictException,
NotFoundException,
Logger,
} from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../../prisma/prisma.service';
import { CreateTenantDto } from './dto/create-tenant.dto';
import { UpdateTenantDto } from './dto/update-tenant.dto';
@Injectable()
export class TenantsService {
private readonly logger = new Logger(TenantsService.name);
constructor(private readonly prisma: PrismaService) {}
/**
* Neuen Tenant (Mandant) anlegen.
*/
async create(dto: CreateTenantDto) {
// Slug-Duplikat pruefen
const existing = await this.prisma.tenant.findUnique({
where: { slug: dto.slug },
});
if (existing) {
throw new ConflictException(`Tenant-Slug "${dto.slug}" bereits vergeben`);
}
const tenant = await this.prisma.tenant.create({
data: {
name: dto.name,
slug: dto.slug,
isActive: true,
settings: (dto.settings ?? {}) as Prisma.InputJsonValue,
},
});
this.logger.log(`Tenant erstellt: ${tenant.name} (${tenant.slug})`);
// TODO: Tenant-Datenbank erstellen (tenant_{slug})
// Das wird spaeter per Prisma Migrate automatisiert
return tenant;
}
/**
* Tenant nach ID finden.
*/
async findById(id: string) {
const tenant = await this.prisma.tenant.findUnique({
where: { id },
include: {
_count: {
select: { members: true },
},
},
});
if (!tenant) {
throw new NotFoundException('Mandant nicht gefunden');
}
return {
...tenant,
memberCount: tenant._count.members,
};
}
/**
* Alle Tenants auflisten.
*/
async findAll(page = 1, limit = 20) {
const skip = (page - 1) * limit;
const [tenants, total] = await Promise.all([
this.prisma.tenant.findMany({
skip,
take: limit,
orderBy: { createdAt: 'desc' },
include: {
_count: {
select: { members: true },
},
},
}),
this.prisma.tenant.count(),
]);
return {
data: tenants.map((t) => ({
id: t.id,
name: t.name,
slug: t.slug,
isActive: t.isActive,
memberCount: t._count.members,
createdAt: t.createdAt,
})),
meta: {
total,
page,
limit,
totalPages: Math.ceil(total / limit),
},
};
}
/**
* Tenant aktualisieren.
*/
async update(id: string, dto: UpdateTenantDto) {
const tenant = await this.prisma.tenant.findUnique({ where: { id } });
if (!tenant) {
throw new NotFoundException('Mandant nicht gefunden');
}
return this.prisma.tenant.update({
where: { id },
data: {
name: dto.name,
isActive: dto.isActive,
settings: dto.settings as Prisma.InputJsonValue,
},
});
}
/**
* User einem Tenant zuweisen.
*/
async addMember(tenantId: string, userId: string, role = 'MEMBER') {
// Pruefen ob bereits Mitglied
const existing = await this.prisma.tenantMembership.findUnique({
where: {
userId_tenantId: { userId, tenantId },
},
});
if (existing) {
throw new ConflictException('Benutzer ist bereits Mitglied');
}
return this.prisma.tenantMembership.create({
data: {
userId,
tenantId,
tenantRole: role,
isActive: true,
},
include: {
user: { select: { email: true, firstName: true, lastName: true } },
tenant: { select: { name: true, slug: true } },
},
});
}
/**
* User aus Tenant entfernen (Soft-Delete).
*/
async removeMember(tenantId: string, userId: string) {
return this.prisma.tenantMembership.update({
where: {
userId_tenantId: { userId, tenantId },
},
data: { isActive: false },
});
}
}

View file

@ -0,0 +1,25 @@
import { IsNotEmpty, IsString, MinLength, MaxLength } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class ChangePasswordDto {
@ApiProperty({
example: 'AltesPasswort123!',
description: 'Aktuelles Passwort',
})
@IsString()
@IsNotEmpty({ message: 'Aktuelles Passwort darf nicht leer sein' })
@MinLength(8, { message: 'Passwort muss mindestens 8 Zeichen lang sein' })
currentPassword!: string;
@ApiProperty({
example: 'NeuesSicheresPasswort456!',
description: 'Neues Passwort (mindestens 8 Zeichen)',
})
@IsString()
@IsNotEmpty({ message: 'Neues Passwort darf nicht leer sein' })
@MinLength(8, {
message: 'Neues Passwort muss mindestens 8 Zeichen lang sein',
})
@MaxLength(128, { message: 'Passwort darf maximal 128 Zeichen lang sein' })
newPassword!: string;
}

View file

@ -0,0 +1,46 @@
import {
IsEmail,
IsNotEmpty,
IsOptional,
IsString,
MinLength,
MaxLength,
IsIn,
} from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class CreateUserDto {
@ApiProperty({ example: 'max.mustermann@xinion.de' })
@IsEmail({}, { message: 'Bitte gültige E-Mail-Adresse angeben' })
@IsNotEmpty()
email!: string;
@ApiProperty({ example: 'SicheresPasswort123!' })
@IsString()
@IsNotEmpty()
@MinLength(8, { message: 'Passwort muss mindestens 8 Zeichen lang sein' })
@MaxLength(128, { message: 'Passwort darf maximal 128 Zeichen lang sein' })
password!: string;
@ApiProperty({ example: 'Max' })
@IsString()
@IsNotEmpty()
@MaxLength(100)
firstName!: string;
@ApiProperty({ example: 'Mustermann' })
@IsString()
@IsNotEmpty()
@MaxLength(100)
lastName!: string;
@ApiProperty({
example: 'USER',
enum: ['PLATFORM_ADMIN', 'TENANT_ADMIN', 'USER'],
required: false,
})
@IsOptional()
@IsString()
@IsIn(['PLATFORM_ADMIN', 'TENANT_ADMIN', 'USER'])
role?: string;
}

View file

@ -0,0 +1,80 @@
import {
IsBoolean,
IsOptional,
IsString,
MaxLength,
Matches,
ValidateIf,
} from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class UpdateUserDto {
@ApiProperty({ example: 'Max', required: false })
@IsOptional()
@IsString()
@MaxLength(100)
firstName?: string;
@ApiProperty({ example: 'Mustermann', required: false })
@IsOptional()
@IsString()
@MaxLength(100)
lastName?: string;
@ApiProperty({ example: true, required: false })
@IsOptional()
@IsBoolean()
isActive?: boolean;
@ApiProperty({
description: 'Profilbild als Base64 Data-URL (max. 400KB), null zum Entfernen',
example: 'data:image/png;base64,iVBORw0KGgo...',
required: false,
nullable: true,
})
@IsOptional()
@ValidateIf((o: UpdateUserDto) => o.avatar !== null)
@IsString()
@MaxLength(400000, { message: 'Profilbild darf maximal 400KB groß sein' })
@Matches(/^data:image\/(jpeg|png|gif|webp);base64,[A-Za-z0-9+/=]+$/, {
message: 'Profilbild muss ein gültiges Base64-Bild sein (JPEG, PNG, GIF oder WebP)',
})
avatar?: string | null;
// --- Kontaktdaten ---
@ApiProperty({ example: '+49 123 456789', required: false, nullable: true })
@IsOptional()
@ValidateIf((o: UpdateUserDto) => o.phone !== null)
@IsString()
@MaxLength(30)
phone?: string | null;
@ApiProperty({ example: '+49 170 1234567', required: false, nullable: true })
@IsOptional()
@ValidateIf((o: UpdateUserDto) => o.mobile !== null)
@IsString()
@MaxLength(30)
mobile?: string | null;
// --- Adresse ---
@ApiProperty({ example: 'Musterstraße 42', required: false, nullable: true })
@IsOptional()
@ValidateIf((o: UpdateUserDto) => o.street !== null)
@IsString()
@MaxLength(200)
street?: string | null;
@ApiProperty({ example: '12345', required: false, nullable: true })
@IsOptional()
@ValidateIf((o: UpdateUserDto) => o.postalCode !== null)
@IsString()
@MaxLength(10)
postalCode?: string | null;
@ApiProperty({ example: 'Berlin', required: false, nullable: true })
@IsOptional()
@ValidateIf((o: UpdateUserDto) => o.city !== null)
@IsString()
@MaxLength(100)
city?: string | null;
}

View file

@ -0,0 +1,140 @@
import {
Controller,
Get,
Post,
Patch,
Delete,
Body,
Param,
Query,
UseGuards,
ParseUUIDPipe,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { UsersService } from './users.service';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
import { ChangePasswordDto } from './dto/change-password.dto';
import { Roles } from '../../common/decorators/roles.decorator';
import { CurrentUser, JwtPayload } from '../../common/decorators/current-user.decorator';
import { RolesGuard } from '../../common/guards/roles.guard';
@ApiTags('Benutzer')
@ApiBearerAuth('access-token')
@Controller('users')
export class UsersController {
constructor(private readonly usersService: UsersService) {}
/**
* GET /api/v1/users/me
* Eigenes Profil abrufen.
*/
@Get('me')
@ApiOperation({ summary: 'Eigenes Profil abrufen' })
async getProfile(@CurrentUser('sub') userId: string) {
return this.usersService.findById(userId);
}
/**
* PATCH /api/v1/users/me
* Eigenes Profil aktualisieren (firstName, lastName).
*/
@Patch('me')
@ApiOperation({ summary: 'Eigenes Profil aktualisieren' })
async updateProfile(
@CurrentUser('sub') userId: string,
@Body() dto: UpdateUserDto,
) {
return this.usersService.updateProfile(userId, dto);
}
/**
* POST /api/v1/users/me/change-password
* Eigenes Passwort ändern.
*/
@Post('me/change-password')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Eigenes Passwort ändern' })
async changePassword(
@CurrentUser('sub') userId: string,
@Body() dto: ChangePasswordDto,
) {
await this.usersService.changePassword(
userId,
dto.currentPassword,
dto.newPassword,
);
return { message: 'Passwort erfolgreich geändert' };
}
/**
* GET /api/v1/users
* Alle User auflisten (nur PLATFORM_ADMIN).
*/
@Get()
@Roles('PLATFORM_ADMIN')
@UseGuards(RolesGuard)
@ApiOperation({ summary: 'Alle Benutzer auflisten (Admin)' })
async findAll(
@Query('page') page?: string,
@Query('limit') limit?: string,
) {
return this.usersService.findAll(
Number(page) || 1,
Number(limit) || 20,
);
}
/**
* GET /api/v1/users/:id
* User nach ID (nur PLATFORM_ADMIN).
*/
@Get(':id')
@Roles('PLATFORM_ADMIN')
@UseGuards(RolesGuard)
@ApiOperation({ summary: 'Benutzer nach ID abrufen (Admin)' })
async findById(@Param('id', ParseUUIDPipe) id: string) {
return this.usersService.findById(id);
}
/**
* POST /api/v1/users
* Neuen User anlegen (nur PLATFORM_ADMIN).
*/
@Post()
@Roles('PLATFORM_ADMIN')
@UseGuards(RolesGuard)
@ApiOperation({ summary: 'Neuen Benutzer anlegen (Admin)' })
async create(@Body() dto: CreateUserDto) {
return this.usersService.create(dto);
}
/**
* PATCH /api/v1/users/:id
* User aktualisieren (nur PLATFORM_ADMIN).
*/
@Patch(':id')
@Roles('PLATFORM_ADMIN')
@UseGuards(RolesGuard)
@ApiOperation({ summary: 'Benutzer aktualisieren (Admin)' })
async update(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateUserDto,
) {
return this.usersService.update(id, dto);
}
/**
* DELETE /api/v1/users/:id
* User löschen (nur PLATFORM_ADMIN).
*/
@Delete(':id')
@Roles('PLATFORM_ADMIN')
@UseGuards(RolesGuard)
@ApiOperation({ summary: 'Benutzer löschen (Admin)' })
async delete(@Param('id', ParseUUIDPipe) id: string) {
return this.usersService.delete(id);
}
}

View file

@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';
@Module({
controllers: [UsersController],
providers: [UsersService],
exports: [UsersService],
})
export class UsersModule {}

View file

@ -0,0 +1,292 @@
import {
Injectable,
ConflictException,
NotFoundException,
UnauthorizedException,
Logger,
} from '@nestjs/common';
import * as bcrypt from 'bcrypt';
import { ConfigService } from '@nestjs/config';
import { PrismaService } from '../../prisma/prisma.service';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
@Injectable()
export class UsersService {
private readonly logger = new Logger(UsersService.name);
constructor(
private readonly prisma: PrismaService,
private readonly config: ConfigService,
) {}
/**
* Neuen User anlegen (mit lokalem Auth-Provider).
*/
async create(dto: CreateUserDto) {
// Email-Duplikat pruefen
const existing = await this.prisma.user.findUnique({
where: { email: dto.email.toLowerCase() },
});
if (existing) {
throw new ConflictException('E-Mail-Adresse bereits vergeben');
}
// Passwort hashen (Bcrypt Cost 12)
const bcryptCost = this.config.get<number>('BCRYPT_COST', 12);
const passwordHash = await bcrypt.hash(dto.password, bcryptCost);
// User + AuthProvider in einer Transaktion anlegen
const user = await this.prisma.user.create({
data: {
email: dto.email.toLowerCase(),
firstName: dto.firstName,
lastName: dto.lastName,
role: dto.role ?? 'USER',
isActive: true,
authProvider: {
create: {
provider: 'LOCAL',
passwordHash,
},
},
},
include: {
authProvider: {
select: { provider: true, createdAt: true },
},
},
});
this.logger.log(`User erstellt: ${user.email} (${user.role})`);
// Passwort-Hash nicht zurückgeben
return {
id: user.id,
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
role: user.role,
isActive: user.isActive,
createdAt: user.createdAt,
};
}
/**
* User nach ID finden.
*/
async findById(id: string) {
const user = await this.prisma.user.findUnique({
where: { id },
include: {
tenantMemberships: {
include: { tenant: { select: { id: true, name: true, slug: true } } },
where: { isActive: true },
},
},
});
if (!user) {
throw new NotFoundException('Benutzer nicht gefunden');
}
return {
id: user.id,
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
avatar: user.avatar,
phone: user.phone,
mobile: user.mobile,
street: user.street,
postalCode: user.postalCode,
city: user.city,
role: user.role,
isActive: user.isActive,
twoFactorEnabled: user.twoFactorEnabled,
tenants: user.tenantMemberships.map((m) => ({
id: m.tenant.id,
name: m.tenant.name,
slug: m.tenant.slug,
role: m.tenantRole,
})),
lastLogin: user.lastLogin,
createdAt: user.createdAt,
};
}
/**
* User aktualisieren.
*/
async update(id: string, dto: UpdateUserDto) {
const user = await this.prisma.user.findUnique({ where: { id } });
if (!user) {
throw new NotFoundException('Benutzer nicht gefunden');
}
const updated = await this.prisma.user.update({
where: { id },
data: {
firstName: dto.firstName,
lastName: dto.lastName,
isActive: dto.isActive,
...(dto.avatar !== undefined && { avatar: dto.avatar }),
...(dto.phone !== undefined && { phone: dto.phone }),
...(dto.mobile !== undefined && { mobile: dto.mobile }),
...(dto.street !== undefined && { street: dto.street }),
...(dto.postalCode !== undefined && { postalCode: dto.postalCode }),
...(dto.city !== undefined && { city: dto.city }),
},
});
return {
id: updated.id,
email: updated.email,
firstName: updated.firstName,
lastName: updated.lastName,
avatar: updated.avatar,
phone: updated.phone,
mobile: updated.mobile,
street: updated.street,
postalCode: updated.postalCode,
city: updated.city,
role: updated.role,
isActive: updated.isActive,
};
}
/**
* Eigenes Profil aktualisieren (nur firstName, lastName).
*/
async updateProfile(userId: string, dto: UpdateUserDto) {
const user = await this.prisma.user.findUnique({ where: { id: userId } });
if (!user) {
throw new NotFoundException('Benutzer nicht gefunden');
}
const updated = await this.prisma.user.update({
where: { id: userId },
data: {
...(dto.firstName !== undefined && { firstName: dto.firstName }),
...(dto.lastName !== undefined && { lastName: dto.lastName }),
...(dto.avatar !== undefined && { avatar: dto.avatar }),
...(dto.phone !== undefined && { phone: dto.phone }),
...(dto.mobile !== undefined && { mobile: dto.mobile }),
...(dto.street !== undefined && { street: dto.street }),
...(dto.postalCode !== undefined && { postalCode: dto.postalCode }),
...(dto.city !== undefined && { city: dto.city }),
},
});
return {
id: updated.id,
email: updated.email,
firstName: updated.firstName,
lastName: updated.lastName,
avatar: updated.avatar,
phone: updated.phone,
mobile: updated.mobile,
street: updated.street,
postalCode: updated.postalCode,
city: updated.city,
role: updated.role,
isActive: updated.isActive,
twoFactorEnabled: updated.twoFactorEnabled,
};
}
/**
* Eigenes Passwort ändern (mit Verifikation des aktuellen Passworts).
*/
async changePassword(
userId: string,
currentPassword: string,
newPassword: string,
): Promise<void> {
const user = await this.prisma.user.findUnique({
where: { id: userId },
include: { authProvider: true },
});
if (!user) {
throw new NotFoundException('Benutzer nicht gefunden');
}
const localAuth = user.authProvider.find((ap) => ap.provider === 'LOCAL');
if (!localAuth?.passwordHash) {
throw new NotFoundException('Kein lokaler Auth-Provider gefunden');
}
// Aktuelles Passwort verifizieren
const isCurrentValid = await bcrypt.compare(
currentPassword,
localAuth.passwordHash,
);
if (!isCurrentValid) {
throw new UnauthorizedException('Aktuelles Passwort ist falsch');
}
// Neues Passwort hashen (Bcrypt Cost 12)
const bcryptCost = this.config.get<number>('BCRYPT_COST', 12);
const newHash = await bcrypt.hash(newPassword, bcryptCost);
// Passwort aktualisieren
await this.prisma.authProvider.update({
where: { id: localAuth.id },
data: { passwordHash: newHash },
});
this.logger.log(`Passwort geändert für User ${user.email}`);
}
/**
* User löschen (inkl. Auth-Provider, Memberships, Profil via Cascade).
*/
async delete(id: string) {
const user = await this.prisma.user.findUnique({ where: { id } });
if (!user) {
throw new NotFoundException('Benutzer nicht gefunden');
}
await this.prisma.user.delete({ where: { id } });
this.logger.log(`User gelöscht: ${user.email}`);
return { message: 'Benutzer wurde gelöscht' };
}
/**
* Alle User auflisten (für Admin).
*/
async findAll(page = 1, limit = 20) {
const skip = (page - 1) * limit;
const [users, total] = await Promise.all([
this.prisma.user.findMany({
skip,
take: limit,
orderBy: { createdAt: 'desc' },
select: {
id: true,
email: true,
firstName: true,
lastName: true,
role: true,
isActive: true,
lastLogin: true,
createdAt: true,
},
}),
this.prisma.user.count(),
]);
return {
data: users,
meta: {
total,
page,
limit,
totalPages: Math.ceil(total / limit),
},
};
}
}

View file

@ -0,0 +1,72 @@
import { Controller, Get } from '@nestjs/common';
import { ApiTags, ApiOperation } from '@nestjs/swagger';
import { Public } from '../common/decorators/public.decorator';
import { PrismaService } from '../prisma/prisma.service';
import { RedisService } from '../redis/redis.service';
interface HealthResponse {
status: 'ok' | 'error';
timestamp: string;
version: string;
services: {
database: 'up' | 'down';
redis: 'up' | 'down';
};
}
@ApiTags('Health')
@Controller('health')
export class HealthController {
constructor(
private readonly prisma: PrismaService,
private readonly redis: RedisService,
) {}
@Get()
@Public()
@ApiOperation({ summary: 'Health-Check fuer alle Services' })
async check(): Promise<HealthResponse> {
const [dbStatus, redisStatus] = await Promise.allSettled([
this.checkDatabase(),
this.checkRedis(),
]);
const allUp =
dbStatus.status === 'fulfilled' &&
dbStatus.value &&
redisStatus.status === 'fulfilled' &&
redisStatus.value;
return {
status: allUp ? 'ok' : 'error',
timestamp: new Date().toISOString(),
version: '0.1.0',
services: {
database:
dbStatus.status === 'fulfilled' && dbStatus.value ? 'up' : 'down',
redis:
redisStatus.status === 'fulfilled' && redisStatus.value
? 'up'
: 'down',
},
};
}
private async checkDatabase(): Promise<boolean> {
try {
await this.prisma.$queryRaw`SELECT 1`;
return true;
} catch {
return false;
}
}
private async checkRedis(): Promise<boolean> {
try {
const pong = await this.redis.ping();
return pong === 'PONG';
} catch {
return false;
}
}
}

View file

@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { HealthController } from './health.controller';
import { PrismaModule } from '../prisma/prisma.module';
import { RedisModule } from '../redis/redis.module';
@Module({
imports: [PrismaModule, RedisModule],
controllers: [HealthController],
})
export class HealthModule {}

View file

@ -0,0 +1,86 @@
import { NestFactory } from '@nestjs/core';
import { ValidationPipe, Logger } from '@nestjs/common';
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
import cookieParser from 'cookie-parser';
import helmet from 'helmet';
import { json } from 'express';
import { AppModule } from './app.module';
async function bootstrap(): Promise<void> {
const logger = new Logger('Bootstrap');
const app = await NestFactory.create(AppModule, {
logger: ['error', 'warn', 'log', 'debug', 'verbose'],
});
// Security
app.use(helmet());
app.use(cookieParser());
// Body size limit für Base64-Uploads (Avatare, Profilanlagen bis 10MB)
app.use(json({ limit: '12mb' }));
// CORS
const corsOrigins = process.env.CORS_ORIGINS?.split(',') ?? [
'http://172.20.10.59',
];
app.enableCors({
origin: corsOrigins,
credentials: true,
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
allowedHeaders: [
'Content-Type',
'Authorization',
'X-Tenant-ID',
'X-Request-ID',
],
});
// Global Validation Pipe (Sicherheitsregel: whitelist + forbidNonWhitelisted)
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
transformOptions: { enableImplicitConversion: true },
}),
);
// Global Prefix
app.setGlobalPrefix('api/v1', {
exclude: ['health'],
});
// Swagger (nur Development)
if (process.env.NODE_ENV !== 'production') {
const config = new DocumentBuilder()
.setTitle('INSIGHT Platform API')
.setDescription('Multi-Tenant Business Platform API')
.setVersion('0.1.0')
.addBearerAuth(
{
type: 'http',
scheme: 'bearer',
bearerFormat: 'JWT',
description: 'Access Token (RS256)',
},
'access-token',
)
.addCookieAuth('refresh_token', {
type: 'apiKey',
in: 'cookie',
description: 'HttpOnly Refresh Token',
})
.build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('api/docs', app, document);
logger.log('Swagger UI: /api/docs');
}
const port = process.env.APP_PORT ?? 3000;
await app.listen(port);
logger.log(`Core-Service laeuft auf Port ${port}`);
logger.log(`Umgebung: ${process.env.NODE_ENV ?? 'development'}`);
}
bootstrap();

View file

@ -0,0 +1,10 @@
import { Global, Module } from '@nestjs/common';
import { PrismaService } from './prisma.service';
import { TenantPrismaService } from './tenant-prisma.service';
@Global()
@Module({
providers: [PrismaService, TenantPrismaService],
exports: [PrismaService, TenantPrismaService],
})
export class PrismaModule {}

View file

@ -0,0 +1,32 @@
import { Injectable, OnModuleInit, OnModuleDestroy, Logger } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
@Injectable()
export class PrismaService
extends PrismaClient
implements OnModuleInit, OnModuleDestroy
{
private readonly logger = new Logger(PrismaService.name);
constructor() {
super({
log: [
{ emit: 'event', level: 'query' },
{ emit: 'stdout', level: 'info' },
{ emit: 'stdout', level: 'warn' },
{ emit: 'stdout', level: 'error' },
],
});
}
async onModuleInit(): Promise<void> {
this.logger.log('Verbinde mit PostgreSQL (platform_core)...');
await this.$connect();
this.logger.log('PostgreSQL Verbindung hergestellt.');
}
async onModuleDestroy(): Promise<void> {
this.logger.log('Trenne PostgreSQL Verbindung...');
await this.$disconnect();
}
}

View file

@ -0,0 +1,103 @@
import { Injectable, Logger } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
/**
* TenantPrismaService - Verwaltet dynamische Datenbankverbindungen pro Mandant.
*
* Jeder Tenant hat eine eigene Datenbank (tenant_{slug}).
* Verbindungen werden gecacht und bei Inaktivitaet automatisch geschlossen.
*/
@Injectable()
export class TenantPrismaService {
private readonly logger = new Logger(TenantPrismaService.name);
private readonly clients = new Map<
string,
{ client: PrismaClient; lastUsed: number }
>();
// Maximale Inaktivitaetszeit in Millisekunden (30 Minuten)
private readonly MAX_IDLE_TIME = 30 * 60 * 1000;
/**
* Gibt einen PrismaClient fuer den angegebenen Tenant zurueck.
* Erstellt eine neue Verbindung oder nutzt eine gecachte.
*/
async getClient(tenantSlug: string): Promise<PrismaClient> {
const existing = this.clients.get(tenantSlug);
if (existing) {
existing.lastUsed = Date.now();
return existing.client;
}
const dbName = `tenant_${tenantSlug}`;
const baseUrl = process.env.DATABASE_URL_DIRECT ?? process.env.DATABASE_URL;
if (!baseUrl) {
throw new Error('DATABASE_URL ist nicht konfiguriert');
}
// URL modifizieren: Datenbankname ersetzen
const url = new URL(baseUrl);
url.pathname = `/${dbName}`;
const client = new PrismaClient({
datasources: {
db: { url: url.toString() },
},
});
await client.$connect();
this.logger.log(`Tenant-DB Verbindung hergestellt: ${dbName}`);
this.clients.set(tenantSlug, {
client,
lastUsed: Date.now(),
});
return client;
}
/**
* Schliesst eine bestimmte Tenant-Verbindung.
*/
async disconnectTenant(tenantSlug: string): Promise<void> {
const existing = this.clients.get(tenantSlug);
if (existing) {
await existing.client.$disconnect();
this.clients.delete(tenantSlug);
this.logger.log(`Tenant-DB Verbindung geschlossen: tenant_${tenantSlug}`);
}
}
/**
* Schliesst alle inaktiven Verbindungen.
* Wird periodisch vom CleanupService aufgerufen.
*/
async cleanupIdleConnections(): Promise<number> {
const now = Date.now();
let closed = 0;
for (const [slug, entry] of this.clients.entries()) {
if (now - entry.lastUsed > this.MAX_IDLE_TIME) {
await entry.client.$disconnect();
this.clients.delete(slug);
this.logger.log(
`Idle Tenant-DB Verbindung geschlossen: tenant_${slug}`,
);
closed++;
}
}
return closed;
}
/**
* Schliesst alle Tenant-Verbindungen (Shutdown).
*/
async disconnectAll(): Promise<void> {
for (const [slug, entry] of this.clients.entries()) {
await entry.client.$disconnect();
this.logger.log(`Tenant-DB Verbindung geschlossen: tenant_${slug}`);
}
this.clients.clear();
}
}

View file

@ -0,0 +1,9 @@
import { Global, Module } from '@nestjs/common';
import { RedisService } from './redis.service';
@Global()
@Module({
providers: [RedisService],
exports: [RedisService],
})
export class RedisModule {}

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