mirror of
http://172.20.10.11:3000/gitadmin/INSIGHT-MVP.git
synced 2026-06-24 23: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_CLIENT_ID= # Application (Client) ID
|
||||
AZURE_CLIENT_SECRET= # Client Secret Value
|
||||
AZURE_REDIRECT_URI=http://172.20.10.59/api/v1/auth/sso/microsoft/callback
|
||||
AZURE_REDIRECT_URI=https://172.20.10.59/api/v1/auth/sso/microsoft/callback
|
||||
|
||||
# --- KI-Hilfe-Chat (optional) ---
|
||||
# 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_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