From 8783d01fc042ef376d8c9f965de5809ae55a0766 Mon Sep 17 00:00:00 2001 From: Thomas Reitz Date: Tue, 10 Mar 2026 15:54:13 +0100 Subject: [PATCH] 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 --- .env.example | 9 +- docker-compose.crm.yml | 58 +++++ packages/crm-service/.dockerignore | 7 + packages/crm-service/Dockerfile | 56 +++++ packages/crm-service/Summarize.md | 79 +++++++ packages/crm-service/nest-cli.json | 8 + packages/crm-service/package.json | 85 +++++++ packages/crm-service/prisma/crm.schema.prisma | 221 ++++++++++++++++++ .../src/activities/activities.controller.ts | 110 +++++++++ .../src/activities/activities.module.ts | 10 + .../src/activities/activities.service.ts | 105 +++++++++ .../src/activities/dto/create-activity.dto.ts | 47 ++++ .../activities/dto/query-activities.dto.ts | 26 +++ .../src/activities/dto/update-activity.dto.ts | 6 + packages/crm-service/src/app.module.ts | 42 ++++ packages/crm-service/src/auth/auth.module.ts | 10 + .../src/auth/guards/jwt-auth.guard.ts | 56 +++++ .../src/auth/guards/roles.guard.ts | 29 +++ .../src/auth/guards/tenant.guard.ts | 27 +++ .../src/auth/strategies/jwt.strategy.ts | 37 +++ .../decorators/current-user.decorator.ts | 21 ++ .../src/common/decorators/index.ts | 3 + .../src/common/decorators/public.decorator.ts | 5 + .../src/common/decorators/roles.decorator.ts | 5 + .../src/common/dto/pagination.dto.ts | 70 ++++++ .../common/filters/global-exception.filter.ts | 70 ++++++ .../crm-service/src/config/env.validation.ts | 62 +++++ .../src/contacts/contacts.controller.ts | 107 +++++++++ .../src/contacts/contacts.module.ts | 10 + .../src/contacts/contacts.service.ts | 116 +++++++++ .../src/contacts/dto/create-contact.dto.ts | 111 +++++++++ .../src/contacts/dto/query-contacts.dto.ts | 26 +++ .../src/contacts/dto/update-contact.dto.ts | 4 + .../crm-service/src/deals/deals.controller.ts | 107 +++++++++ .../crm-service/src/deals/deals.module.ts | 10 + .../crm-service/src/deals/deals.service.ts | 199 ++++++++++++++++ .../src/deals/dto/create-deal.dto.ts | 64 +++++ .../src/deals/dto/query-deals.dto.ts | 41 ++++ .../src/deals/dto/update-deal.dto.ts | 4 + .../src/health/health.controller.ts | 74 ++++++ .../crm-service/src/health/health.module.ts | 7 + packages/crm-service/src/main.ts | 81 +++++++ .../src/pipelines/dto/create-pipeline.dto.ts | 51 ++++ .../src/pipelines/dto/update-pipeline.dto.ts | 20 ++ .../src/pipelines/pipelines.controller.ts | 135 +++++++++++ .../src/pipelines/pipelines.module.ts | 10 + .../src/pipelines/pipelines.service.ts | 114 +++++++++ .../src/prisma/crm-prisma.module.ts | 9 + .../src/prisma/crm-prisma.service.ts | 25 ++ .../crm-service/src/redis/redis.module.ts | 9 + .../crm-service/src/redis/redis.service.ts | 79 +++++++ packages/crm-service/tsconfig.json | 29 +++ 52 files changed, 2705 insertions(+), 1 deletion(-) create mode 100644 docker-compose.crm.yml create mode 100644 packages/crm-service/.dockerignore create mode 100644 packages/crm-service/Dockerfile create mode 100644 packages/crm-service/Summarize.md create mode 100644 packages/crm-service/nest-cli.json create mode 100644 packages/crm-service/package.json create mode 100644 packages/crm-service/prisma/crm.schema.prisma create mode 100644 packages/crm-service/src/activities/activities.controller.ts create mode 100644 packages/crm-service/src/activities/activities.module.ts create mode 100644 packages/crm-service/src/activities/activities.service.ts create mode 100644 packages/crm-service/src/activities/dto/create-activity.dto.ts create mode 100644 packages/crm-service/src/activities/dto/query-activities.dto.ts create mode 100644 packages/crm-service/src/activities/dto/update-activity.dto.ts create mode 100644 packages/crm-service/src/app.module.ts create mode 100644 packages/crm-service/src/auth/auth.module.ts create mode 100644 packages/crm-service/src/auth/guards/jwt-auth.guard.ts create mode 100644 packages/crm-service/src/auth/guards/roles.guard.ts create mode 100644 packages/crm-service/src/auth/guards/tenant.guard.ts create mode 100644 packages/crm-service/src/auth/strategies/jwt.strategy.ts create mode 100644 packages/crm-service/src/common/decorators/current-user.decorator.ts create mode 100644 packages/crm-service/src/common/decorators/index.ts create mode 100644 packages/crm-service/src/common/decorators/public.decorator.ts create mode 100644 packages/crm-service/src/common/decorators/roles.decorator.ts create mode 100644 packages/crm-service/src/common/dto/pagination.dto.ts create mode 100644 packages/crm-service/src/common/filters/global-exception.filter.ts create mode 100644 packages/crm-service/src/config/env.validation.ts create mode 100644 packages/crm-service/src/contacts/contacts.controller.ts create mode 100644 packages/crm-service/src/contacts/contacts.module.ts create mode 100644 packages/crm-service/src/contacts/contacts.service.ts create mode 100644 packages/crm-service/src/contacts/dto/create-contact.dto.ts create mode 100644 packages/crm-service/src/contacts/dto/query-contacts.dto.ts create mode 100644 packages/crm-service/src/contacts/dto/update-contact.dto.ts create mode 100644 packages/crm-service/src/deals/deals.controller.ts create mode 100644 packages/crm-service/src/deals/deals.module.ts create mode 100644 packages/crm-service/src/deals/deals.service.ts create mode 100644 packages/crm-service/src/deals/dto/create-deal.dto.ts create mode 100644 packages/crm-service/src/deals/dto/query-deals.dto.ts create mode 100644 packages/crm-service/src/deals/dto/update-deal.dto.ts create mode 100644 packages/crm-service/src/health/health.controller.ts create mode 100644 packages/crm-service/src/health/health.module.ts create mode 100644 packages/crm-service/src/main.ts create mode 100644 packages/crm-service/src/pipelines/dto/create-pipeline.dto.ts create mode 100644 packages/crm-service/src/pipelines/dto/update-pipeline.dto.ts create mode 100644 packages/crm-service/src/pipelines/pipelines.controller.ts create mode 100644 packages/crm-service/src/pipelines/pipelines.module.ts create mode 100644 packages/crm-service/src/pipelines/pipelines.service.ts create mode 100644 packages/crm-service/src/prisma/crm-prisma.module.ts create mode 100644 packages/crm-service/src/prisma/crm-prisma.service.ts create mode 100644 packages/crm-service/src/redis/redis.module.ts create mode 100644 packages/crm-service/src/redis/redis.service.ts create mode 100644 packages/crm-service/tsconfig.json diff --git a/.env.example b/.env.example index 6ce9a1e..049bb88 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/docker-compose.crm.yml b/docker-compose.crm.yml new file mode 100644 index 0000000..1448d2c --- /dev/null +++ b/docker-compose.crm.yml @@ -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 diff --git a/packages/crm-service/.dockerignore b/packages/crm-service/.dockerignore new file mode 100644 index 0000000..a335510 --- /dev/null +++ b/packages/crm-service/.dockerignore @@ -0,0 +1,7 @@ +node_modules +dist +coverage +.env +*.md +.git +.gitignore diff --git a/packages/crm-service/Dockerfile b/packages/crm-service/Dockerfile new file mode 100644 index 0000000..ef00aae --- /dev/null +++ b/packages/crm-service/Dockerfile @@ -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"] diff --git a/packages/crm-service/Summarize.md b/packages/crm-service/Summarize.md new file mode 100644 index 0000000..1c8b55f --- /dev/null +++ b/packages/crm-service/Summarize.md @@ -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 diff --git a/packages/crm-service/nest-cli.json b/packages/crm-service/nest-cli.json new file mode 100644 index 0000000..f9aa683 --- /dev/null +++ b/packages/crm-service/nest-cli.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/nest-cli", + "collection": "@nestjs/schematics", + "sourceRoot": "src", + "compilerOptions": { + "deleteOutDir": true + } +} diff --git a/packages/crm-service/package.json b/packages/crm-service/package.json new file mode 100644 index 0000000..45aefca --- /dev/null +++ b/packages/crm-service/package.json @@ -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": { + "^@/(.*)$": "/$1" + } + } +} diff --git a/packages/crm-service/prisma/crm.schema.prisma b/packages/crm-service/prisma/crm.schema.prisma new file mode 100644 index 0000000..175fcba --- /dev/null +++ b/packages/crm-service/prisma/crm.schema.prisma @@ -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") +} diff --git a/packages/crm-service/src/activities/activities.controller.ts b/packages/crm-service/src/activities/activities.controller.ts new file mode 100644 index 0000000..6d14463 --- /dev/null +++ b/packages/crm-service/src/activities/activities.controller.ts @@ -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); + } +} diff --git a/packages/crm-service/src/activities/activities.module.ts b/packages/crm-service/src/activities/activities.module.ts new file mode 100644 index 0000000..180a457 --- /dev/null +++ b/packages/crm-service/src/activities/activities.module.ts @@ -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 {} diff --git a/packages/crm-service/src/activities/activities.service.ts b/packages/crm-service/src/activities/activities.service.ts new file mode 100644 index 0000000..744c798 --- /dev/null +++ b/packages/crm-service/src/activities/activities.service.ts @@ -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 } }); + } +} diff --git a/packages/crm-service/src/activities/dto/create-activity.dto.ts b/packages/crm-service/src/activities/dto/create-activity.dto.ts new file mode 100644 index 0000000..9136378 --- /dev/null +++ b/packages/crm-service/src/activities/dto/create-activity.dto.ts @@ -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; +} diff --git a/packages/crm-service/src/activities/dto/query-activities.dto.ts b/packages/crm-service/src/activities/dto/query-activities.dto.ts new file mode 100644 index 0000000..109a62c --- /dev/null +++ b/packages/crm-service/src/activities/dto/query-activities.dto.ts @@ -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'; +} diff --git a/packages/crm-service/src/activities/dto/update-activity.dto.ts b/packages/crm-service/src/activities/dto/update-activity.dto.ts new file mode 100644 index 0000000..f2d9e74 --- /dev/null +++ b/packages/crm-service/src/activities/dto/update-activity.dto.ts @@ -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), +) {} diff --git a/packages/crm-service/src/app.module.ts b/packages/crm-service/src/app.module.ts new file mode 100644 index 0000000..104f5d6 --- /dev/null +++ b/packages/crm-service/src/app.module.ts @@ -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 {} diff --git a/packages/crm-service/src/auth/auth.module.ts b/packages/crm-service/src/auth/auth.module.ts new file mode 100644 index 0000000..b7d70e7 --- /dev/null +++ b/packages/crm-service/src/auth/auth.module.ts @@ -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 {} diff --git a/packages/crm-service/src/auth/guards/jwt-auth.guard.ts b/packages/crm-service/src/auth/guards/jwt-auth.guard.ts new file mode 100644 index 0000000..f7def23 --- /dev/null +++ b/packages/crm-service/src/auth/guards/jwt-auth.guard.ts @@ -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 { + const isPublic = this.reflector.getAllAndOverride(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(err: Error | null, user: T, info: Error | undefined): T { + if (err || !user) { + throw err || new UnauthorizedException('Zugriff verweigert'); + } + return user; + } +} diff --git a/packages/crm-service/src/auth/guards/roles.guard.ts b/packages/crm-service/src/auth/guards/roles.guard.ts new file mode 100644 index 0000000..8416968 --- /dev/null +++ b/packages/crm-service/src/auth/guards/roles.guard.ts @@ -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( + 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); + } +} diff --git a/packages/crm-service/src/auth/guards/tenant.guard.ts b/packages/crm-service/src/auth/guards/tenant.guard.ts new file mode 100644 index 0000000..3629e84 --- /dev/null +++ b/packages/crm-service/src/auth/guards/tenant.guard.ts @@ -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; + } +} diff --git a/packages/crm-service/src/auth/strategies/jwt.strategy.ts b/packages/crm-service/src/auth/strategies/jwt.strategy.ts new file mode 100644 index 0000000..79ca86e --- /dev/null +++ b/packages/crm-service/src/auth/strategies/jwt.strategy.ts @@ -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( + '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('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, + }; + } +} diff --git a/packages/crm-service/src/common/decorators/current-user.decorator.ts b/packages/crm-service/src/common/decorators/current-user.decorator.ts new file mode 100644 index 0000000..fe2fc9f --- /dev/null +++ b/packages/crm-service/src/common/decorators/current-user.decorator.ts @@ -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(); + const user = request.user as JwtPayload; + return data ? user?.[data] : user; + }, +); diff --git a/packages/crm-service/src/common/decorators/index.ts b/packages/crm-service/src/common/decorators/index.ts new file mode 100644 index 0000000..7cec100 --- /dev/null +++ b/packages/crm-service/src/common/decorators/index.ts @@ -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'; diff --git a/packages/crm-service/src/common/decorators/public.decorator.ts b/packages/crm-service/src/common/decorators/public.decorator.ts new file mode 100644 index 0000000..767ac49 --- /dev/null +++ b/packages/crm-service/src/common/decorators/public.decorator.ts @@ -0,0 +1,5 @@ +import { SetMetadata } from '@nestjs/common'; + +export const IS_PUBLIC_KEY = 'isPublic'; + +export const Public = () => SetMetadata(IS_PUBLIC_KEY, true); diff --git a/packages/crm-service/src/common/decorators/roles.decorator.ts b/packages/crm-service/src/common/decorators/roles.decorator.ts new file mode 100644 index 0000000..e7eea58 --- /dev/null +++ b/packages/crm-service/src/common/decorators/roles.decorator.ts @@ -0,0 +1,5 @@ +import { SetMetadata } from '@nestjs/common'; + +export const ROLES_KEY = 'roles'; + +export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles); diff --git a/packages/crm-service/src/common/dto/pagination.dto.ts b/packages/crm-service/src/common/dto/pagination.dto.ts new file mode 100644 index 0000000..0d75d36 --- /dev/null +++ b/packages/crm-service/src/common/dto/pagination.dto.ts @@ -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 { + success: true; + data: T[]; + pagination: { + page: number; + pageSize: number; + total: number; + totalPages: number; + }; + meta: { + timestamp: string; + }; +} + +export interface SingleResponse { + success: true; + data: T; + meta: { + timestamp: string; + }; +} + +export function paginatedResponse( + data: T[], + total: number, + page: number, + pageSize: number, +): PaginatedResponse { + return { + success: true, + data, + pagination: { + page, + pageSize, + total, + totalPages: Math.ceil(total / pageSize), + }, + meta: { + timestamp: new Date().toISOString(), + }, + }; +} + +export function singleResponse(data: T): SingleResponse { + return { + success: true, + data, + meta: { + timestamp: new Date().toISOString(), + }, + }; +} diff --git a/packages/crm-service/src/common/filters/global-exception.filter.ts b/packages/crm-service/src/common/filters/global-exception.filter.ts new file mode 100644 index 0000000..c88d195 --- /dev/null +++ b/packages/crm-service/src/common/filters/global-exception.filter.ts @@ -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(); + + 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; + 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 = { + 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'; + } +} diff --git a/packages/crm-service/src/config/env.validation.ts b/packages/crm-service/src/config/env.validation.ts new file mode 100644 index 0000000..1657404 --- /dev/null +++ b/packages/crm-service/src/config/env.validation.ts @@ -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, +): 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; +} diff --git a/packages/crm-service/src/contacts/contacts.controller.ts b/packages/crm-service/src/contacts/contacts.controller.ts new file mode 100644 index 0000000..0e7a958 --- /dev/null +++ b/packages/crm-service/src/contacts/contacts.controller.ts @@ -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); + } +} diff --git a/packages/crm-service/src/contacts/contacts.module.ts b/packages/crm-service/src/contacts/contacts.module.ts new file mode 100644 index 0000000..ef19ed0 --- /dev/null +++ b/packages/crm-service/src/contacts/contacts.module.ts @@ -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 {} diff --git a/packages/crm-service/src/contacts/contacts.service.ts b/packages/crm-service/src/contacts/contacts.service.ts new file mode 100644 index 0000000..afb1a7d --- /dev/null +++ b/packages/crm-service/src/contacts/contacts.service.ts @@ -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 } }); + } +} diff --git a/packages/crm-service/src/contacts/dto/create-contact.dto.ts b/packages/crm-service/src/contacts/dto/create-contact.dto.ts new file mode 100644 index 0000000..a2a0d31 --- /dev/null +++ b/packages/crm-service/src/contacts/dto/create-contact.dto.ts @@ -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; +} diff --git a/packages/crm-service/src/contacts/dto/query-contacts.dto.ts b/packages/crm-service/src/contacts/dto/query-contacts.dto.ts new file mode 100644 index 0000000..9c045b4 --- /dev/null +++ b/packages/crm-service/src/contacts/dto/query-contacts.dto.ts @@ -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'; +} diff --git a/packages/crm-service/src/contacts/dto/update-contact.dto.ts b/packages/crm-service/src/contacts/dto/update-contact.dto.ts new file mode 100644 index 0000000..366df50 --- /dev/null +++ b/packages/crm-service/src/contacts/dto/update-contact.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/swagger'; +import { CreateContactDto } from './create-contact.dto'; + +export class UpdateContactDto extends PartialType(CreateContactDto) {} diff --git a/packages/crm-service/src/deals/deals.controller.ts b/packages/crm-service/src/deals/deals.controller.ts new file mode 100644 index 0000000..3223d80 --- /dev/null +++ b/packages/crm-service/src/deals/deals.controller.ts @@ -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); + } +} diff --git a/packages/crm-service/src/deals/deals.module.ts b/packages/crm-service/src/deals/deals.module.ts new file mode 100644 index 0000000..2dc9dda --- /dev/null +++ b/packages/crm-service/src/deals/deals.module.ts @@ -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 {} diff --git a/packages/crm-service/src/deals/deals.service.ts b/packages/crm-service/src/deals/deals.service.ts new file mode 100644 index 0000000..411fba0 --- /dev/null +++ b/packages/crm-service/src/deals/deals.service.ts @@ -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 } }); + } +} diff --git a/packages/crm-service/src/deals/dto/create-deal.dto.ts b/packages/crm-service/src/deals/dto/create-deal.dto.ts new file mode 100644 index 0000000..b83255c --- /dev/null +++ b/packages/crm-service/src/deals/dto/create-deal.dto.ts @@ -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; +} diff --git a/packages/crm-service/src/deals/dto/query-deals.dto.ts b/packages/crm-service/src/deals/dto/query-deals.dto.ts new file mode 100644 index 0000000..6474190 --- /dev/null +++ b/packages/crm-service/src/deals/dto/query-deals.dto.ts @@ -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'; +} diff --git a/packages/crm-service/src/deals/dto/update-deal.dto.ts b/packages/crm-service/src/deals/dto/update-deal.dto.ts new file mode 100644 index 0000000..58ca96a --- /dev/null +++ b/packages/crm-service/src/deals/dto/update-deal.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/swagger'; +import { CreateDealDto } from './create-deal.dto'; + +export class UpdateDealDto extends PartialType(CreateDealDto) {} diff --git a/packages/crm-service/src/health/health.controller.ts b/packages/crm-service/src/health/health.controller.ts new file mode 100644 index 0000000..94d9a65 --- /dev/null +++ b/packages/crm-service/src/health/health.controller.ts @@ -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 { + 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 { + try { + await this.prisma.$queryRaw`SELECT 1`; + return true; + } catch { + return false; + } + } + + private async checkRedis(): Promise { + try { + const pong = await this.redis.ping(); + return pong === 'PONG'; + } catch { + return false; + } + } +} diff --git a/packages/crm-service/src/health/health.module.ts b/packages/crm-service/src/health/health.module.ts new file mode 100644 index 0000000..7476abe --- /dev/null +++ b/packages/crm-service/src/health/health.module.ts @@ -0,0 +1,7 @@ +import { Module } from '@nestjs/common'; +import { HealthController } from './health.controller'; + +@Module({ + controllers: [HealthController], +}) +export class HealthModule {} diff --git a/packages/crm-service/src/main.ts b/packages/crm-service/src/main.ts new file mode 100644 index 0000000..2bf569c --- /dev/null +++ b/packages/crm-service/src/main.ts @@ -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 { + 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(); diff --git a/packages/crm-service/src/pipelines/dto/create-pipeline.dto.ts b/packages/crm-service/src/pipelines/dto/create-pipeline.dto.ts new file mode 100644 index 0000000..fd6fd5b --- /dev/null +++ b/packages/crm-service/src/pipelines/dto/create-pipeline.dto.ts @@ -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[]; +} diff --git a/packages/crm-service/src/pipelines/dto/update-pipeline.dto.ts b/packages/crm-service/src/pipelines/dto/update-pipeline.dto.ts new file mode 100644 index 0000000..78901c4 --- /dev/null +++ b/packages/crm-service/src/pipelines/dto/update-pipeline.dto.ts @@ -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; +} diff --git a/packages/crm-service/src/pipelines/pipelines.controller.ts b/packages/crm-service/src/pipelines/pipelines.controller.ts new file mode 100644 index 0000000..1ea158c --- /dev/null +++ b/packages/crm-service/src/pipelines/pipelines.controller.ts @@ -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); + } +} diff --git a/packages/crm-service/src/pipelines/pipelines.module.ts b/packages/crm-service/src/pipelines/pipelines.module.ts new file mode 100644 index 0000000..da11e93 --- /dev/null +++ b/packages/crm-service/src/pipelines/pipelines.module.ts @@ -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 {} diff --git a/packages/crm-service/src/pipelines/pipelines.service.ts b/packages/crm-service/src/pipelines/pipelines.service.ts new file mode 100644 index 0000000..a09a552 --- /dev/null +++ b/packages/crm-service/src/pipelines/pipelines.service.ts @@ -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 } }); + } +} diff --git a/packages/crm-service/src/prisma/crm-prisma.module.ts b/packages/crm-service/src/prisma/crm-prisma.module.ts new file mode 100644 index 0000000..b05e0e5 --- /dev/null +++ b/packages/crm-service/src/prisma/crm-prisma.module.ts @@ -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 {} diff --git a/packages/crm-service/src/prisma/crm-prisma.service.ts b/packages/crm-service/src/prisma/crm-prisma.service.ts new file mode 100644 index 0000000..28883f8 --- /dev/null +++ b/packages/crm-service/src/prisma/crm-prisma.service.ts @@ -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 { + await this.$connect(); + this.logger.log('CRM Prisma Client verbunden.'); + } + + async onModuleDestroy(): Promise { + await this.$disconnect(); + this.logger.log('CRM Prisma Client getrennt.'); + } +} diff --git a/packages/crm-service/src/redis/redis.module.ts b/packages/crm-service/src/redis/redis.module.ts new file mode 100644 index 0000000..b9cfabf --- /dev/null +++ b/packages/crm-service/src/redis/redis.module.ts @@ -0,0 +1,9 @@ +import { Global, Module } from '@nestjs/common'; +import { RedisService } from './redis.service'; + +@Global() +@Module({ + providers: [RedisService], + exports: [RedisService], +}) +export class RedisModule {} diff --git a/packages/crm-service/src/redis/redis.service.ts b/packages/crm-service/src/redis/redis.service.ts new file mode 100644 index 0000000..fb0f97f --- /dev/null +++ b/packages/crm-service/src/redis/redis.service.ts @@ -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 { + const host = this.config.get('REDIS_HOST', 'redis'); + const port = this.config.get('REDIS_PORT', 6379); + const password = this.config.get('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 { + this.logger.log('Trenne Redis Verbindung...'); + await this.client.quit(); + } + + async ping(): Promise { + return this.client.ping(); + } + + async isTokenBlocked(jti: string): Promise { + const result = await this.client.get(`blocked:${jti}`); + return result !== null; + } + + async get(key: string): Promise { + return this.client.get(key); + } + + async set(key: string, value: string, ttlSeconds?: number): Promise { + if (ttlSeconds) { + await this.client.set(key, value, 'EX', ttlSeconds); + } else { + await this.client.set(key, value); + } + } + + async del(key: string): Promise { + await this.client.del(key); + } +} diff --git a/packages/crm-service/tsconfig.json b/packages/crm-service/tsconfig.json new file mode 100644 index 0000000..5479b57 --- /dev/null +++ b/packages/crm-service/tsconfig.json @@ -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"] +}