mirror of
http://172.20.10.11:3000/gitadmin/INSIGHT-MVP.git
synced 2026-06-25 04:56:40 +02:00
feat(crm): scaffold CRM service with full CRUD modules
Eigenstaendiger NestJS-Service unter packages/crm-service/ mit: - Prisma Schema (app_crm): Contact, Activity, Pipeline, PipelineStage, Deal - JWT RS256 Auth mit shared Public Key und Token-Revocation - Multi-Tenancy: TenantGuard + tenantId-Filter auf allen Queries - CRUD-Module: Contacts, Activities, Pipelines, Deals - Docker-Integration: docker-compose.crm.yml (Port 3100, Traefik-Route /api/v1/crm) - Health-Check, Swagger, GlobalExceptionFilter, Pagination Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
ffd48a38a3
commit
8783d01fc0
52 changed files with 2705 additions and 1 deletions
|
|
@ -71,7 +71,7 @@ GRAFANA_ADMIN_PASSWORD= # Sicheres Passwort setzen!
|
||||||
AZURE_TENANT_ID= # Directory (Tenant) ID
|
AZURE_TENANT_ID= # Directory (Tenant) ID
|
||||||
AZURE_CLIENT_ID= # Application (Client) ID
|
AZURE_CLIENT_ID= # Application (Client) ID
|
||||||
AZURE_CLIENT_SECRET= # Client Secret Value
|
AZURE_CLIENT_SECRET= # Client Secret Value
|
||||||
AZURE_REDIRECT_URI=http://172.20.10.59/api/v1/auth/sso/microsoft/callback
|
AZURE_REDIRECT_URI=https://172.20.10.59/api/v1/auth/sso/microsoft/callback
|
||||||
|
|
||||||
# --- KI-Hilfe-Chat (optional) ---
|
# --- KI-Hilfe-Chat (optional) ---
|
||||||
# ANTHROPIC_API_KEY= # Claude API Key
|
# ANTHROPIC_API_KEY= # Claude API Key
|
||||||
|
|
@ -79,3 +79,10 @@ AZURE_REDIRECT_URI=http://172.20.10.59/api/v1/auth/sso/microsoft/callback
|
||||||
|
|
||||||
# --- DeepL (optional, fuer Hilfesystem-Uebersetzungen) ---
|
# --- DeepL (optional, fuer Hilfesystem-Uebersetzungen) ---
|
||||||
# DEEPL_API_KEY=
|
# DEEPL_API_KEY=
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# CRM-Service (packages/crm-service)
|
||||||
|
# ============================================================
|
||||||
|
CRM_APP_PORT=3100
|
||||||
|
CRM_DATABASE_URL=postgresql://${DB_USER}:${DB_PASSWORD}@pgbouncer:6432/${DB_NAME}?schema=app_crm
|
||||||
|
CRM_DATABASE_URL_DIRECT=postgresql://${DB_USER}:${DB_PASSWORD}@postgres:5432/${DB_NAME}?schema=app_crm
|
||||||
|
|
|
||||||
58
docker-compose.crm.yml
Normal file
58
docker-compose.crm.yml
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
# ============================================================
|
||||||
|
# INSIGHT CRM-Service - Docker Compose Override
|
||||||
|
# ============================================================
|
||||||
|
# Start: docker compose -f docker-compose.yml -f docker-compose.crm.yml up -d
|
||||||
|
# Nur CRM: docker compose -f docker-compose.yml -f docker-compose.crm.yml up -d crm
|
||||||
|
|
||||||
|
services:
|
||||||
|
crm:
|
||||||
|
build:
|
||||||
|
context: ./packages/crm-service
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
target: development
|
||||||
|
container_name: insight-crm
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
- NODE_ENV=${NODE_ENV:-development}
|
||||||
|
- APP_PORT=3100
|
||||||
|
- DATABASE_URL=postgresql://${DB_USER:-insight}:${DB_PASSWORD}@pgbouncer:6432/${DB_NAME:-platform_core}?schema=app_crm
|
||||||
|
- DATABASE_URL_DIRECT=postgresql://${DB_USER:-insight}:${DB_PASSWORD}@postgres:5432/${DB_NAME:-platform_core}?schema=app_crm
|
||||||
|
- REDIS_HOST=redis
|
||||||
|
- REDIS_PORT=6379
|
||||||
|
- REDIS_PASSWORD=${REDIS_PASSWORD:-}
|
||||||
|
- JWT_PUBLIC_KEY_PATH=/app/keys/jwt-public.pem
|
||||||
|
- JWT_ISSUER=${JWT_ISSUER:-insight-platform}
|
||||||
|
- CORS_ORIGINS=${CORS_ORIGINS:-http://172.20.10.59}
|
||||||
|
volumes:
|
||||||
|
- ./packages/crm-service:/app
|
||||||
|
- /app/node_modules
|
||||||
|
- ./.keys/jwt-public.pem:/app/keys/jwt-public.pem:ro
|
||||||
|
networks:
|
||||||
|
- insight-web
|
||||||
|
- insight-db
|
||||||
|
- insight-cache
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=true"
|
||||||
|
- "traefik.http.routers.crm.rule=PathPrefix(`/api/v1/crm`)"
|
||||||
|
- "traefik.http.routers.crm.entrypoints=web"
|
||||||
|
- "traefik.http.services.crm.loadbalancer.server.port=3100"
|
||||||
|
- "traefik.http.routers.crm.middlewares=cors-headers@file,security-headers@file"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3100/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 15s
|
||||||
|
depends_on:
|
||||||
|
pgbouncer:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
networks:
|
||||||
|
insight-web:
|
||||||
|
external: true
|
||||||
|
insight-db:
|
||||||
|
external: true
|
||||||
|
insight-cache:
|
||||||
|
external: true
|
||||||
7
packages/crm-service/.dockerignore
Normal file
7
packages/crm-service/.dockerignore
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
coverage
|
||||||
|
.env
|
||||||
|
*.md
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
56
packages/crm-service/Dockerfile
Normal file
56
packages/crm-service/Dockerfile
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
# ============================================================
|
||||||
|
# INSIGHT CRM-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
|
||||||
|
COPY package.json package-lock.json* ./
|
||||||
|
RUN npm ci --ignore-scripts
|
||||||
|
# Prisma Generate braucht die Schema-Dateien
|
||||||
|
COPY prisma ./prisma
|
||||||
|
RUN npx prisma generate --schema=prisma/crm.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/crm.schema.prisma
|
||||||
|
EXPOSE 3100
|
||||||
|
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
|
||||||
|
|
||||||
|
# Nur Produktions-Dependencies
|
||||||
|
COPY package.json package-lock.json* ./
|
||||||
|
RUN npm ci --omit=dev --ignore-scripts
|
||||||
|
|
||||||
|
# Prisma Client generieren
|
||||||
|
COPY prisma ./prisma
|
||||||
|
RUN npx prisma generate --schema=prisma/crm.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 3100
|
||||||
|
CMD ["node", "dist/main"]
|
||||||
79
packages/crm-service/Summarize.md
Normal file
79
packages/crm-service/Summarize.md
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
# CRM-Service - Zusammenfassung
|
||||||
|
|
||||||
|
## Stand: 2026-03-10
|
||||||
|
|
||||||
|
### Was wurde erstellt
|
||||||
|
|
||||||
|
Der CRM-Service als eigenstaendiges NestJS-Package unter `packages/crm-service/`.
|
||||||
|
|
||||||
|
### Struktur
|
||||||
|
|
||||||
|
```
|
||||||
|
packages/crm-service/
|
||||||
|
package.json — Dependencies (NestJS 10, Prisma, Passport, ioredis)
|
||||||
|
tsconfig.json — Strict TypeScript
|
||||||
|
nest-cli.json — NestJS CLI Config
|
||||||
|
Dockerfile — Multi-Stage (base, deps, development, build, production)
|
||||||
|
.dockerignore — Excludes
|
||||||
|
prisma/
|
||||||
|
crm.schema.prisma — Eigenes Schema (app_crm) mit eigenem Client-Output
|
||||||
|
src/
|
||||||
|
main.ts — Bootstrap (Port 3100, Prefix: api/v1/crm, Swagger)
|
||||||
|
app.module.ts — Root Module mit globalem JwtAuthGuard + ExceptionFilter
|
||||||
|
config/ — Umgebungsvariablen-Validierung
|
||||||
|
prisma/ — CrmPrismaService (eigener Client)
|
||||||
|
redis/ — RedisService (Token-Blocklist, Cache)
|
||||||
|
auth/ — JWT Strategy (RS256), JwtAuthGuard, RolesGuard, TenantGuard
|
||||||
|
common/ — Decorators (@Public, @Roles, @CurrentUser), Pagination, ExceptionFilter
|
||||||
|
contacts/ — CRUD: Kontakte (PERSON, ORGANIZATION)
|
||||||
|
activities/ — CRUD: Aktivitaeten (NOTE, CALL, EMAIL, MEETING, TASK)
|
||||||
|
pipelines/ — CRUD: Sales-Pipelines mit Stages
|
||||||
|
deals/ — CRUD: Deals mit Pipeline/Stage-Zuordnung
|
||||||
|
```
|
||||||
|
|
||||||
|
### Datenbank-Modelle (app_crm Schema)
|
||||||
|
|
||||||
|
- **Contact** — Kontakte mit Typen, Adresse, Tags, Audit-Trail
|
||||||
|
- **Activity** — Aktivitaeten verknuepft mit Kontakten
|
||||||
|
- **Pipeline** — Konfigurierbare Sales-Pipelines pro Tenant
|
||||||
|
- **PipelineStage** — Stufen innerhalb einer Pipeline
|
||||||
|
- **Deal** — Verkaufschancen mit Wert, Status, Pipeline-Zuordnung
|
||||||
|
|
||||||
|
### API-Endpunkte
|
||||||
|
|
||||||
|
| Methode | Pfad | Beschreibung |
|
||||||
|
|---------|------|-------------|
|
||||||
|
| GET/POST | /api/v1/crm/contacts | Liste / Erstellen |
|
||||||
|
| GET/PATCH/DELETE | /api/v1/crm/contacts/:id | Detail / Update / Delete |
|
||||||
|
| GET/POST | /api/v1/crm/activities | Liste / Erstellen |
|
||||||
|
| GET/PATCH/DELETE | /api/v1/crm/activities/:id | Detail / Update / Delete |
|
||||||
|
| GET/POST | /api/v1/crm/pipelines | Liste / Erstellen |
|
||||||
|
| GET/PATCH/DELETE | /api/v1/crm/pipelines/:id | Detail / Update / Delete |
|
||||||
|
| POST/DELETE | /api/v1/crm/pipelines/:id/stages | Stage hinzufuegen/entfernen |
|
||||||
|
| GET/POST | /api/v1/crm/deals | Liste / Erstellen |
|
||||||
|
| GET/PATCH/DELETE | /api/v1/crm/deals/:id | Detail / Update / Delete |
|
||||||
|
| GET | /health | Health-Check (public) |
|
||||||
|
|
||||||
|
### Docker-Integration
|
||||||
|
|
||||||
|
- `docker-compose.crm.yml` im Projekt-Root
|
||||||
|
- Port: 3100
|
||||||
|
- Netzwerke: insight-web, insight-db, insight-cache
|
||||||
|
- Traefik-Route: /api/v1/crm/*
|
||||||
|
- JWT Public Key als Read-Only Volume
|
||||||
|
|
||||||
|
### Sicherheit
|
||||||
|
|
||||||
|
- JWT RS256 Validierung mit shared Public Key
|
||||||
|
- Token-Revocation via Redis (blocked:{jti})
|
||||||
|
- Multi-Tenancy: Alle Queries filtern nach tenantId
|
||||||
|
- TenantGuard sichert mandantenbezogenen Zugriff
|
||||||
|
- Globaler ValidationPipe (whitelist + forbidNonWhitelisted)
|
||||||
|
- Strict TypeScript, kein `any`
|
||||||
|
|
||||||
|
### Naechste Schritte
|
||||||
|
|
||||||
|
1. `npm install` in packages/crm-service/
|
||||||
|
2. Prisma Migration: `npx prisma migrate dev --schema=prisma/crm.schema.prisma --name init`
|
||||||
|
3. Docker Build testen
|
||||||
|
4. Integration mit laufender Plattform testen
|
||||||
8
packages/crm-service/nest-cli.json
Normal file
8
packages/crm-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
|
||||||
|
}
|
||||||
|
}
|
||||||
85
packages/crm-service/package.json
Normal file
85
packages/crm-service/package.json
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
{
|
||||||
|
"name": "@insight/crm-service",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "INSIGHT MVP - CRM 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/crm.schema.prisma",
|
||||||
|
"prisma:migrate": "prisma migrate dev --schema=prisma/crm.schema.prisma",
|
||||||
|
"prisma:migrate:deploy": "prisma migrate deploy --schema=prisma/crm.schema.prisma",
|
||||||
|
"prisma:studio": "prisma studio --schema=prisma/crm.schema.prisma"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@nestjs/common": "^10.4.0",
|
||||||
|
"@nestjs/config": "^3.2.0",
|
||||||
|
"@nestjs/core": "^10.4.0",
|
||||||
|
"@nestjs/passport": "^10.0.3",
|
||||||
|
"@nestjs/platform-express": "^10.4.0",
|
||||||
|
"@nestjs/swagger": "^7.4.0",
|
||||||
|
"@prisma/client": "^6.4.0",
|
||||||
|
"class-transformer": "^0.5.1",
|
||||||
|
"class-validator": "^0.14.1",
|
||||||
|
"cookie-parser": "^1.4.7",
|
||||||
|
"helmet": "^8.0.0",
|
||||||
|
"ioredis": "^5.4.1",
|
||||||
|
"passport": "^0.7.0",
|
||||||
|
"passport-jwt": "^4.0.1",
|
||||||
|
"reflect-metadata": "^0.2.2",
|
||||||
|
"rxjs": "^7.8.1",
|
||||||
|
"uuid": "^10.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@nestjs/cli": "^10.4.0",
|
||||||
|
"@nestjs/schematics": "^10.1.0",
|
||||||
|
"@nestjs/testing": "^10.4.0",
|
||||||
|
"@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/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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
221
packages/crm-service/prisma/crm.schema.prisma
Normal file
221
packages/crm-service/prisma/crm.schema.prisma
Normal file
|
|
@ -0,0 +1,221 @@
|
||||||
|
// ============================================================
|
||||||
|
// INSIGHT CRM Service - Prisma Schema
|
||||||
|
// ============================================================
|
||||||
|
// Eigenes Schema im PostgreSQL-Schema 'app_crm'
|
||||||
|
// Eigener Prisma-Client-Output (kein Konflikt mit Core)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
generator client {
|
||||||
|
provider = "prisma-client-js"
|
||||||
|
output = "../node_modules/.prisma/crm-client"
|
||||||
|
previewFeatures = ["multiSchema"]
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "postgresql"
|
||||||
|
url = env("DATABASE_URL")
|
||||||
|
directUrl = env("DATABASE_URL_DIRECT")
|
||||||
|
schemas = ["app_crm"]
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// Contact - CRM-Kontakte (Personen & Organisationen)
|
||||||
|
// --------------------------------------------------------
|
||||||
|
model Contact {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
tenantId String @map("tenant_id") @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")
|
||||||
|
|
||||||
|
// Audit-Trail (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[]
|
||||||
|
deals Deal[]
|
||||||
|
|
||||||
|
@@index([tenantId])
|
||||||
|
@@index([tenantId, email])
|
||||||
|
@@index([tenantId, companyName])
|
||||||
|
@@index([tenantId, lastName, firstName])
|
||||||
|
@@index([tenantId, isActive])
|
||||||
|
@@map("contacts")
|
||||||
|
@@schema("app_crm")
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ContactType {
|
||||||
|
PERSON
|
||||||
|
ORGANIZATION
|
||||||
|
|
||||||
|
@@schema("app_crm")
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// Activity - CRM-Aktivitaeten (Notizen, Anrufe, E-Mails)
|
||||||
|
// --------------------------------------------------------
|
||||||
|
model Activity {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
tenantId String @map("tenant_id") @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")
|
||||||
|
|
||||||
|
// Audit-Trail
|
||||||
|
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
|
||||||
|
contact Contact @relation(fields: [contactId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([tenantId])
|
||||||
|
@@index([tenantId, contactId])
|
||||||
|
@@index([tenantId, type])
|
||||||
|
@@index([tenantId, scheduledAt])
|
||||||
|
@@map("activities")
|
||||||
|
@@schema("app_crm")
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ActivityType {
|
||||||
|
NOTE
|
||||||
|
CALL
|
||||||
|
EMAIL
|
||||||
|
MEETING
|
||||||
|
TASK
|
||||||
|
|
||||||
|
@@schema("app_crm")
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// Pipeline - Sales-Pipelines (konfigurierbar pro Tenant)
|
||||||
|
// --------------------------------------------------------
|
||||||
|
model Pipeline {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
tenantId String @map("tenant_id") @db.Uuid
|
||||||
|
name String @db.VarChar(200)
|
||||||
|
|
||||||
|
isDefault Boolean @default(false) @map("is_default")
|
||||||
|
isActive Boolean @default(true) @map("is_active")
|
||||||
|
|
||||||
|
// Audit-Trail
|
||||||
|
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
|
||||||
|
stages PipelineStage[]
|
||||||
|
deals Deal[]
|
||||||
|
|
||||||
|
@@index([tenantId])
|
||||||
|
@@index([tenantId, isActive])
|
||||||
|
@@map("pipelines")
|
||||||
|
@@schema("app_crm")
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// PipelineStage - Stufen einer Pipeline (z.B. Lead, Angebot)
|
||||||
|
// --------------------------------------------------------
|
||||||
|
model PipelineStage {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
pipelineId String @map("pipeline_id") @db.Uuid
|
||||||
|
name String @db.VarChar(200)
|
||||||
|
sortOrder Int @default(0) @map("sort_order")
|
||||||
|
color String @default("#6B7280") @db.VarChar(7)
|
||||||
|
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
// Relationen
|
||||||
|
pipeline Pipeline @relation(fields: [pipelineId], references: [id], onDelete: Cascade)
|
||||||
|
deals Deal[]
|
||||||
|
|
||||||
|
@@index([pipelineId])
|
||||||
|
@@index([pipelineId, sortOrder])
|
||||||
|
@@map("pipeline_stages")
|
||||||
|
@@schema("app_crm")
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// Deal - Verkaufschancen / Deals
|
||||||
|
// --------------------------------------------------------
|
||||||
|
model Deal {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
tenantId String @map("tenant_id") @db.Uuid
|
||||||
|
pipelineId String @map("pipeline_id") @db.Uuid
|
||||||
|
stageId String @map("stage_id") @db.Uuid
|
||||||
|
contactId String? @map("contact_id") @db.Uuid
|
||||||
|
title String @db.VarChar(500)
|
||||||
|
value Decimal? @db.Decimal(15, 2)
|
||||||
|
currency String @default("EUR") @db.VarChar(3)
|
||||||
|
status DealStatus @default(OPEN)
|
||||||
|
|
||||||
|
expectedCloseDate DateTime? @map("expected_close_date")
|
||||||
|
closedAt DateTime? @map("closed_at")
|
||||||
|
notes String? @db.Text
|
||||||
|
|
||||||
|
// Audit-Trail
|
||||||
|
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
|
||||||
|
pipeline Pipeline @relation(fields: [pipelineId], references: [id], onDelete: Cascade)
|
||||||
|
stage PipelineStage @relation(fields: [stageId], references: [id])
|
||||||
|
contact Contact? @relation(fields: [contactId], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
|
@@index([tenantId])
|
||||||
|
@@index([tenantId, pipelineId])
|
||||||
|
@@index([tenantId, stageId])
|
||||||
|
@@index([tenantId, contactId])
|
||||||
|
@@index([tenantId, status])
|
||||||
|
@@map("deals")
|
||||||
|
@@schema("app_crm")
|
||||||
|
}
|
||||||
|
|
||||||
|
enum DealStatus {
|
||||||
|
OPEN
|
||||||
|
WON
|
||||||
|
LOST
|
||||||
|
|
||||||
|
@@schema("app_crm")
|
||||||
|
}
|
||||||
110
packages/crm-service/src/activities/activities.controller.ts
Normal file
110
packages/crm-service/src/activities/activities.controller.ts
Normal file
|
|
@ -0,0 +1,110 @@
|
||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Post,
|
||||||
|
Patch,
|
||||||
|
Delete,
|
||||||
|
Body,
|
||||||
|
Param,
|
||||||
|
Query,
|
||||||
|
ParseUUIDPipe,
|
||||||
|
HttpCode,
|
||||||
|
HttpStatus,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
ApiTags,
|
||||||
|
ApiOperation,
|
||||||
|
ApiBearerAuth,
|
||||||
|
ApiParam,
|
||||||
|
} from '@nestjs/swagger';
|
||||||
|
import { ActivitiesService } from './activities.service';
|
||||||
|
import { CreateActivityDto } from './dto/create-activity.dto';
|
||||||
|
import { UpdateActivityDto } from './dto/update-activity.dto';
|
||||||
|
import { QueryActivitiesDto } from './dto/query-activities.dto';
|
||||||
|
import { CurrentUser, JwtPayload } from '../common/decorators';
|
||||||
|
import { TenantGuard } from '../auth/guards/tenant.guard';
|
||||||
|
import {
|
||||||
|
paginatedResponse,
|
||||||
|
singleResponse,
|
||||||
|
} from '../common/dto/pagination.dto';
|
||||||
|
|
||||||
|
@ApiTags('Activities')
|
||||||
|
@ApiBearerAuth('access-token')
|
||||||
|
@UseGuards(TenantGuard)
|
||||||
|
@Controller('activities')
|
||||||
|
export class ActivitiesController {
|
||||||
|
constructor(private readonly activitiesService: ActivitiesService) {}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@HttpCode(HttpStatus.CREATED)
|
||||||
|
@ApiOperation({ summary: 'Aktivitaet erstellen' })
|
||||||
|
async create(
|
||||||
|
@CurrentUser() user: JwtPayload,
|
||||||
|
@Body() dto: CreateActivityDto,
|
||||||
|
) {
|
||||||
|
const activity = await this.activitiesService.create(
|
||||||
|
user.tenantId!,
|
||||||
|
user.sub,
|
||||||
|
dto,
|
||||||
|
);
|
||||||
|
return singleResponse(activity);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@ApiOperation({ summary: 'Aktivitaeten auflisten (paginiert, filterbar)' })
|
||||||
|
async findAll(
|
||||||
|
@CurrentUser() user: JwtPayload,
|
||||||
|
@Query() query: QueryActivitiesDto,
|
||||||
|
) {
|
||||||
|
const result = await this.activitiesService.findAll(
|
||||||
|
user.tenantId!,
|
||||||
|
query,
|
||||||
|
);
|
||||||
|
return paginatedResponse(
|
||||||
|
result.data,
|
||||||
|
result.total,
|
||||||
|
result.page,
|
||||||
|
result.pageSize,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
@ApiOperation({ summary: 'Aktivitaet-Details abrufen' })
|
||||||
|
@ApiParam({ name: 'id', type: 'string', format: 'uuid' })
|
||||||
|
async findOne(
|
||||||
|
@CurrentUser() user: JwtPayload,
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
) {
|
||||||
|
const activity = await this.activitiesService.findOne(user.tenantId!, id);
|
||||||
|
return singleResponse(activity);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id')
|
||||||
|
@ApiOperation({ summary: 'Aktivitaet aktualisieren' })
|
||||||
|
@ApiParam({ name: 'id', type: 'string', format: 'uuid' })
|
||||||
|
async update(
|
||||||
|
@CurrentUser() user: JwtPayload,
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body() dto: UpdateActivityDto,
|
||||||
|
) {
|
||||||
|
const activity = await this.activitiesService.update(
|
||||||
|
user.tenantId!,
|
||||||
|
id,
|
||||||
|
user.sub,
|
||||||
|
dto,
|
||||||
|
);
|
||||||
|
return singleResponse(activity);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
@ApiOperation({ summary: 'Aktivitaet loeschen' })
|
||||||
|
@ApiParam({ name: 'id', type: 'string', format: 'uuid' })
|
||||||
|
async remove(
|
||||||
|
@CurrentUser() user: JwtPayload,
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
) {
|
||||||
|
const activity = await this.activitiesService.remove(user.tenantId!, id);
|
||||||
|
return singleResponse(activity);
|
||||||
|
}
|
||||||
|
}
|
||||||
10
packages/crm-service/src/activities/activities.module.ts
Normal file
10
packages/crm-service/src/activities/activities.module.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ActivitiesController } from './activities.controller';
|
||||||
|
import { ActivitiesService } from './activities.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [ActivitiesController],
|
||||||
|
providers: [ActivitiesService],
|
||||||
|
exports: [ActivitiesService],
|
||||||
|
})
|
||||||
|
export class ActivitiesModule {}
|
||||||
105
packages/crm-service/src/activities/activities.service.ts
Normal file
105
packages/crm-service/src/activities/activities.service.ts
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { CrmPrismaService } from '../prisma/crm-prisma.service';
|
||||||
|
import { CreateActivityDto } from './dto/create-activity.dto';
|
||||||
|
import { UpdateActivityDto } from './dto/update-activity.dto';
|
||||||
|
import { QueryActivitiesDto } from './dto/query-activities.dto';
|
||||||
|
import { Prisma } from '.prisma/crm-client';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ActivitiesService {
|
||||||
|
constructor(private readonly prisma: CrmPrismaService) {}
|
||||||
|
|
||||||
|
async create(tenantId: string, userId: string, dto: CreateActivityDto) {
|
||||||
|
// Pruefen ob der Kontakt existiert und zum Tenant gehoert
|
||||||
|
const contact = await this.prisma.contact.findFirst({
|
||||||
|
where: { id: dto.contactId, tenantId },
|
||||||
|
});
|
||||||
|
if (!contact) {
|
||||||
|
throw new NotFoundException('Kontakt nicht gefunden');
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.prisma.activity.create({
|
||||||
|
data: {
|
||||||
|
tenantId,
|
||||||
|
contactId: dto.contactId,
|
||||||
|
type: dto.type,
|
||||||
|
subject: dto.subject,
|
||||||
|
description: dto.description,
|
||||||
|
scheduledAt: dto.scheduledAt ? new Date(dto.scheduledAt) : undefined,
|
||||||
|
completedAt: dto.completedAt ? new Date(dto.completedAt) : undefined,
|
||||||
|
createdBy: userId,
|
||||||
|
},
|
||||||
|
include: { contact: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async findAll(tenantId: string, query: QueryActivitiesDto) {
|
||||||
|
const page = query.page ?? 1;
|
||||||
|
const pageSize = query.pageSize ?? 25;
|
||||||
|
|
||||||
|
const where: Prisma.ActivityWhereInput = { tenantId };
|
||||||
|
|
||||||
|
if (query.contactId) {
|
||||||
|
where.contactId = query.contactId;
|
||||||
|
}
|
||||||
|
if (query.type) {
|
||||||
|
where.type = query.type;
|
||||||
|
}
|
||||||
|
|
||||||
|
const allowedSortFields = ['createdAt', 'updatedAt', 'scheduledAt', 'subject'];
|
||||||
|
const sortField = allowedSortFields.includes(query.sort ?? '')
|
||||||
|
? (query.sort as string)
|
||||||
|
: 'createdAt';
|
||||||
|
|
||||||
|
const [data, total] = await Promise.all([
|
||||||
|
this.prisma.activity.findMany({
|
||||||
|
where,
|
||||||
|
skip: (page - 1) * pageSize,
|
||||||
|
take: pageSize,
|
||||||
|
orderBy: { [sortField]: query.order ?? 'desc' },
|
||||||
|
include: { contact: { select: { id: true, firstName: true, lastName: true, companyName: true } } },
|
||||||
|
}),
|
||||||
|
this.prisma.activity.count({ where }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return { data, total, page, pageSize };
|
||||||
|
}
|
||||||
|
|
||||||
|
async findOne(tenantId: string, id: string) {
|
||||||
|
const activity = await this.prisma.activity.findFirst({
|
||||||
|
where: { id, tenantId },
|
||||||
|
include: { contact: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!activity) {
|
||||||
|
throw new NotFoundException('Aktivitaet nicht gefunden');
|
||||||
|
}
|
||||||
|
|
||||||
|
return activity;
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(
|
||||||
|
tenantId: string,
|
||||||
|
id: string,
|
||||||
|
userId: string,
|
||||||
|
dto: UpdateActivityDto,
|
||||||
|
) {
|
||||||
|
await this.findOne(tenantId, id);
|
||||||
|
|
||||||
|
return this.prisma.activity.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
...dto,
|
||||||
|
scheduledAt: dto.scheduledAt ? new Date(dto.scheduledAt) : undefined,
|
||||||
|
completedAt: dto.completedAt ? new Date(dto.completedAt) : undefined,
|
||||||
|
updatedBy: userId,
|
||||||
|
},
|
||||||
|
include: { contact: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(tenantId: string, id: string) {
|
||||||
|
await this.findOne(tenantId, id);
|
||||||
|
return this.prisma.activity.delete({ where: { id } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,47 @@
|
||||||
|
import {
|
||||||
|
IsString,
|
||||||
|
IsOptional,
|
||||||
|
IsEnum,
|
||||||
|
IsUUID,
|
||||||
|
IsDateString,
|
||||||
|
MaxLength,
|
||||||
|
} from 'class-validator';
|
||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
export enum ActivityType {
|
||||||
|
NOTE = 'NOTE',
|
||||||
|
CALL = 'CALL',
|
||||||
|
EMAIL = 'EMAIL',
|
||||||
|
MEETING = 'MEETING',
|
||||||
|
TASK = 'TASK',
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CreateActivityDto {
|
||||||
|
@ApiProperty({ format: 'uuid' })
|
||||||
|
@IsUUID()
|
||||||
|
contactId!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ enum: ActivityType })
|
||||||
|
@IsEnum(ActivityType)
|
||||||
|
type!: ActivityType;
|
||||||
|
|
||||||
|
@ApiProperty({ maxLength: 500 })
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(500)
|
||||||
|
subject!: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ format: 'date-time' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
scheduledAt?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ format: 'date-time' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
completedAt?: string;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
import { IsString, IsOptional, IsEnum, IsUUID } from 'class-validator';
|
||||||
|
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { PaginationDto } from '../../common/dto/pagination.dto';
|
||||||
|
import { ActivityType } from './create-activity.dto';
|
||||||
|
|
||||||
|
export class QueryActivitiesDto extends PaginationDto {
|
||||||
|
@ApiPropertyOptional({ format: 'uuid', description: 'Filter nach Kontakt' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
contactId?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ enum: ActivityType })
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(ActivityType)
|
||||||
|
type?: ActivityType;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ default: 'createdAt' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
sort?: string = 'createdAt';
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ enum: ['asc', 'desc'], default: 'desc' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(['asc', 'desc'] as const)
|
||||||
|
order?: 'asc' | 'desc' = 'desc';
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
import { PartialType, OmitType } from '@nestjs/swagger';
|
||||||
|
import { CreateActivityDto } from './create-activity.dto';
|
||||||
|
|
||||||
|
export class UpdateActivityDto extends PartialType(
|
||||||
|
OmitType(CreateActivityDto, ['contactId'] as const),
|
||||||
|
) {}
|
||||||
42
packages/crm-service/src/app.module.ts
Normal file
42
packages/crm-service/src/app.module.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ConfigModule } from '@nestjs/config';
|
||||||
|
import { APP_GUARD, APP_FILTER } from '@nestjs/core';
|
||||||
|
import { validate } from './config/env.validation';
|
||||||
|
import { CrmPrismaModule } from './prisma/crm-prisma.module';
|
||||||
|
import { RedisModule } from './redis/redis.module';
|
||||||
|
import { AuthModule } from './auth/auth.module';
|
||||||
|
import { JwtAuthGuard } from './auth/guards/jwt-auth.guard';
|
||||||
|
import { GlobalExceptionFilter } from './common/filters/global-exception.filter';
|
||||||
|
import { HealthModule } from './health/health.module';
|
||||||
|
import { ContactsModule } from './contacts/contacts.module';
|
||||||
|
import { ActivitiesModule } from './activities/activities.module';
|
||||||
|
import { PipelinesModule } from './pipelines/pipelines.module';
|
||||||
|
import { DealsModule } from './deals/deals.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
ConfigModule.forRoot({
|
||||||
|
isGlobal: true,
|
||||||
|
validate,
|
||||||
|
}),
|
||||||
|
CrmPrismaModule,
|
||||||
|
RedisModule,
|
||||||
|
AuthModule,
|
||||||
|
HealthModule,
|
||||||
|
ContactsModule,
|
||||||
|
ActivitiesModule,
|
||||||
|
PipelinesModule,
|
||||||
|
DealsModule,
|
||||||
|
],
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: APP_GUARD,
|
||||||
|
useClass: JwtAuthGuard,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: APP_FILTER,
|
||||||
|
useClass: GlobalExceptionFilter,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class AppModule {}
|
||||||
10
packages/crm-service/src/auth/auth.module.ts
Normal file
10
packages/crm-service/src/auth/auth.module.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { PassportModule } from '@nestjs/passport';
|
||||||
|
import { JwtStrategy } from './strategies/jwt.strategy';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [PassportModule.register({ defaultStrategy: 'jwt' })],
|
||||||
|
providers: [JwtStrategy],
|
||||||
|
exports: [PassportModule],
|
||||||
|
})
|
||||||
|
export class AuthModule {}
|
||||||
56
packages/crm-service/src/auth/guards/jwt-auth.guard.ts
Normal file
56
packages/crm-service/src/auth/guards/jwt-auth.guard.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
import {
|
||||||
|
Injectable,
|
||||||
|
ExecutionContext,
|
||||||
|
UnauthorizedException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Reflector } from '@nestjs/core';
|
||||||
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
import { IS_PUBLIC_KEY } from '../../common/decorators/public.decorator';
|
||||||
|
import { RedisService } from '../../redis/redis.service';
|
||||||
|
import { JwtPayload } from '../../common/decorators/current-user.decorator';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class JwtAuthGuard extends AuthGuard('jwt') {
|
||||||
|
constructor(
|
||||||
|
private readonly reflector: Reflector,
|
||||||
|
private readonly redis: RedisService,
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
|
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
|
||||||
|
context.getHandler(),
|
||||||
|
context.getClass(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (isPublic) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
29
packages/crm-service/src/auth/guards/roles.guard.ts
Normal file
29
packages/crm-service/src/auth/guards/roles.guard.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
|
||||||
|
import { Reflector } from '@nestjs/core';
|
||||||
|
import { ROLES_KEY } from '../../common/decorators/roles.decorator';
|
||||||
|
import { JwtPayload } from '../../common/decorators/current-user.decorator';
|
||||||
|
|
||||||
|
@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);
|
||||||
|
}
|
||||||
|
}
|
||||||
27
packages/crm-service/src/auth/guards/tenant.guard.ts
Normal file
27
packages/crm-service/src/auth/guards/tenant.guard.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
import {
|
||||||
|
Injectable,
|
||||||
|
CanActivate,
|
||||||
|
ExecutionContext,
|
||||||
|
ForbiddenException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { JwtPayload } from '../../common/decorators/current-user.decorator';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class TenantGuard implements CanActivate {
|
||||||
|
canActivate(context: ExecutionContext): boolean {
|
||||||
|
const request = context.switchToHttp().getRequest();
|
||||||
|
const user = request.user as JwtPayload;
|
||||||
|
|
||||||
|
// PLATFORM_ADMIN hat Zugriff auf alle Tenants
|
||||||
|
if (user?.role === 'PLATFORM_ADMIN') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alle anderen User muessen eine tenantId haben
|
||||||
|
if (!user?.tenantId) {
|
||||||
|
throw new ForbiddenException('Kein Mandant zugeordnet');
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
37
packages/crm-service/src/auth/strategies/jwt.strategy.ts
Normal file
37
packages/crm-service/src/auth/strategies/jwt.strategy.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
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';
|
||||||
|
|
||||||
|
@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'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||||
|
import { Request } from 'express';
|
||||||
|
|
||||||
|
export interface JwtPayload {
|
||||||
|
sub: string;
|
||||||
|
email: string;
|
||||||
|
role: string;
|
||||||
|
tenantId?: string;
|
||||||
|
tenantSlug?: string;
|
||||||
|
jti: string;
|
||||||
|
iat: number;
|
||||||
|
exp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
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/crm-service/src/common/decorators/index.ts
Normal file
3
packages/crm-service/src/common/decorators/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
export { CurrentUser, JwtPayload } from './current-user.decorator';
|
||||||
|
export { Public, IS_PUBLIC_KEY } from './public.decorator';
|
||||||
|
export { Roles, ROLES_KEY } from './roles.decorator';
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
import { SetMetadata } from '@nestjs/common';
|
||||||
|
|
||||||
|
export const IS_PUBLIC_KEY = 'isPublic';
|
||||||
|
|
||||||
|
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
import { SetMetadata } from '@nestjs/common';
|
||||||
|
|
||||||
|
export const ROLES_KEY = 'roles';
|
||||||
|
|
||||||
|
export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles);
|
||||||
70
packages/crm-service/src/common/dto/pagination.dto.ts
Normal file
70
packages/crm-service/src/common/dto/pagination.dto.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
import { IsInt, IsOptional, Min, Max } from 'class-validator';
|
||||||
|
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
export class PaginationDto {
|
||||||
|
@ApiPropertyOptional({ default: 1, minimum: 1 })
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
page?: number = 1;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ default: 25, minimum: 1, maximum: 100 })
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@Max(100)
|
||||||
|
pageSize?: number = 25;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaginatedResponse<T> {
|
||||||
|
success: true;
|
||||||
|
data: T[];
|
||||||
|
pagination: {
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
total: number;
|
||||||
|
totalPages: number;
|
||||||
|
};
|
||||||
|
meta: {
|
||||||
|
timestamp: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SingleResponse<T> {
|
||||||
|
success: true;
|
||||||
|
data: T;
|
||||||
|
meta: {
|
||||||
|
timestamp: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function paginatedResponse<T>(
|
||||||
|
data: T[],
|
||||||
|
total: number,
|
||||||
|
page: number,
|
||||||
|
pageSize: number,
|
||||||
|
): PaginatedResponse<T> {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data,
|
||||||
|
pagination: {
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
total,
|
||||||
|
totalPages: Math.ceil(total / pageSize),
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function singleResponse<T>(data: T): SingleResponse<T> {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data,
|
||||||
|
meta: {
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,70 @@
|
||||||
|
import {
|
||||||
|
ExceptionFilter,
|
||||||
|
Catch,
|
||||||
|
ArgumentsHost,
|
||||||
|
HttpException,
|
||||||
|
HttpStatus,
|
||||||
|
Logger,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Response } from 'express';
|
||||||
|
|
||||||
|
@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>();
|
||||||
|
|
||||||
|
let status = HttpStatus.INTERNAL_SERVER_ERROR;
|
||||||
|
let code = 'INTERNAL_ERROR';
|
||||||
|
let message = 'Ein interner Fehler ist aufgetreten.';
|
||||||
|
let details: unknown[] | undefined;
|
||||||
|
|
||||||
|
if (exception instanceof HttpException) {
|
||||||
|
status = exception.getStatus();
|
||||||
|
const exceptionResponse = exception.getResponse();
|
||||||
|
|
||||||
|
if (typeof exceptionResponse === 'string') {
|
||||||
|
message = exceptionResponse;
|
||||||
|
} else if (typeof exceptionResponse === 'object') {
|
||||||
|
const resp = exceptionResponse as Record<string, unknown>;
|
||||||
|
message = (resp.message as string) ?? message;
|
||||||
|
if (Array.isArray(resp.message)) {
|
||||||
|
details = resp.message as unknown[];
|
||||||
|
message = 'Validierungsfehler';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
code = this.getErrorCode(status);
|
||||||
|
} else {
|
||||||
|
this.logger.error('Unbehandelter Fehler', exception);
|
||||||
|
}
|
||||||
|
|
||||||
|
response.status(status).json({
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
code,
|
||||||
|
message,
|
||||||
|
...(details ? { details } : {}),
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private getErrorCode(status: number): string {
|
||||||
|
const codeMap: Record<number, string> = {
|
||||||
|
400: 'BAD_REQUEST',
|
||||||
|
401: 'UNAUTHORIZED',
|
||||||
|
403: 'FORBIDDEN',
|
||||||
|
404: 'NOT_FOUND',
|
||||||
|
409: 'CONFLICT',
|
||||||
|
422: 'UNPROCESSABLE_ENTITY',
|
||||||
|
429: 'TOO_MANY_REQUESTS',
|
||||||
|
500: 'INTERNAL_ERROR',
|
||||||
|
};
|
||||||
|
return codeMap[status] ?? 'UNKNOWN_ERROR';
|
||||||
|
}
|
||||||
|
}
|
||||||
62
packages/crm-service/src/config/env.validation.ts
Normal file
62
packages/crm-service/src/config/env.validation.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
import { plainToInstance } from 'class-transformer';
|
||||||
|
import { IsString, IsNumber, IsOptional, validateSync } from 'class-validator';
|
||||||
|
|
||||||
|
export class EnvironmentVariables {
|
||||||
|
@IsString()
|
||||||
|
DATABASE_URL!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
DATABASE_URL_DIRECT!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
REDIS_HOST?: string;
|
||||||
|
|
||||||
|
@IsNumber()
|
||||||
|
@IsOptional()
|
||||||
|
REDIS_PORT?: number;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
REDIS_PASSWORD?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
JWT_PUBLIC_KEY_PATH?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
JWT_ISSUER?: string;
|
||||||
|
|
||||||
|
@IsNumber()
|
||||||
|
@IsOptional()
|
||||||
|
APP_PORT?: number;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
NODE_ENV?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
CORS_ORIGINS?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validate(
|
||||||
|
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(
|
||||||
|
`Umgebungsvariablen-Validierung fehlgeschlagen:\n${errors.toString()}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return validatedConfig;
|
||||||
|
}
|
||||||
107
packages/crm-service/src/contacts/contacts.controller.ts
Normal file
107
packages/crm-service/src/contacts/contacts.controller.ts
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Post,
|
||||||
|
Patch,
|
||||||
|
Delete,
|
||||||
|
Body,
|
||||||
|
Param,
|
||||||
|
Query,
|
||||||
|
ParseUUIDPipe,
|
||||||
|
HttpCode,
|
||||||
|
HttpStatus,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
ApiTags,
|
||||||
|
ApiOperation,
|
||||||
|
ApiBearerAuth,
|
||||||
|
ApiParam,
|
||||||
|
} from '@nestjs/swagger';
|
||||||
|
import { ContactsService } from './contacts.service';
|
||||||
|
import { CreateContactDto } from './dto/create-contact.dto';
|
||||||
|
import { UpdateContactDto } from './dto/update-contact.dto';
|
||||||
|
import { QueryContactsDto } from './dto/query-contacts.dto';
|
||||||
|
import { CurrentUser, JwtPayload } from '../common/decorators';
|
||||||
|
import { TenantGuard } from '../auth/guards/tenant.guard';
|
||||||
|
import {
|
||||||
|
paginatedResponse,
|
||||||
|
singleResponse,
|
||||||
|
} from '../common/dto/pagination.dto';
|
||||||
|
|
||||||
|
@ApiTags('Contacts')
|
||||||
|
@ApiBearerAuth('access-token')
|
||||||
|
@UseGuards(TenantGuard)
|
||||||
|
@Controller('contacts')
|
||||||
|
export class ContactsController {
|
||||||
|
constructor(private readonly contactsService: ContactsService) {}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@HttpCode(HttpStatus.CREATED)
|
||||||
|
@ApiOperation({ summary: 'Kontakt erstellen' })
|
||||||
|
async create(
|
||||||
|
@CurrentUser() user: JwtPayload,
|
||||||
|
@Body() dto: CreateContactDto,
|
||||||
|
) {
|
||||||
|
const contact = await this.contactsService.create(
|
||||||
|
user.tenantId!,
|
||||||
|
user.sub,
|
||||||
|
dto,
|
||||||
|
);
|
||||||
|
return singleResponse(contact);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@ApiOperation({ summary: 'Kontakte auflisten (paginiert, filterbar)' })
|
||||||
|
async findAll(
|
||||||
|
@CurrentUser() user: JwtPayload,
|
||||||
|
@Query() query: QueryContactsDto,
|
||||||
|
) {
|
||||||
|
const result = await this.contactsService.findAll(user.tenantId!, query);
|
||||||
|
return paginatedResponse(
|
||||||
|
result.data,
|
||||||
|
result.total,
|
||||||
|
result.page,
|
||||||
|
result.pageSize,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
@ApiOperation({ summary: 'Kontakt-Details abrufen' })
|
||||||
|
@ApiParam({ name: 'id', type: 'string', format: 'uuid' })
|
||||||
|
async findOne(
|
||||||
|
@CurrentUser() user: JwtPayload,
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
) {
|
||||||
|
const contact = await this.contactsService.findOne(user.tenantId!, id);
|
||||||
|
return singleResponse(contact);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id')
|
||||||
|
@ApiOperation({ summary: 'Kontakt aktualisieren' })
|
||||||
|
@ApiParam({ name: 'id', type: 'string', format: 'uuid' })
|
||||||
|
async update(
|
||||||
|
@CurrentUser() user: JwtPayload,
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body() dto: UpdateContactDto,
|
||||||
|
) {
|
||||||
|
const contact = await this.contactsService.update(
|
||||||
|
user.tenantId!,
|
||||||
|
id,
|
||||||
|
user.sub,
|
||||||
|
dto,
|
||||||
|
);
|
||||||
|
return singleResponse(contact);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
@ApiOperation({ summary: 'Kontakt loeschen' })
|
||||||
|
@ApiParam({ name: 'id', type: 'string', format: 'uuid' })
|
||||||
|
async remove(
|
||||||
|
@CurrentUser() user: JwtPayload,
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
) {
|
||||||
|
const contact = await this.contactsService.remove(user.tenantId!, id);
|
||||||
|
return singleResponse(contact);
|
||||||
|
}
|
||||||
|
}
|
||||||
10
packages/crm-service/src/contacts/contacts.module.ts
Normal file
10
packages/crm-service/src/contacts/contacts.module.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ContactsController } from './contacts.controller';
|
||||||
|
import { ContactsService } from './contacts.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [ContactsController],
|
||||||
|
providers: [ContactsService],
|
||||||
|
exports: [ContactsService],
|
||||||
|
})
|
||||||
|
export class ContactsModule {}
|
||||||
116
packages/crm-service/src/contacts/contacts.service.ts
Normal file
116
packages/crm-service/src/contacts/contacts.service.ts
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { CrmPrismaService } from '../prisma/crm-prisma.service';
|
||||||
|
import { CreateContactDto } from './dto/create-contact.dto';
|
||||||
|
import { UpdateContactDto } from './dto/update-contact.dto';
|
||||||
|
import { QueryContactsDto } from './dto/query-contacts.dto';
|
||||||
|
import { Prisma } from '.prisma/crm-client';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ContactsService {
|
||||||
|
constructor(private readonly prisma: CrmPrismaService) {}
|
||||||
|
|
||||||
|
async create(tenantId: string, userId: string, dto: CreateContactDto) {
|
||||||
|
return this.prisma.contact.create({
|
||||||
|
data: {
|
||||||
|
tenantId,
|
||||||
|
createdBy: userId,
|
||||||
|
type: dto.type,
|
||||||
|
firstName: dto.firstName,
|
||||||
|
lastName: dto.lastName,
|
||||||
|
companyName: dto.companyName,
|
||||||
|
email: dto.email,
|
||||||
|
phone: dto.phone,
|
||||||
|
mobile: dto.mobile,
|
||||||
|
website: dto.website,
|
||||||
|
street: dto.street,
|
||||||
|
zip: dto.zip,
|
||||||
|
city: dto.city,
|
||||||
|
state: dto.state,
|
||||||
|
country: dto.country,
|
||||||
|
notes: dto.notes,
|
||||||
|
tags: dto.tags ?? [],
|
||||||
|
isActive: dto.isActive ?? true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async findAll(tenantId: string, query: QueryContactsDto) {
|
||||||
|
const page = query.page ?? 1;
|
||||||
|
const pageSize = query.pageSize ?? 25;
|
||||||
|
|
||||||
|
const where: Prisma.ContactWhereInput = { tenantId };
|
||||||
|
|
||||||
|
if (query.type) {
|
||||||
|
where.type = query.type;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.search) {
|
||||||
|
where.OR = [
|
||||||
|
{ firstName: { contains: query.search, mode: 'insensitive' } },
|
||||||
|
{ lastName: { contains: query.search, mode: 'insensitive' } },
|
||||||
|
{ companyName: { contains: query.search, mode: 'insensitive' } },
|
||||||
|
{ email: { contains: query.search, mode: 'insensitive' } },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
const allowedSortFields = [
|
||||||
|
'createdAt',
|
||||||
|
'updatedAt',
|
||||||
|
'firstName',
|
||||||
|
'lastName',
|
||||||
|
'companyName',
|
||||||
|
'email',
|
||||||
|
];
|
||||||
|
const sortField = allowedSortFields.includes(query.sort ?? '')
|
||||||
|
? (query.sort as string)
|
||||||
|
: 'createdAt';
|
||||||
|
|
||||||
|
const [data, total] = await Promise.all([
|
||||||
|
this.prisma.contact.findMany({
|
||||||
|
where,
|
||||||
|
skip: (page - 1) * pageSize,
|
||||||
|
take: pageSize,
|
||||||
|
orderBy: { [sortField]: query.order ?? 'desc' },
|
||||||
|
}),
|
||||||
|
this.prisma.contact.count({ where }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return { data, total, page, pageSize };
|
||||||
|
}
|
||||||
|
|
||||||
|
async findOne(tenantId: string, id: string) {
|
||||||
|
const contact = await this.prisma.contact.findFirst({
|
||||||
|
where: { id, tenantId },
|
||||||
|
include: { activities: { orderBy: { createdAt: 'desc' }, take: 10 } },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!contact) {
|
||||||
|
throw new NotFoundException('Kontakt nicht gefunden');
|
||||||
|
}
|
||||||
|
|
||||||
|
return contact;
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(
|
||||||
|
tenantId: string,
|
||||||
|
id: string,
|
||||||
|
userId: string,
|
||||||
|
dto: UpdateContactDto,
|
||||||
|
) {
|
||||||
|
await this.findOne(tenantId, id);
|
||||||
|
|
||||||
|
return this.prisma.contact.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
...dto,
|
||||||
|
updatedBy: userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(tenantId: string, id: string) {
|
||||||
|
await this.findOne(tenantId, id);
|
||||||
|
|
||||||
|
return this.prisma.contact.delete({ where: { id } });
|
||||||
|
}
|
||||||
|
}
|
||||||
111
packages/crm-service/src/contacts/dto/create-contact.dto.ts
Normal file
111
packages/crm-service/src/contacts/dto/create-contact.dto.ts
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
import {
|
||||||
|
IsString,
|
||||||
|
IsOptional,
|
||||||
|
IsEnum,
|
||||||
|
IsBoolean,
|
||||||
|
IsArray,
|
||||||
|
MaxLength,
|
||||||
|
IsEmail,
|
||||||
|
IsUrl,
|
||||||
|
} from 'class-validator';
|
||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
export enum ContactType {
|
||||||
|
PERSON = 'PERSON',
|
||||||
|
ORGANIZATION = 'ORGANIZATION',
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CreateContactDto {
|
||||||
|
@ApiPropertyOptional({ enum: ContactType, default: ContactType.PERSON })
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(ContactType)
|
||||||
|
type?: ContactType = ContactType.PERSON;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ maxLength: 100 })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(100)
|
||||||
|
firstName?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ maxLength: 100 })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(100)
|
||||||
|
lastName?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ maxLength: 200 })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(200)
|
||||||
|
companyName?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ maxLength: 255 })
|
||||||
|
@IsOptional()
|
||||||
|
@IsEmail()
|
||||||
|
@MaxLength(255)
|
||||||
|
email?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ maxLength: 50 })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(50)
|
||||||
|
phone?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ maxLength: 50 })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(50)
|
||||||
|
mobile?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ maxLength: 500 })
|
||||||
|
@IsOptional()
|
||||||
|
@IsUrl()
|
||||||
|
@MaxLength(500)
|
||||||
|
website?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ maxLength: 200 })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(200)
|
||||||
|
street?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ maxLength: 20 })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(20)
|
||||||
|
zip?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ maxLength: 100 })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(100)
|
||||||
|
city?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ maxLength: 100 })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(100)
|
||||||
|
state?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ maxLength: 2, default: 'DE' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(2)
|
||||||
|
country?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
notes?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ type: [String] })
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
tags?: string[];
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ default: true })
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
isActive?: boolean;
|
||||||
|
}
|
||||||
26
packages/crm-service/src/contacts/dto/query-contacts.dto.ts
Normal file
26
packages/crm-service/src/contacts/dto/query-contacts.dto.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
import { IsString, IsOptional, IsEnum } from 'class-validator';
|
||||||
|
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { PaginationDto } from '../../common/dto/pagination.dto';
|
||||||
|
import { ContactType } from './create-contact.dto';
|
||||||
|
|
||||||
|
export class QueryContactsDto extends PaginationDto {
|
||||||
|
@ApiPropertyOptional({ description: 'Suchbegriff (Name, Firma, E-Mail)' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
search?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ enum: ContactType })
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(ContactType)
|
||||||
|
type?: ContactType;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Sortierfeld', default: 'createdAt' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
sort?: string = 'createdAt';
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ enum: ['asc', 'desc'], default: 'desc' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(['asc', 'desc'] as const)
|
||||||
|
order?: 'asc' | 'desc' = 'desc';
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
import { PartialType } from '@nestjs/swagger';
|
||||||
|
import { CreateContactDto } from './create-contact.dto';
|
||||||
|
|
||||||
|
export class UpdateContactDto extends PartialType(CreateContactDto) {}
|
||||||
107
packages/crm-service/src/deals/deals.controller.ts
Normal file
107
packages/crm-service/src/deals/deals.controller.ts
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Post,
|
||||||
|
Patch,
|
||||||
|
Delete,
|
||||||
|
Body,
|
||||||
|
Param,
|
||||||
|
Query,
|
||||||
|
ParseUUIDPipe,
|
||||||
|
HttpCode,
|
||||||
|
HttpStatus,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
ApiTags,
|
||||||
|
ApiOperation,
|
||||||
|
ApiBearerAuth,
|
||||||
|
ApiParam,
|
||||||
|
} from '@nestjs/swagger';
|
||||||
|
import { DealsService } from './deals.service';
|
||||||
|
import { CreateDealDto } from './dto/create-deal.dto';
|
||||||
|
import { UpdateDealDto } from './dto/update-deal.dto';
|
||||||
|
import { QueryDealsDto } from './dto/query-deals.dto';
|
||||||
|
import { CurrentUser, JwtPayload } from '../common/decorators';
|
||||||
|
import { TenantGuard } from '../auth/guards/tenant.guard';
|
||||||
|
import {
|
||||||
|
paginatedResponse,
|
||||||
|
singleResponse,
|
||||||
|
} from '../common/dto/pagination.dto';
|
||||||
|
|
||||||
|
@ApiTags('Deals')
|
||||||
|
@ApiBearerAuth('access-token')
|
||||||
|
@UseGuards(TenantGuard)
|
||||||
|
@Controller('deals')
|
||||||
|
export class DealsController {
|
||||||
|
constructor(private readonly dealsService: DealsService) {}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@HttpCode(HttpStatus.CREATED)
|
||||||
|
@ApiOperation({ summary: 'Deal erstellen' })
|
||||||
|
async create(
|
||||||
|
@CurrentUser() user: JwtPayload,
|
||||||
|
@Body() dto: CreateDealDto,
|
||||||
|
) {
|
||||||
|
const deal = await this.dealsService.create(
|
||||||
|
user.tenantId!,
|
||||||
|
user.sub,
|
||||||
|
dto,
|
||||||
|
);
|
||||||
|
return singleResponse(deal);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@ApiOperation({ summary: 'Deals auflisten (paginiert, filterbar)' })
|
||||||
|
async findAll(
|
||||||
|
@CurrentUser() user: JwtPayload,
|
||||||
|
@Query() query: QueryDealsDto,
|
||||||
|
) {
|
||||||
|
const result = await this.dealsService.findAll(user.tenantId!, query);
|
||||||
|
return paginatedResponse(
|
||||||
|
result.data,
|
||||||
|
result.total,
|
||||||
|
result.page,
|
||||||
|
result.pageSize,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
@ApiOperation({ summary: 'Deal-Details abrufen' })
|
||||||
|
@ApiParam({ name: 'id', type: 'string', format: 'uuid' })
|
||||||
|
async findOne(
|
||||||
|
@CurrentUser() user: JwtPayload,
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
) {
|
||||||
|
const deal = await this.dealsService.findOne(user.tenantId!, id);
|
||||||
|
return singleResponse(deal);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id')
|
||||||
|
@ApiOperation({ summary: 'Deal aktualisieren' })
|
||||||
|
@ApiParam({ name: 'id', type: 'string', format: 'uuid' })
|
||||||
|
async update(
|
||||||
|
@CurrentUser() user: JwtPayload,
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body() dto: UpdateDealDto,
|
||||||
|
) {
|
||||||
|
const deal = await this.dealsService.update(
|
||||||
|
user.tenantId!,
|
||||||
|
id,
|
||||||
|
user.sub,
|
||||||
|
dto,
|
||||||
|
);
|
||||||
|
return singleResponse(deal);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
@ApiOperation({ summary: 'Deal loeschen' })
|
||||||
|
@ApiParam({ name: 'id', type: 'string', format: 'uuid' })
|
||||||
|
async remove(
|
||||||
|
@CurrentUser() user: JwtPayload,
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
) {
|
||||||
|
const deal = await this.dealsService.remove(user.tenantId!, id);
|
||||||
|
return singleResponse(deal);
|
||||||
|
}
|
||||||
|
}
|
||||||
10
packages/crm-service/src/deals/deals.module.ts
Normal file
10
packages/crm-service/src/deals/deals.module.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { DealsController } from './deals.controller';
|
||||||
|
import { DealsService } from './deals.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [DealsController],
|
||||||
|
providers: [DealsService],
|
||||||
|
exports: [DealsService],
|
||||||
|
})
|
||||||
|
export class DealsModule {}
|
||||||
199
packages/crm-service/src/deals/deals.service.ts
Normal file
199
packages/crm-service/src/deals/deals.service.ts
Normal file
|
|
@ -0,0 +1,199 @@
|
||||||
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { CrmPrismaService } from '../prisma/crm-prisma.service';
|
||||||
|
import { CreateDealDto } from './dto/create-deal.dto';
|
||||||
|
import { UpdateDealDto } from './dto/update-deal.dto';
|
||||||
|
import { QueryDealsDto } from './dto/query-deals.dto';
|
||||||
|
import { Prisma } from '.prisma/crm-client';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class DealsService {
|
||||||
|
constructor(private readonly prisma: CrmPrismaService) {}
|
||||||
|
|
||||||
|
async create(tenantId: string, userId: string, dto: CreateDealDto) {
|
||||||
|
// Pipeline und Stage validieren
|
||||||
|
const pipeline = await this.prisma.pipeline.findFirst({
|
||||||
|
where: { id: dto.pipelineId, tenantId },
|
||||||
|
});
|
||||||
|
if (!pipeline) {
|
||||||
|
throw new NotFoundException('Pipeline nicht gefunden');
|
||||||
|
}
|
||||||
|
|
||||||
|
const stage = await this.prisma.pipelineStage.findFirst({
|
||||||
|
where: { id: dto.stageId, pipelineId: dto.pipelineId },
|
||||||
|
});
|
||||||
|
if (!stage) {
|
||||||
|
throw new NotFoundException('Pipeline-Stufe nicht gefunden');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kontakt validieren (optional)
|
||||||
|
if (dto.contactId) {
|
||||||
|
const contact = await this.prisma.contact.findFirst({
|
||||||
|
where: { id: dto.contactId, tenantId },
|
||||||
|
});
|
||||||
|
if (!contact) {
|
||||||
|
throw new NotFoundException('Kontakt nicht gefunden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.prisma.deal.create({
|
||||||
|
data: {
|
||||||
|
tenantId,
|
||||||
|
pipelineId: dto.pipelineId,
|
||||||
|
stageId: dto.stageId,
|
||||||
|
contactId: dto.contactId,
|
||||||
|
title: dto.title,
|
||||||
|
value: dto.value,
|
||||||
|
currency: dto.currency ?? 'EUR',
|
||||||
|
status: dto.status ?? 'OPEN',
|
||||||
|
expectedCloseDate: dto.expectedCloseDate
|
||||||
|
? new Date(dto.expectedCloseDate)
|
||||||
|
: undefined,
|
||||||
|
notes: dto.notes,
|
||||||
|
createdBy: userId,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
pipeline: { select: { id: true, name: true } },
|
||||||
|
stage: { select: { id: true, name: true, color: true } },
|
||||||
|
contact: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
companyName: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async findAll(tenantId: string, query: QueryDealsDto) {
|
||||||
|
const page = query.page ?? 1;
|
||||||
|
const pageSize = query.pageSize ?? 25;
|
||||||
|
|
||||||
|
const where: Prisma.DealWhereInput = { tenantId };
|
||||||
|
|
||||||
|
if (query.pipelineId) {
|
||||||
|
where.pipelineId = query.pipelineId;
|
||||||
|
}
|
||||||
|
if (query.stageId) {
|
||||||
|
where.stageId = query.stageId;
|
||||||
|
}
|
||||||
|
if (query.contactId) {
|
||||||
|
where.contactId = query.contactId;
|
||||||
|
}
|
||||||
|
if (query.status) {
|
||||||
|
where.status = query.status;
|
||||||
|
}
|
||||||
|
if (query.search) {
|
||||||
|
where.title = { contains: query.search, mode: 'insensitive' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const allowedSortFields = [
|
||||||
|
'createdAt',
|
||||||
|
'updatedAt',
|
||||||
|
'title',
|
||||||
|
'value',
|
||||||
|
'expectedCloseDate',
|
||||||
|
];
|
||||||
|
const sortField = allowedSortFields.includes(query.sort ?? '')
|
||||||
|
? (query.sort as string)
|
||||||
|
: 'createdAt';
|
||||||
|
|
||||||
|
const [data, total] = await Promise.all([
|
||||||
|
this.prisma.deal.findMany({
|
||||||
|
where,
|
||||||
|
skip: (page - 1) * pageSize,
|
||||||
|
take: pageSize,
|
||||||
|
orderBy: { [sortField]: query.order ?? 'desc' },
|
||||||
|
include: {
|
||||||
|
pipeline: { select: { id: true, name: true } },
|
||||||
|
stage: { select: { id: true, name: true, color: true } },
|
||||||
|
contact: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
companyName: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
this.prisma.deal.count({ where }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return { data, total, page, pageSize };
|
||||||
|
}
|
||||||
|
|
||||||
|
async findOne(tenantId: string, id: string) {
|
||||||
|
const deal = await this.prisma.deal.findFirst({
|
||||||
|
where: { id, tenantId },
|
||||||
|
include: {
|
||||||
|
pipeline: { include: { stages: { orderBy: { sortOrder: 'asc' } } } },
|
||||||
|
stage: true,
|
||||||
|
contact: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!deal) {
|
||||||
|
throw new NotFoundException('Deal nicht gefunden');
|
||||||
|
}
|
||||||
|
|
||||||
|
return deal;
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(
|
||||||
|
tenantId: string,
|
||||||
|
id: string,
|
||||||
|
userId: string,
|
||||||
|
dto: UpdateDealDto,
|
||||||
|
) {
|
||||||
|
await this.findOne(tenantId, id);
|
||||||
|
|
||||||
|
// Stage validieren wenn geaendert
|
||||||
|
if (dto.stageId) {
|
||||||
|
const deal = await this.prisma.deal.findUnique({ where: { id } });
|
||||||
|
const pipelineId = dto.pipelineId ?? deal?.pipelineId;
|
||||||
|
const stage = await this.prisma.pipelineStage.findFirst({
|
||||||
|
where: { id: dto.stageId, pipelineId },
|
||||||
|
});
|
||||||
|
if (!stage) {
|
||||||
|
throw new NotFoundException('Pipeline-Stufe nicht gefunden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateData: Prisma.DealUpdateInput = {
|
||||||
|
...dto,
|
||||||
|
expectedCloseDate: dto.expectedCloseDate
|
||||||
|
? new Date(dto.expectedCloseDate)
|
||||||
|
: undefined,
|
||||||
|
updatedBy: userId,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Wenn Deal gewonnen/verloren, closedAt setzen
|
||||||
|
if (dto.status === 'WON' || dto.status === 'LOST') {
|
||||||
|
updateData.closedAt = new Date();
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.prisma.deal.update({
|
||||||
|
where: { id },
|
||||||
|
data: updateData,
|
||||||
|
include: {
|
||||||
|
pipeline: { select: { id: true, name: true } },
|
||||||
|
stage: { select: { id: true, name: true, color: true } },
|
||||||
|
contact: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
companyName: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(tenantId: string, id: string) {
|
||||||
|
await this.findOne(tenantId, id);
|
||||||
|
return this.prisma.deal.delete({ where: { id } });
|
||||||
|
}
|
||||||
|
}
|
||||||
64
packages/crm-service/src/deals/dto/create-deal.dto.ts
Normal file
64
packages/crm-service/src/deals/dto/create-deal.dto.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
import {
|
||||||
|
IsString,
|
||||||
|
IsOptional,
|
||||||
|
IsUUID,
|
||||||
|
IsNumber,
|
||||||
|
IsDateString,
|
||||||
|
IsEnum,
|
||||||
|
MaxLength,
|
||||||
|
Min,
|
||||||
|
} from 'class-validator';
|
||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
export enum DealStatus {
|
||||||
|
OPEN = 'OPEN',
|
||||||
|
WON = 'WON',
|
||||||
|
LOST = 'LOST',
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CreateDealDto {
|
||||||
|
@ApiProperty({ format: 'uuid' })
|
||||||
|
@IsUUID()
|
||||||
|
pipelineId!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ format: 'uuid' })
|
||||||
|
@IsUUID()
|
||||||
|
stageId!: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ format: 'uuid' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
contactId?: string;
|
||||||
|
|
||||||
|
@ApiProperty({ maxLength: 500 })
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(500)
|
||||||
|
title!: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Wert in Waehrung (z.B. 15000.00)' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber({ maxDecimalPlaces: 2 })
|
||||||
|
@Min(0)
|
||||||
|
value?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ default: 'EUR', maxLength: 3 })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(3)
|
||||||
|
currency?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ enum: DealStatus, default: DealStatus.OPEN })
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(DealStatus)
|
||||||
|
status?: DealStatus;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ format: 'date-time' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
expectedCloseDate?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
41
packages/crm-service/src/deals/dto/query-deals.dto.ts
Normal file
41
packages/crm-service/src/deals/dto/query-deals.dto.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
import { IsString, IsOptional, IsEnum, IsUUID } from 'class-validator';
|
||||||
|
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { PaginationDto } from '../../common/dto/pagination.dto';
|
||||||
|
import { DealStatus } from './create-deal.dto';
|
||||||
|
|
||||||
|
export class QueryDealsDto extends PaginationDto {
|
||||||
|
@ApiPropertyOptional({ format: 'uuid', description: 'Filter nach Pipeline' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
pipelineId?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ format: 'uuid', description: 'Filter nach Stage' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
stageId?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ format: 'uuid', description: 'Filter nach Kontakt' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
contactId?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ enum: DealStatus })
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(DealStatus)
|
||||||
|
status?: DealStatus;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Suchbegriff (Titel)' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
search?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ default: 'createdAt' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
sort?: string = 'createdAt';
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ enum: ['asc', 'desc'], default: 'desc' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(['asc', 'desc'] as const)
|
||||||
|
order?: 'asc' | 'desc' = 'desc';
|
||||||
|
}
|
||||||
4
packages/crm-service/src/deals/dto/update-deal.dto.ts
Normal file
4
packages/crm-service/src/deals/dto/update-deal.dto.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
import { PartialType } from '@nestjs/swagger';
|
||||||
|
import { CreateDealDto } from './create-deal.dto';
|
||||||
|
|
||||||
|
export class UpdateDealDto extends PartialType(CreateDealDto) {}
|
||||||
74
packages/crm-service/src/health/health.controller.ts
Normal file
74
packages/crm-service/src/health/health.controller.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
import { Controller, Get } from '@nestjs/common';
|
||||||
|
import { ApiTags, ApiOperation } from '@nestjs/swagger';
|
||||||
|
import { Public } from '../common/decorators/public.decorator';
|
||||||
|
import { CrmPrismaService } from '../prisma/crm-prisma.service';
|
||||||
|
import { RedisService } from '../redis/redis.service';
|
||||||
|
|
||||||
|
interface HealthResponse {
|
||||||
|
status: 'ok' | 'error';
|
||||||
|
service: string;
|
||||||
|
timestamp: string;
|
||||||
|
version: string;
|
||||||
|
services: {
|
||||||
|
database: 'up' | 'down';
|
||||||
|
redis: 'up' | 'down';
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiTags('Health')
|
||||||
|
@Controller('health')
|
||||||
|
export class HealthController {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: CrmPrismaService,
|
||||||
|
private readonly redis: RedisService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@Public()
|
||||||
|
@ApiOperation({ summary: 'Health-Check fuer CRM-Service' })
|
||||||
|
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',
|
||||||
|
service: 'crm-service',
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
7
packages/crm-service/src/health/health.module.ts
Normal file
7
packages/crm-service/src/health/health.module.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { HealthController } from './health.controller';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [HealthController],
|
||||||
|
})
|
||||||
|
export class HealthModule {}
|
||||||
81
packages/crm-service/src/main.ts
Normal file
81
packages/crm-service/src/main.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
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 (12MB fuer Base64-Uploads)
|
||||||
|
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 (whitelist + forbidNonWhitelisted)
|
||||||
|
app.useGlobalPipes(
|
||||||
|
new ValidationPipe({
|
||||||
|
whitelist: true,
|
||||||
|
forbidNonWhitelisted: true,
|
||||||
|
transform: true,
|
||||||
|
transformOptions: { enableImplicitConversion: true },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Global Prefix: /api/v1/crm (NICHT /api/v1 — das ist der Core!)
|
||||||
|
app.setGlobalPrefix('api/v1/crm', {
|
||||||
|
exclude: ['health'],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Swagger (nur Development)
|
||||||
|
if (process.env.NODE_ENV !== 'production') {
|
||||||
|
const config = new DocumentBuilder()
|
||||||
|
.setTitle('INSIGHT CRM API')
|
||||||
|
.setDescription('CRM Module API for INSIGHT Platform')
|
||||||
|
.setVersion('0.1.0')
|
||||||
|
.addBearerAuth(
|
||||||
|
{
|
||||||
|
type: 'http',
|
||||||
|
scheme: 'bearer',
|
||||||
|
bearerFormat: 'JWT',
|
||||||
|
description: 'Access Token (RS256)',
|
||||||
|
},
|
||||||
|
'access-token',
|
||||||
|
)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
const document = SwaggerModule.createDocument(app, config);
|
||||||
|
SwaggerModule.setup('api/v1/crm/docs', app, document);
|
||||||
|
logger.log('Swagger UI: /api/v1/crm/docs');
|
||||||
|
}
|
||||||
|
|
||||||
|
const port = process.env.APP_PORT ?? 3100;
|
||||||
|
await app.listen(port);
|
||||||
|
logger.log(`CRM-Service laeuft auf Port ${port}`);
|
||||||
|
logger.log(`Umgebung: ${process.env.NODE_ENV ?? 'development'}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
bootstrap();
|
||||||
|
|
@ -0,0 +1,51 @@
|
||||||
|
import {
|
||||||
|
IsString,
|
||||||
|
IsOptional,
|
||||||
|
IsBoolean,
|
||||||
|
IsArray,
|
||||||
|
ValidateNested,
|
||||||
|
IsInt,
|
||||||
|
Min,
|
||||||
|
MaxLength,
|
||||||
|
Matches,
|
||||||
|
} from 'class-validator';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
export class CreatePipelineStageDto {
|
||||||
|
@ApiProperty({ maxLength: 200 })
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(200)
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ default: 0 })
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
sortOrder?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ default: '#6B7280', description: 'Hex-Farbcode' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@Matches(/^#[0-9A-Fa-f]{6}$/)
|
||||||
|
color?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CreatePipelineDto {
|
||||||
|
@ApiProperty({ maxLength: 200 })
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(200)
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ default: false })
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
isDefault?: boolean;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ type: [CreatePipelineStageDto] })
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => CreatePipelineStageDto)
|
||||||
|
stages?: CreatePipelineStageDto[];
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
import { IsString, IsOptional, IsBoolean, MaxLength } from 'class-validator';
|
||||||
|
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
export class UpdatePipelineDto {
|
||||||
|
@ApiPropertyOptional({ maxLength: 200 })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(200)
|
||||||
|
name?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
isDefault?: boolean;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
isActive?: boolean;
|
||||||
|
}
|
||||||
135
packages/crm-service/src/pipelines/pipelines.controller.ts
Normal file
135
packages/crm-service/src/pipelines/pipelines.controller.ts
Normal file
|
|
@ -0,0 +1,135 @@
|
||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Post,
|
||||||
|
Patch,
|
||||||
|
Delete,
|
||||||
|
Body,
|
||||||
|
Param,
|
||||||
|
ParseUUIDPipe,
|
||||||
|
HttpCode,
|
||||||
|
HttpStatus,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
ApiTags,
|
||||||
|
ApiOperation,
|
||||||
|
ApiBearerAuth,
|
||||||
|
ApiParam,
|
||||||
|
} from '@nestjs/swagger';
|
||||||
|
import { PipelinesService } from './pipelines.service';
|
||||||
|
import { CreatePipelineDto, CreatePipelineStageDto } from './dto/create-pipeline.dto';
|
||||||
|
import { UpdatePipelineDto } from './dto/update-pipeline.dto';
|
||||||
|
import { CurrentUser, JwtPayload } from '../common/decorators';
|
||||||
|
import { TenantGuard } from '../auth/guards/tenant.guard';
|
||||||
|
import { singleResponse } from '../common/dto/pagination.dto';
|
||||||
|
|
||||||
|
@ApiTags('Pipelines')
|
||||||
|
@ApiBearerAuth('access-token')
|
||||||
|
@UseGuards(TenantGuard)
|
||||||
|
@Controller('pipelines')
|
||||||
|
export class PipelinesController {
|
||||||
|
constructor(private readonly pipelinesService: PipelinesService) {}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@HttpCode(HttpStatus.CREATED)
|
||||||
|
@ApiOperation({ summary: 'Pipeline erstellen' })
|
||||||
|
async create(
|
||||||
|
@CurrentUser() user: JwtPayload,
|
||||||
|
@Body() dto: CreatePipelineDto,
|
||||||
|
) {
|
||||||
|
const pipeline = await this.pipelinesService.create(
|
||||||
|
user.tenantId!,
|
||||||
|
user.sub,
|
||||||
|
dto,
|
||||||
|
);
|
||||||
|
return singleResponse(pipeline);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@ApiOperation({ summary: 'Alle Pipelines auflisten' })
|
||||||
|
async findAll(@CurrentUser() user: JwtPayload) {
|
||||||
|
const pipelines = await this.pipelinesService.findAll(user.tenantId!);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: pipelines,
|
||||||
|
meta: { timestamp: new Date().toISOString() },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
@ApiOperation({ summary: 'Pipeline-Details abrufen' })
|
||||||
|
@ApiParam({ name: 'id', type: 'string', format: 'uuid' })
|
||||||
|
async findOne(
|
||||||
|
@CurrentUser() user: JwtPayload,
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
) {
|
||||||
|
const pipeline = await this.pipelinesService.findOne(user.tenantId!, id);
|
||||||
|
return singleResponse(pipeline);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id')
|
||||||
|
@ApiOperation({ summary: 'Pipeline aktualisieren' })
|
||||||
|
@ApiParam({ name: 'id', type: 'string', format: 'uuid' })
|
||||||
|
async update(
|
||||||
|
@CurrentUser() user: JwtPayload,
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body() dto: UpdatePipelineDto,
|
||||||
|
) {
|
||||||
|
const pipeline = await this.pipelinesService.update(
|
||||||
|
user.tenantId!,
|
||||||
|
id,
|
||||||
|
user.sub,
|
||||||
|
dto,
|
||||||
|
);
|
||||||
|
return singleResponse(pipeline);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
@ApiOperation({ summary: 'Pipeline loeschen' })
|
||||||
|
@ApiParam({ name: 'id', type: 'string', format: 'uuid' })
|
||||||
|
async remove(
|
||||||
|
@CurrentUser() user: JwtPayload,
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
) {
|
||||||
|
const pipeline = await this.pipelinesService.remove(user.tenantId!, id);
|
||||||
|
return singleResponse(pipeline);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stage Management
|
||||||
|
@Post(':id/stages')
|
||||||
|
@HttpCode(HttpStatus.CREATED)
|
||||||
|
@ApiOperation({ summary: 'Stufe zur Pipeline hinzufuegen' })
|
||||||
|
@ApiParam({ name: 'id', type: 'string', format: 'uuid' })
|
||||||
|
async addStage(
|
||||||
|
@CurrentUser() user: JwtPayload,
|
||||||
|
@Param('id', ParseUUIDPipe) pipelineId: string,
|
||||||
|
@Body() dto: CreatePipelineStageDto,
|
||||||
|
) {
|
||||||
|
const stage = await this.pipelinesService.addStage(
|
||||||
|
user.tenantId!,
|
||||||
|
pipelineId,
|
||||||
|
dto.name,
|
||||||
|
dto.sortOrder ?? 0,
|
||||||
|
dto.color,
|
||||||
|
);
|
||||||
|
return singleResponse(stage);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id/stages/:stageId')
|
||||||
|
@ApiOperation({ summary: 'Stufe aus Pipeline entfernen' })
|
||||||
|
@ApiParam({ name: 'id', type: 'string', format: 'uuid' })
|
||||||
|
@ApiParam({ name: 'stageId', type: 'string', format: 'uuid' })
|
||||||
|
async removeStage(
|
||||||
|
@CurrentUser() user: JwtPayload,
|
||||||
|
@Param('id', ParseUUIDPipe) pipelineId: string,
|
||||||
|
@Param('stageId', ParseUUIDPipe) stageId: string,
|
||||||
|
) {
|
||||||
|
const stage = await this.pipelinesService.removeStage(
|
||||||
|
user.tenantId!,
|
||||||
|
pipelineId,
|
||||||
|
stageId,
|
||||||
|
);
|
||||||
|
return singleResponse(stage);
|
||||||
|
}
|
||||||
|
}
|
||||||
10
packages/crm-service/src/pipelines/pipelines.module.ts
Normal file
10
packages/crm-service/src/pipelines/pipelines.module.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { PipelinesController } from './pipelines.controller';
|
||||||
|
import { PipelinesService } from './pipelines.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [PipelinesController],
|
||||||
|
providers: [PipelinesService],
|
||||||
|
exports: [PipelinesService],
|
||||||
|
})
|
||||||
|
export class PipelinesModule {}
|
||||||
114
packages/crm-service/src/pipelines/pipelines.service.ts
Normal file
114
packages/crm-service/src/pipelines/pipelines.service.ts
Normal file
|
|
@ -0,0 +1,114 @@
|
||||||
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { CrmPrismaService } from '../prisma/crm-prisma.service';
|
||||||
|
import { CreatePipelineDto } from './dto/create-pipeline.dto';
|
||||||
|
import { UpdatePipelineDto } from './dto/update-pipeline.dto';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PipelinesService {
|
||||||
|
constructor(private readonly prisma: CrmPrismaService) {}
|
||||||
|
|
||||||
|
async create(tenantId: string, userId: string, dto: CreatePipelineDto) {
|
||||||
|
return this.prisma.pipeline.create({
|
||||||
|
data: {
|
||||||
|
tenantId,
|
||||||
|
name: dto.name,
|
||||||
|
isDefault: dto.isDefault ?? false,
|
||||||
|
createdBy: userId,
|
||||||
|
stages: dto.stages
|
||||||
|
? {
|
||||||
|
create: dto.stages.map((stage, index) => ({
|
||||||
|
name: stage.name,
|
||||||
|
sortOrder: stage.sortOrder ?? index,
|
||||||
|
color: stage.color ?? '#6B7280',
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
include: { stages: { orderBy: { sortOrder: 'asc' } } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async findAll(tenantId: string) {
|
||||||
|
return this.prisma.pipeline.findMany({
|
||||||
|
where: { tenantId, isActive: true },
|
||||||
|
include: {
|
||||||
|
stages: { orderBy: { sortOrder: 'asc' } },
|
||||||
|
_count: { select: { deals: true } },
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async findOne(tenantId: string, id: string) {
|
||||||
|
const pipeline = await this.prisma.pipeline.findFirst({
|
||||||
|
where: { id, tenantId },
|
||||||
|
include: {
|
||||||
|
stages: { orderBy: { sortOrder: 'asc' } },
|
||||||
|
_count: { select: { deals: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!pipeline) {
|
||||||
|
throw new NotFoundException('Pipeline nicht gefunden');
|
||||||
|
}
|
||||||
|
|
||||||
|
return pipeline;
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(
|
||||||
|
tenantId: string,
|
||||||
|
id: string,
|
||||||
|
userId: string,
|
||||||
|
dto: UpdatePipelineDto,
|
||||||
|
) {
|
||||||
|
await this.findOne(tenantId, id);
|
||||||
|
|
||||||
|
return this.prisma.pipeline.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
...dto,
|
||||||
|
updatedBy: userId,
|
||||||
|
},
|
||||||
|
include: { stages: { orderBy: { sortOrder: 'asc' } } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(tenantId: string, id: string) {
|
||||||
|
await this.findOne(tenantId, id);
|
||||||
|
return this.prisma.pipeline.delete({ where: { id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pipeline-Stage Management
|
||||||
|
async addStage(
|
||||||
|
tenantId: string,
|
||||||
|
pipelineId: string,
|
||||||
|
name: string,
|
||||||
|
sortOrder: number,
|
||||||
|
color?: string,
|
||||||
|
) {
|
||||||
|
await this.findOne(tenantId, pipelineId);
|
||||||
|
|
||||||
|
return this.prisma.pipelineStage.create({
|
||||||
|
data: {
|
||||||
|
pipelineId,
|
||||||
|
name,
|
||||||
|
sortOrder,
|
||||||
|
color: color ?? '#6B7280',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeStage(tenantId: string, pipelineId: string, stageId: string) {
|
||||||
|
await this.findOne(tenantId, pipelineId);
|
||||||
|
|
||||||
|
const stage = await this.prisma.pipelineStage.findFirst({
|
||||||
|
where: { id: stageId, pipelineId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!stage) {
|
||||||
|
throw new NotFoundException('Pipeline-Stufe nicht gefunden');
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.prisma.pipelineStage.delete({ where: { id: stageId } });
|
||||||
|
}
|
||||||
|
}
|
||||||
9
packages/crm-service/src/prisma/crm-prisma.module.ts
Normal file
9
packages/crm-service/src/prisma/crm-prisma.module.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
import { Global, Module } from '@nestjs/common';
|
||||||
|
import { CrmPrismaService } from './crm-prisma.service';
|
||||||
|
|
||||||
|
@Global()
|
||||||
|
@Module({
|
||||||
|
providers: [CrmPrismaService],
|
||||||
|
exports: [CrmPrismaService],
|
||||||
|
})
|
||||||
|
export class CrmPrismaModule {}
|
||||||
25
packages/crm-service/src/prisma/crm-prisma.service.ts
Normal file
25
packages/crm-service/src/prisma/crm-prisma.service.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
import {
|
||||||
|
Injectable,
|
||||||
|
OnModuleInit,
|
||||||
|
OnModuleDestroy,
|
||||||
|
Logger,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { PrismaClient } from '.prisma/crm-client';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CrmPrismaService
|
||||||
|
extends PrismaClient
|
||||||
|
implements OnModuleInit, OnModuleDestroy
|
||||||
|
{
|
||||||
|
private readonly logger = new Logger(CrmPrismaService.name);
|
||||||
|
|
||||||
|
async onModuleInit(): Promise<void> {
|
||||||
|
await this.$connect();
|
||||||
|
this.logger.log('CRM Prisma Client verbunden.');
|
||||||
|
}
|
||||||
|
|
||||||
|
async onModuleDestroy(): Promise<void> {
|
||||||
|
await this.$disconnect();
|
||||||
|
this.logger.log('CRM Prisma Client getrennt.');
|
||||||
|
}
|
||||||
|
}
|
||||||
9
packages/crm-service/src/redis/redis.module.ts
Normal file
9
packages/crm-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 {}
|
||||||
79
packages/crm-service/src/redis/redis.service.ts
Normal file
79
packages/crm-service/src/redis/redis.service.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
import {
|
||||||
|
Injectable,
|
||||||
|
OnModuleInit,
|
||||||
|
OnModuleDestroy,
|
||||||
|
Logger,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import Redis from 'ioredis';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class RedisService implements OnModuleInit, OnModuleDestroy {
|
||||||
|
private readonly logger = new Logger(RedisService.name);
|
||||||
|
private client!: Redis;
|
||||||
|
|
||||||
|
constructor(private readonly config: ConfigService) {}
|
||||||
|
|
||||||
|
async onModuleInit(): Promise<void> {
|
||||||
|
const host = this.config.get<string>('REDIS_HOST', 'redis');
|
||||||
|
const port = this.config.get<number>('REDIS_PORT', 6379);
|
||||||
|
const password = this.config.get<string>('REDIS_PASSWORD');
|
||||||
|
|
||||||
|
this.client = new Redis({
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
password: password || undefined,
|
||||||
|
maxRetriesPerRequest: 3,
|
||||||
|
retryStrategy: (times: number) => {
|
||||||
|
if (times > 10) {
|
||||||
|
this.logger.error(
|
||||||
|
'Redis: Maximale Verbindungsversuche erreicht.',
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return Math.min(times * 200, 5000);
|
||||||
|
},
|
||||||
|
lazyConnect: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.client.on('error', (err: Error) => {
|
||||||
|
this.logger.error(`Redis Fehler: ${err.message}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.client.on('connect', () => {
|
||||||
|
this.logger.log('Redis Verbindung hergestellt.');
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.client.connect();
|
||||||
|
}
|
||||||
|
|
||||||
|
async onModuleDestroy(): Promise<void> {
|
||||||
|
this.logger.log('Trenne Redis Verbindung...');
|
||||||
|
await this.client.quit();
|
||||||
|
}
|
||||||
|
|
||||||
|
async ping(): Promise<string> {
|
||||||
|
return this.client.ping();
|
||||||
|
}
|
||||||
|
|
||||||
|
async isTokenBlocked(jti: string): Promise<boolean> {
|
||||||
|
const result = await this.client.get(`blocked:${jti}`);
|
||||||
|
return result !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async get(key: string): Promise<string | null> {
|
||||||
|
return this.client.get(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
async set(key: string, value: string, ttlSeconds?: number): Promise<void> {
|
||||||
|
if (ttlSeconds) {
|
||||||
|
await this.client.set(key, value, 'EX', ttlSeconds);
|
||||||
|
} else {
|
||||||
|
await this.client.set(key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async del(key: string): Promise<void> {
|
||||||
|
await this.client.del(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
29
packages/crm-service/tsconfig.json
Normal file
29
packages/crm-service/tsconfig.json
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"module": "commonjs",
|
||||||
|
"declaration": true,
|
||||||
|
"removeComments": true,
|
||||||
|
"emitDecoratorMetadata": true,
|
||||||
|
"experimentalDecorators": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"target": "ES2022",
|
||||||
|
"sourceMap": true,
|
||||||
|
"outDir": "./dist",
|
||||||
|
"baseUrl": "./",
|
||||||
|
"incremental": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noImplicitAny": true,
|
||||||
|
"strictNullChecks": true,
|
||||||
|
"noImplicitReturns": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist", "test"]
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue