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:
Thomas Reitz 2026-03-10 15:54:13 +01:00
parent ffd48a38a3
commit 8783d01fc0
52 changed files with 2705 additions and 1 deletions

View file

@ -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
View 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

View file

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

View 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"]

View 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

View file

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

View 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"
}
}
}

View 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")
}

View 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);
}
}

View 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 {}

View 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 } });
}
}

View file

@ -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;
}

View file

@ -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';
}

View file

@ -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),
) {}

View 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 {}

View 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 {}

View 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;
}
}

View 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);
}
}

View 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;
}
}

View 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,
};
}
}

View file

@ -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;
},
);

View 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';

View file

@ -0,0 +1,5 @@
import { SetMetadata } from '@nestjs/common';
export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);

View file

@ -0,0 +1,5 @@
import { SetMetadata } from '@nestjs/common';
export const ROLES_KEY = 'roles';
export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles);

View 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(),
},
};
}

View file

@ -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';
}
}

View 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;
}

View 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);
}
}

View 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 {}

View 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 } });
}
}

View 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;
}

View 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';
}

View file

@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/swagger';
import { CreateContactDto } from './create-contact.dto';
export class UpdateContactDto extends PartialType(CreateContactDto) {}

View 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);
}
}

View 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 {}

View 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 } });
}
}

View 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;
}

View 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';
}

View file

@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/swagger';
import { CreateDealDto } from './create-deal.dto';
export class UpdateDealDto extends PartialType(CreateDealDto) {}

View 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;
}
}
}

View file

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

View 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();

View file

@ -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[];
}

View file

@ -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;
}

View 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);
}
}

View 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 {}

View 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 } });
}
}

View 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 {}

View 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.');
}
}

View file

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

View 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);
}
}

View 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"]
}