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>
81
.env.example
Normal 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
|
|
@ -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
|
||||
108
.forgejo/workflows/deploy.yml
Normal 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
|
|
@ -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
|
|
@ -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
|
|
@ -0,0 +1 @@
|
|||
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINlPo+AvDMTMZC0G49o+kuU98/aC85N90QU3a+FaTjoG insight-cicd@xinion.lan
|
||||
7
.keys/deploy_ed25519
Normal 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
|
|
@ -0,0 +1 @@
|
|||
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMuTpqzLyjqTIDMJ4bwEE4o2JeHH3imL+NeipeuBfiTo insight-deploy@xinion.lan
|
||||
BIN
CLAUDE_BRIEFING.docx
Normal file
BIN
INSIGHT_Konzept_v1.0.docx
Normal file
BIN
Icons/Address.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
Icons/Mail.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
Icons/Mobile.png
Normal file
|
After Width: | Height: | Size: 750 B |
BIN
Icons/Phone.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
207
README.md
|
|
@ -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
|
|
@ -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)
|
||||
47
config/grafana/provisioning/datasources/datasources.yml
Normal 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
|
|
@ -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
|
||||
22
config/postgres/init/01-init-extensions.sql
Normal 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
|
||||
$$;
|
||||
40
config/prometheus/prometheus.yml
Normal 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"]
|
||||
51
config/promtail/promtail.yml
Normal 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
41
config/tempo/tempo.yml
Normal 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
|
||||
49
config/traefik/dynamic/middlewares.yml
Normal 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
|
||||
2
config/traefik/dynamic/tls.yml
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
# TLS-Konfiguration deaktiviert fuer Alpha/Dev (IP-basierter HTTP-Zugang).
|
||||
# Wird reaktiviert wenn DNS + HTTPS eingerichtet wird.
|
||||
185
docker-compose.observability.yml
Normal 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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -0,0 +1,3 @@
|
|||
IP: 172.20.10.11
|
||||
User: sysadmin (non root)
|
||||
Passord: $Sabrina$6506$
|
||||
7
packages/core-service/.dockerignore
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
node_modules
|
||||
dist
|
||||
coverage
|
||||
.env
|
||||
*.md
|
||||
.git
|
||||
.gitignore
|
||||
67
packages/core-service/Dockerfile
Normal 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"]
|
||||
BIN
packages/core-service/assets/icons/Address.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
packages/core-service/assets/icons/Mail.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
packages/core-service/assets/icons/Mobile.png
Normal file
|
After Width: | Height: | Size: 750 B |
BIN
packages/core-service/assets/icons/Phone.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
8
packages/core-service/nest-cli.json
Normal 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
107
packages/core-service/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
327
packages/core-service/prisma/core.schema.prisma
Normal 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")
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "users" ADD COLUMN "avatar" TEXT;
|
||||
|
|
@ -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);
|
||||
|
|
@ -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;
|
||||
|
|
@ -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"
|
||||
65
packages/core-service/prisma/seed.ts
Normal 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());
|
||||
111
packages/core-service/prisma/tenant.schema.prisma
Normal 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
|
||||
}
|
||||
61
packages/core-service/src/app.module.ts
Normal 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 {}
|
||||
|
|
@ -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;
|
||||
},
|
||||
);
|
||||
3
packages/core-service/src/common/decorators/index.ts
Normal 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';
|
||||
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
65
packages/core-service/src/common/guards/jwt-auth.guard.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
37
packages/core-service/src/common/guards/roles.guard.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
120
packages/core-service/src/config/env.validation.ts
Normal 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;
|
||||
}
|
||||
171
packages/core-service/src/core/auth/auth.controller.ts
Normal 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
|
||||
});
|
||||
}
|
||||
}
|
||||
49
packages/core-service/src/core/auth/auth.module.ts
Normal 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 {}
|
||||
527
packages/core-service/src/core/auth/auth.service.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
13
packages/core-service/src/core/auth/dto/disable-2fa.dto.ts
Normal 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;
|
||||
}
|
||||
13
packages/core-service/src/core/auth/dto/enable-2fa.dto.ts
Normal 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;
|
||||
}
|
||||
30
packages/core-service/src/core/auth/dto/login.dto.ts
Normal 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;
|
||||
}
|
||||
299
packages/core-service/src/core/auth/sso/entra-id.service.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
251
packages/core-service/src/core/auth/sso/sso.controller.ts
Normal 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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
54
packages/core-service/src/core/auth/totp.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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[];
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
298
packages/core-service/src/core/settings/settings.controller.ts
Normal 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}`;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { SettingsController } from './settings.controller';
|
||||
|
||||
@Module({
|
||||
controllers: [SettingsController],
|
||||
})
|
||||
export class SettingsModule {}
|
||||
19
packages/core-service/src/core/tenants/dto/add-member.dto.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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>;
|
||||
}
|
||||
|
|
@ -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>;
|
||||
}
|
||||
113
packages/core-service/src/core/tenants/tenants.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
10
packages/core-service/src/core/tenants/tenants.module.ts
Normal 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 {}
|
||||
167
packages/core-service/src/core/tenants/tenants.service.ts
Normal 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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
46
packages/core-service/src/core/users/dto/create-user.dto.ts
Normal 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;
|
||||
}
|
||||
80
packages/core-service/src/core/users/dto/update-user.dto.ts
Normal 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;
|
||||
}
|
||||
140
packages/core-service/src/core/users/users.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
10
packages/core-service/src/core/users/users.module.ts
Normal 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 {}
|
||||
292
packages/core-service/src/core/users/users.service.ts
Normal 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),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
72
packages/core-service/src/health/health.controller.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
10
packages/core-service/src/health/health.module.ts
Normal 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 {}
|
||||
86
packages/core-service/src/main.ts
Normal 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();
|
||||
10
packages/core-service/src/prisma/prisma.module.ts
Normal 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 {}
|
||||
32
packages/core-service/src/prisma/prisma.service.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
103
packages/core-service/src/prisma/tenant-prisma.service.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
9
packages/core-service/src/redis/redis.module.ts
Normal 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 {}
|
||||