feat(crm): Phase 2.2-2.4 backend + contract files — vollständige CRM-Service Implementierung

- Phase 2.3 Forecast: probability-Feld in PipelineStage, GET /crm/deals/forecast Endpoint
- Phase 2.2 Import: ImportModule mit preview/execute/history Endpoints (CSV, XLSX, vCard)
- Phase 2.4 Enrichment: EnrichmentModule mit /enrich + /settings/integrations/north-data
- Contracts: ContractsModule mit CRUD + File-Upload Endpoints (Multer, max 25MB)
- Migrations: 20260312_contract_files, 20260312_phase23_forecast
- docker-compose.crm.yml: uploads Volume für Vertragsdokumente

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Thomas Reitz 2026-03-12 22:06:58 +01:00
parent bfe672ec96
commit 63cb05d4d8
35 changed files with 2257 additions and 25 deletions

View file

@ -31,6 +31,7 @@ services:
- ./packages/crm-service:/app - ./packages/crm-service:/app
- /app/node_modules - /app/node_modules
- ./.keys/jwt-public.pem:/app/keys/jwt-public.pem:ro - ./.keys/jwt-public.pem:/app/keys/jwt-public.pem:ro
- ./uploads:/app/uploads
networks: networks:
- insight-web - insight-web
- insight-db - insight-db

View file

@ -3437,3 +3437,39 @@ Nach Implementierung der 4 Endpoints:
**Hinweis:** Das Frontend kann vorab gegen den definierten API-Contract gebaut werden. Sobald die Backend-Endpoints live sind, ist die Integration sofort funktional. **Hinweis:** Das Frontend kann vorab gegen den definierten API-Contract gebaut werden. Sobald die Backend-Endpoints live sind, ist die Integration sofort funktional.
---
### 2026-03-12 | Backend: Vertragsdokumente (Contract Files) — Fertiggestellt
**Neue/geaenderte Dateien:**
| Datei | Aenderung |
|-------|-----------|
| `prisma/crm.schema.prisma` | ContractFile Model + files-Relation auf Contract |
| `prisma/migrations/20260312_contract_files/migration.sql` | contract_files Tabelle + Index |
| `src/contracts/contracts.service.ts` | +uploadFile, listFiles, getFile, deleteFile; remove() loescht nun auch Dateien auf Disk |
| `src/contracts/contracts.controller.ts` | +4 File-Endpoints (Upload, Liste, Download, Delete) |
| `Summarize.md` | ContractFile dokumentiert |
**Endpoints:**
| Methode | Pfad | Beschreibung |
|---------|------|-------------|
| POST | /api/v1/crm/companies/:id/contracts/:cid/files | Datei hochladen (multipart, field: `file`) |
| GET | /api/v1/crm/companies/:id/contracts/:cid/files | Datei-Liste |
| GET | /api/v1/crm/companies/:id/contracts/:cid/files/:fid/download | Download (?inline=true fuer Inline-Anzeige) |
| DELETE | /api/v1/crm/companies/:id/contracts/:cid/files/:fid | Datei loeschen (DB + Disk) |
**Sicherheit:**
- Tenant-Isolation: tenantId aus JWT, nicht aus Request
- Path-Traversal-Schutz: UUID-Prefix auf Disk, `path.basename()` fuer Dateinamen
- MIME-Filter: nur PDF, Word, Excel erlaubt
- Max 25 MB pro Datei, max 10 Dateien pro Vertrag
- Cascade: Vertrag loeschen → Dateien auf Disk + DB werden entfernt
**Speicherort:** `/app/uploads/contracts/{tenantId}/{contractId}/{uuid}-{originalName}`
**Docker-Volume:** `./uploads:/app/uploads` in docker-compose.crm.yml ergaenzen
**TypeScript-Check:** 0 Fehler

View file

@ -21,7 +21,7 @@ packages/crm-service/
src/ src/
main.ts — Bootstrap (Port 3100, Prefix: api/v1/crm, Swagger) main.ts — Bootstrap (Port 3100, Prefix: api/v1/crm, Swagger)
app.module.ts — Root Module mit globalem JwtAuthGuard + ExceptionFilter + ScheduleModule app.module.ts — Root Module mit globalem JwtAuthGuard + ExceptionFilter + ScheduleModule
config/ — Umgebungsvariablen-Validierung (inkl. LEXWARE_*) config/ — Umgebungsvariablen-Validierung (inkl. LEXWARE_*, NORTH_DATA_*)
prisma/ — CrmPrismaService (eigener Client) prisma/ — CrmPrismaService (eigener Client)
redis/ — RedisService (Token-Blocklist, Cache, Distributed Locks) redis/ — RedisService (Token-Blocklist, Cache, Distributed Locks)
auth/ — JWT Strategy (RS256), JwtAuthGuard, RolesGuard, TenantGuard auth/ — JWT Strategy (RS256), JwtAuthGuard, RolesGuard, TenantGuard
@ -30,15 +30,18 @@ packages/crm-service/
companies/ — CRUD: Unternehmen (Multi-Value emails/phones, Owner m:n, Status, Lexware ERP-Push, Custom Fields) companies/ — CRUD: Unternehmen (Multi-Value emails/phones, Owner m:n, Status, Lexware ERP-Push, Custom Fields)
contacts/ — CRUD: Kontakte (Multi-Value emails/phones, Owner m:n, Status, Events, Custom Fields) contacts/ — CRUD: Kontakte (Multi-Value emails/phones, Owner m:n, Status, Events, Custom Fields)
activities/ — CRUD: Aktivitaeten (NOTE, CALL, EMAIL, MEETING, TASK, FOLLOWUP; contactId+companyId optional) activities/ — CRUD: Aktivitaeten (NOTE, CALL, EMAIL, MEETING, TASK, FOLLOWUP; contactId+companyId optional)
pipelines/ — CRUD: Sales-Pipelines mit Stages (inkl. Stage-Update) pipelines/ — CRUD: Sales-Pipelines mit Stages (inkl. Stage-Update, probability)
deals/ — CRUD: Vorgaenge mit Pipeline/Stage/Contact/Company + DealVouchers + LostReason + Owner m:n + Events + Custom Fields deals/ — CRUD: Vorgaenge mit Pipeline/Stage/Contact/Company + DealVouchers + LostReason + Owner m:n + Events + Custom Fields + Forecast
import/ — CSV/Excel Import (Phase 2.2): Preview + Execute, Duplikat-Erkennung, GDPR Temp-File-Loesung
enrichment/ — Datenanreicherung (Phase 2.4): Unternehmensregister.de + North Data API, Suggestion-Only
contracts/ — CRUD: Vertraege (nested unter /companies/:id/contracts, Status DRAFT/ACTIVE/EXPIRED/CANCELLED) + Datei-Upload (PDF/Word/Excel, max 25MB, max 10 pro Vertrag)
owners/ — Shared Owner-Service (Contact/Company/Deal Owners, Upsert, Rollen) owners/ — Shared Owner-Service (Contact/Company/Deal Owners, Upsert, Rollen)
events/ — CRM Event Publisher (Redis Pub/Sub) + Activity Due-Soon Scheduler events/ — CRM Event Publisher (Redis Pub/Sub) + Activity Due-Soon Scheduler
industries/ — CRUD: Branchen (admin-konfigurierbar, mit Farbe) industries/ — CRUD: Branchen (admin-konfigurierbar, mit Farbe)
account-types/ — CRUD: Kontotypen (admin-konfigurierbar) account-types/ — CRUD: Kontotypen (admin-konfigurierbar)
relationship-types/ — CRUD: Beziehungstypen (admin-konfigurierbar) relationship-types/ — CRUD: Beziehungstypen (admin-konfigurierbar)
company-relationships/ — Company-zu-Company Beziehungen (N:M, bidirektional) company-relationships/ — Company-zu-Company Beziehungen (N:M, bidirektional)
health/ — Health-Check (DB, Redis, Lexware) health/ — Health-Check (DB, Redis, Lexware, Enrichment)
lexware/ — Lexware Office Integration lexware/ — Lexware Office Integration
lexware.module.ts — Feature Module (HttpModule + ScheduleModule) lexware.module.ts — Feature Module (HttpModule + ScheduleModule)
lexware-client.service.ts — Rate-limitierter HTTP Client (Token Bucket, 2 req/s) lexware-client.service.ts — Rate-limitierter HTTP Client (Token Bucket, 2 req/s)
@ -60,7 +63,7 @@ packages/crm-service/
- **Contact** — Kontakte mit Multi-Value emails/phones, Owner m:n, EntityStatus, linkedinUrl/birthday/source/department, Lexware-Verknuepfung - **Contact** — Kontakte mit Multi-Value emails/phones, Owner m:n, EntityStatus, linkedinUrl/birthday/source/department, Lexware-Verknuepfung
- **Activity** — Aktivitaeten verknuepft mit Kontakten UND/ODER Companies (contactId + companyId beide optional, min. 1) + FOLLOWUP-Typ - **Activity** — Aktivitaeten verknuepft mit Kontakten UND/ODER Companies (contactId + companyId beide optional, min. 1) + FOLLOWUP-Typ
- **Pipeline** — Konfigurierbare Sales-Pipelines pro Tenant - **Pipeline** — Konfigurierbare Sales-Pipelines pro Tenant
- **PipelineStage** — Stufen innerhalb einer Pipeline - **PipelineStage** — Stufen innerhalb einer Pipeline (+ probability Decimal(3,2) fuer Forecast)
- **Deal** — Vorgaenge mit dealVouchers-Relation, Owner m:n, LostReason/LostReasonText, Events - **Deal** — Vorgaenge mit dealVouchers-Relation, Owner m:n, LostReason/LostReasonText, Events
- **ContactEmail** — Multi-Value E-Mail-Adressen (Contact/Company, Typ: WORK/PERSONAL/OTHER) - **ContactEmail** — Multi-Value E-Mail-Adressen (Contact/Company, Typ: WORK/PERSONAL/OTHER)
- **ContactPhone** — Multi-Value Telefonnummern (Contact/Company, Typ: OFFICE/MOBILE/FAX) - **ContactPhone** — Multi-Value Telefonnummern (Contact/Company, Typ: OFFICE/MOBILE/FAX)
@ -71,7 +74,8 @@ packages/crm-service/
- **AccountType** — Admin-konfigurierbare Kontotypen (unique pro Tenant) - **AccountType** — Admin-konfigurierbare Kontotypen (unique pro Tenant)
- **RelationshipType** — Admin-konfigurierbare Beziehungstypen (unique pro Tenant) - **RelationshipType** — Admin-konfigurierbare Beziehungstypen (unique pro Tenant)
- **CompanyRelationship** — N:M Company-zu-Company Beziehungen mit Typ und Notizen - **CompanyRelationship** — N:M Company-zu-Company Beziehungen mit Typ und Notizen
- **Contract** — Vertraege (DB-Modell vorhanden, UI-Platzhalter) - **Contract** — Vertraege mit title, status (DRAFT/ACTIVE/EXPIRED/CANCELLED), startDate, endDate, value (Decimal 15,2), currency, notes; Company-Relation (Cascade)
- **ContractFile** — Vertragsdokumente (PDF/Word/Excel), originalName, storagePath, mimeType, size; Contract-Relation (Cascade)
- **LexwareVoucher** — Gecachte Belege aus Lexware Office - **LexwareVoucher** — Gecachte Belege aus Lexware Office
- **DealVoucher** — Join-Table Deal <-> Beleg (m:n mit Audit-Trail) - **DealVoucher** — Join-Table Deal <-> Beleg (m:n mit Audit-Trail)
- **CustomFieldDef** — Benutzerdefinierte Feld-Definitionen (Phase 2.1): entityType, name (Slug), label, fieldType, options (JSONB), isRequired, position. Unique: [tenantId, entityType, name] - **CustomFieldDef** — Benutzerdefinierte Feld-Definitionen (Phase 2.1): entityType, name (Slug), label, fieldType, options (JSONB), isRequired, position. Unique: [tenantId, entityType, name]
@ -130,8 +134,23 @@ CustomFieldDef (1) --< (n) CustomFieldValue — fieldDefId (Cascade)
| POST/DELETE | /api/v1/crm/pipelines/:id/stages | Stage hinzufuegen/entfernen | | POST/DELETE | /api/v1/crm/pipelines/:id/stages | Stage hinzufuegen/entfernen |
| PATCH | /api/v1/crm/pipelines/:id/stages/:stageId | Stage bearbeiten | | PATCH | /api/v1/crm/pipelines/:id/stages/:stageId | Stage bearbeiten |
| GET/POST | /api/v1/crm/deals | Liste / Erstellen | | GET/POST | /api/v1/crm/deals | Liste / Erstellen |
| GET | /api/v1/crm/deals/forecast | Umsatz-Forecast (gewichtete Pipeline, Phase 2.3) |
| GET/PATCH/DELETE | /api/v1/crm/deals/:id | Detail / Update / Delete | | GET/PATCH/DELETE | /api/v1/crm/deals/:id | Detail / Update / Delete |
| GET | /health | Health-Check (DB, Redis, Lexware) | | **Import (Phase 2.2)** | | |
| POST | /api/v1/crm/import/preview | Datei-Vorschau (CSV/XLSX, Multipart) |
| POST | /api/v1/crm/import/execute | Import ausfuehren (mit Mapping + Duplikat-Strategie) |
| **Contracts (Vertraege)** | | |
| GET/POST | /api/v1/crm/companies/:id/contracts | Liste / Erstellen |
| GET/PATCH/DELETE | /api/v1/crm/companies/:id/contracts/:cid | Detail / Update / Delete |
| POST | /api/v1/crm/companies/:id/contracts/:cid/files | Datei hochladen (Multipart, max 25MB) |
| GET | /api/v1/crm/companies/:id/contracts/:cid/files | Datei-Liste |
| GET | /api/v1/crm/companies/:id/contracts/:cid/files/:fid/download | Datei herunterladen (?inline=true) |
| DELETE | /api/v1/crm/companies/:id/contracts/:cid/files/:fid | Datei loeschen |
| **Enrichment (Phase 2.4)** | | |
| POST | /api/v1/crm/companies/:id/enrich | Unternehmensdaten anreichern (Suggestion-Only) |
| GET | /api/v1/crm/settings/integrations/north-data | North Data Einstellungen abrufen |
| PUT | /api/v1/crm/settings/integrations/north-data | North Data Einstellungen aktualisieren |
| GET | /health | Health-Check (DB, Redis, Lexware, Enrichment) |
| **Custom Fields** | | | | **Custom Fields** | | |
| POST | /api/v1/crm/custom-fields | Feld-Definition erstellen | | POST | /api/v1/crm/custom-fields | Feld-Definition erstellen |
| GET | /api/v1/crm/custom-fields?entityType=PERSON | Definitionen auflisten (nach Entity-Typ) | | GET | /api/v1/crm/custom-fields?entityType=PERSON | Definitionen auflisten (nach Entity-Typ) |
@ -197,13 +216,12 @@ CustomFieldDef (1) --< (n) CustomFieldValue — fieldDefId (Cascade)
- `20260311_add_company_detail_overhaul` — Company Detail Overhaul - `20260311_add_company_detail_overhaul` — Company Detail Overhaul
- `20260312_phase1_schema_expansion` — Phase 1: Enums, Multi-Value, Owner, LostReason - `20260312_phase1_schema_expansion` — Phase 1: Enums, Multi-Value, Owner, LostReason
- `20260312_phase2_custom_fields` — Phase 2.1: Custom Fields (Definitionen + Werte) - `20260312_phase2_custom_fields` — Phase 2.1: Custom Fields (Definitionen + Werte)
- `20260312_phase23_forecast` — Phase 2.3: probability-Spalte auf pipeline_stages
- `20260312_contract_files` — ContractFile-Tabelle (Vertragsdokumente)
### Naechste Schritte ### Naechste Schritte
1. Migration `20260312_phase2_custom_fields` auf Server anwenden 1. Migrationen auf Server anwenden (phase2_custom_fields + phase23_forecast)
2. Container neu bauen und deployen 2. Container neu bauen und deployen
3. Frontend: Custom Fields Admin-UI + Entity-Integration 3. Frontend: Forecast-Widget, Import-UI, Enrichment-UI
4. Phase 2.2: Kontakt-Import (CSV, Excel, vCard) 4. Phase 2.5: Berechtigungsmodell (Sichtbarkeitsfilter)
5. Phase 2.3: Forecast-Endpoint (Probability-Feld auf PipelineStage)
6. Phase 2.4: Firmendaten-Anreicherung (Data Enrichment)
7. Phase 2.5: Berechtigungsmodell (Sichtbarkeitsfilter)

View file

@ -22,13 +22,15 @@
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.14.1", "class-validator": "^0.14.1",
"cookie-parser": "^1.4.7", "cookie-parser": "^1.4.7",
"csv-parser": "^3.2.0",
"helmet": "^8.0.0", "helmet": "^8.0.0",
"ioredis": "^5.4.1", "ioredis": "^5.4.1",
"passport": "^0.7.0", "passport": "^0.7.0",
"passport-jwt": "^4.0.1", "passport-jwt": "^4.0.1",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1", "rxjs": "^7.8.1",
"uuid": "^10.0.0" "uuid": "^10.0.0",
"xlsx": "^0.18.5"
}, },
"devDependencies": { "devDependencies": {
"@nestjs/cli": "^10.4.0", "@nestjs/cli": "^10.4.0",
@ -37,6 +39,7 @@
"@types/cookie-parser": "^1.4.7", "@types/cookie-parser": "^1.4.7",
"@types/express": "^5.0.0", "@types/express": "^5.0.0",
"@types/jest": "^29.5.12", "@types/jest": "^29.5.12",
"@types/multer": "^1.4.13",
"@types/node": "^22.0.0", "@types/node": "^22.0.0",
"@types/passport-jwt": "^4.0.1", "@types/passport-jwt": "^4.0.1",
"@types/uuid": "^10.0.0", "@types/uuid": "^10.0.0",
@ -2559,6 +2562,16 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/multer": {
"version": "1.4.13",
"resolved": "https://registry.npmjs.org/@types/multer/-/multer-1.4.13.tgz",
"integrity": "sha512-bhhdtPw7JqCiEfC9Jimx5LqX9BDIPJEh2q/fQ4bqbBPtyEZYr3cvF22NwG0DmPZNYA0CAf2CnqDB4KIGGpJcaw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/express": "*"
}
},
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "22.19.15", "version": "22.19.15",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz",
@ -3146,6 +3159,15 @@
"node": ">=0.4.0" "node": ">=0.4.0"
} }
}, },
"node_modules/adler-32": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz",
"integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/ajv": { "node_modules/ajv": {
"version": "8.12.0", "version": "8.12.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz",
@ -3859,6 +3881,19 @@
], ],
"license": "CC-BY-4.0" "license": "CC-BY-4.0"
}, },
"node_modules/cfb": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz",
"integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==",
"license": "Apache-2.0",
"dependencies": {
"adler-32": "~1.3.0",
"crc-32": "~1.2.0"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/chalk": { "node_modules/chalk": {
"version": "4.1.2", "version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
@ -4104,6 +4139,15 @@
"node": ">= 0.12.0" "node": ">= 0.12.0"
} }
}, },
"node_modules/codepage": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz",
"integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/collect-v8-coverage": { "node_modules/collect-v8-coverage": {
"version": "1.0.3", "version": "1.0.3",
"resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.3.tgz", "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.3.tgz",
@ -4306,6 +4350,18 @@
} }
} }
}, },
"node_modules/crc-32": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
"license": "Apache-2.0",
"bin": {
"crc32": "bin/crc32.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/create-jest": { "node_modules/create-jest": {
"version": "29.7.0", "version": "29.7.0",
"resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz", "resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz",
@ -4360,6 +4416,18 @@
"node": ">= 8" "node": ">= 8"
} }
}, },
"node_modules/csv-parser": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/csv-parser/-/csv-parser-3.2.0.tgz",
"integrity": "sha512-fgKbp+AJbn1h2dcAHKIdKNSSjfp43BZZykXsCjzALjKy80VXQNHPFJ6T9Afwdzoj24aMkq8GwDS7KGcDPpejrA==",
"license": "MIT",
"bin": {
"csv-parser": "bin/csv-parser"
},
"engines": {
"node": ">= 10"
}
},
"node_modules/debug": { "node_modules/debug": {
"version": "4.4.3", "version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@ -5587,6 +5655,15 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/frac": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz",
"integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/fresh": { "node_modules/fresh": {
"version": "0.5.2", "version": "0.5.2",
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
@ -9075,6 +9152,18 @@
"dev": true, "dev": true,
"license": "BSD-3-Clause" "license": "BSD-3-Clause"
}, },
"node_modules/ssf": {
"version": "0.11.2",
"resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz",
"integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==",
"license": "Apache-2.0",
"dependencies": {
"frac": "~1.1.2"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/stack-utils": { "node_modules/stack-utils": {
"version": "2.0.6", "version": "2.0.6",
"resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz",
@ -10239,6 +10328,24 @@
"node": ">= 8" "node": ">= 8"
} }
}, },
"node_modules/wmf": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz",
"integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/word": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz",
"integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/word-wrap": { "node_modules/word-wrap": {
"version": "1.2.5", "version": "1.2.5",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
@ -10318,6 +10425,27 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/xlsx": {
"version": "0.18.5",
"resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz",
"integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
"license": "Apache-2.0",
"dependencies": {
"adler-32": "~1.3.0",
"cfb": "~1.2.1",
"codepage": "~1.15.0",
"crc-32": "~1.2.1",
"ssf": "~0.11.2",
"wmf": "~1.0.1",
"word": "~0.3.0"
},
"bin": {
"xlsx": "bin/xlsx.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/xtend": { "node_modules/xtend": {
"version": "4.0.2", "version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",

View file

@ -38,13 +38,15 @@
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.14.1", "class-validator": "^0.14.1",
"cookie-parser": "^1.4.7", "cookie-parser": "^1.4.7",
"csv-parser": "^3.2.0",
"helmet": "^8.0.0", "helmet": "^8.0.0",
"ioredis": "^5.4.1", "ioredis": "^5.4.1",
"passport": "^0.7.0", "passport": "^0.7.0",
"passport-jwt": "^4.0.1", "passport-jwt": "^4.0.1",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1", "rxjs": "^7.8.1",
"uuid": "^10.0.0" "uuid": "^10.0.0",
"xlsx": "^0.18.5"
}, },
"devDependencies": { "devDependencies": {
"@nestjs/cli": "^10.4.0", "@nestjs/cli": "^10.4.0",
@ -53,6 +55,7 @@
"@types/cookie-parser": "^1.4.7", "@types/cookie-parser": "^1.4.7",
"@types/express": "^5.0.0", "@types/express": "^5.0.0",
"@types/jest": "^29.5.12", "@types/jest": "^29.5.12",
"@types/multer": "^1.4.13",
"@types/node": "^22.0.0", "@types/node": "^22.0.0",
"@types/passport-jwt": "^4.0.1", "@types/passport-jwt": "^4.0.1",
"@types/uuid": "^10.0.0", "@types/uuid": "^10.0.0",

View file

@ -410,12 +410,31 @@ model Contract {
// Relationen // Relationen
company Company @relation(fields: [companyId], references: [id], onDelete: Cascade) company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
files ContractFile[]
@@index([tenantId, companyId]) @@index([tenantId, companyId])
@@map("contracts") @@map("contracts")
@@schema("app_crm") @@schema("app_crm")
} }
model ContractFile {
id String @id @default(uuid()) @db.Uuid
tenantId String @map("tenant_id") @db.Uuid
contractId String @map("contract_id") @db.Uuid
originalName String @map("original_name") @db.VarChar(500)
storagePath String @map("storage_path") @db.VarChar(1000)
mimeType String @map("mime_type") @db.VarChar(200)
size Int
uploadedBy String @map("uploaded_by") @db.Uuid
createdAt DateTime @default(now()) @map("created_at")
contract Contract @relation(fields: [contractId], references: [id], onDelete: Cascade)
@@index([contractId])
@@map("contract_files")
@@schema("app_crm")
}
enum ContractStatus { enum ContractStatus {
DRAFT DRAFT
ACTIVE ACTIVE
@ -461,7 +480,8 @@ model PipelineStage {
pipelineId String @map("pipeline_id") @db.Uuid pipelineId String @map("pipeline_id") @db.Uuid
name String @db.VarChar(200) name String @db.VarChar(200)
sortOrder Int @default(0) @map("sort_order") sortOrder Int @default(0) @map("sort_order")
color String @default("#6B7280") @db.VarChar(7) color String @default("#6B7280") @db.VarChar(7)
probability Decimal @default(0) @map("probability") @db.Decimal(3, 2)
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at") updatedAt DateTime @updatedAt @map("updated_at")

View file

@ -0,0 +1,16 @@
-- Contract Files: Dokumente an Vertraegen
CREATE TABLE app_crm.contract_files (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
contract_id UUID NOT NULL REFERENCES app_crm.contracts(id) ON DELETE CASCADE,
original_name VARCHAR(500) NOT NULL,
storage_path VARCHAR(1000) NOT NULL,
mime_type VARCHAR(200) NOT NULL,
size INTEGER NOT NULL,
uploaded_by UUID NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_contract_files_contract_id ON app_crm.contract_files(contract_id);
COMMENT ON TABLE app_crm.contract_files IS 'Vertragsdokumente (PDF, Word, Excel)';

View file

@ -0,0 +1,10 @@
-- ============================================================
-- Phase 2.3: Forecast — Wahrscheinlichkeit auf Pipeline-Stufen
-- ============================================================
-- Neue Spalte probability auf pipeline_stages
ALTER TABLE "app_crm"."pipeline_stages"
ADD COLUMN "probability" DECIMAL(3, 2) NOT NULL DEFAULT 0;
COMMENT ON COLUMN "app_crm"."pipeline_stages"."probability"
IS 'Abschlusswahrscheinlichkeit 0.00 bis 1.00 (z.B. 0.75 = 75%)';

View file

@ -22,6 +22,9 @@ import { CompanyRelationshipsModule } from './company-relationships/company-rela
import { TradeEventsModule } from './trade-events/trade-events.module'; import { TradeEventsModule } from './trade-events/trade-events.module';
import { CrmEventsModule } from './events/crm-events.module'; import { CrmEventsModule } from './events/crm-events.module';
import { CustomFieldsModule } from './custom-fields/custom-fields.module'; import { CustomFieldsModule } from './custom-fields/custom-fields.module';
import { ImportModule } from './import/import.module';
import { EnrichmentModule } from './enrichment/enrichment.module';
import { ContractsModule } from './contracts/contracts.module';
@Module({ @Module({
imports: [ imports: [
@ -47,6 +50,9 @@ import { CustomFieldsModule } from './custom-fields/custom-fields.module';
TradeEventsModule, TradeEventsModule,
CrmEventsModule, CrmEventsModule,
CustomFieldsModule, CustomFieldsModule,
ImportModule,
EnrichmentModule,
ContractsModule,
], ],
providers: [ providers: [
{ {

View file

@ -48,6 +48,15 @@ export class EnvironmentVariables {
@IsString() @IsString()
@IsOptional() @IsOptional()
LEXWARE_API_URL?: string; LEXWARE_API_URL?: string;
// North Data Integration (optional)
@IsString()
@IsOptional()
NORTH_DATA_API_KEY?: string;
@IsString()
@IsOptional()
NORTH_DATA_API_URL?: string;
} }
export function validate( export function validate(

View file

@ -0,0 +1,239 @@
import {
Controller,
Get,
Post,
Patch,
Delete,
Body,
Param,
Query,
Res,
ParseUUIDPipe,
HttpCode,
HttpStatus,
UseGuards,
UseInterceptors,
UploadedFile,
ParseFilePipe,
MaxFileSizeValidator,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import {
ApiTags,
ApiOperation,
ApiBearerAuth,
ApiParam,
ApiConsumes,
ApiQuery,
} from '@nestjs/swagger';
import { Response } from 'express';
import { ContractsService } from './contracts.service';
import { CreateContractDto } from './dto/create-contract.dto';
import { UpdateContractDto } from './dto/update-contract.dto';
import { QueryContractsDto } from './dto/query-contracts.dto';
import { CurrentUser, JwtPayload } from '../common/decorators';
import { TenantGuard } from '../auth/guards/tenant.guard';
import {
paginatedResponse,
singleResponse,
} from '../common/dto/pagination.dto';
@ApiTags('Contracts (Vertraege)')
@ApiBearerAuth('access-token')
@UseGuards(TenantGuard)
@Controller('companies/:companyId/contracts')
export class ContractsController {
constructor(private readonly contractsService: ContractsService) {}
@Post()
@HttpCode(HttpStatus.CREATED)
@ApiOperation({ summary: 'Vertrag anlegen' })
@ApiParam({ name: 'companyId', type: 'string', format: 'uuid' })
async create(
@CurrentUser() user: JwtPayload,
@Param('companyId', ParseUUIDPipe) companyId: string,
@Body() dto: CreateContractDto,
) {
const contract = await this.contractsService.create(
user.tenantId!,
companyId,
user.sub,
dto,
);
return singleResponse(contract);
}
@Get()
@ApiOperation({ summary: 'Vertraege einer Firma auflisten (paginiert)' })
@ApiParam({ name: 'companyId', type: 'string', format: 'uuid' })
async findAll(
@CurrentUser() user: JwtPayload,
@Param('companyId', ParseUUIDPipe) companyId: string,
@Query() query: QueryContractsDto,
) {
const result = await this.contractsService.findAll(
user.tenantId!,
companyId,
query,
);
return paginatedResponse(
result.data,
result.total,
result.page,
result.pageSize,
);
}
@Get(':id')
@ApiOperation({ summary: 'Vertrag-Details abrufen' })
@ApiParam({ name: 'companyId', type: 'string', format: 'uuid' })
@ApiParam({ name: 'id', type: 'string', format: 'uuid' })
async findOne(
@CurrentUser() user: JwtPayload,
@Param('companyId', ParseUUIDPipe) companyId: string,
@Param('id', ParseUUIDPipe) id: string,
) {
const contract = await this.contractsService.findOne(
user.tenantId!,
companyId,
id,
);
return singleResponse(contract);
}
@Patch(':id')
@ApiOperation({ summary: 'Vertrag aktualisieren' })
@ApiParam({ name: 'companyId', type: 'string', format: 'uuid' })
@ApiParam({ name: 'id', type: 'string', format: 'uuid' })
async update(
@CurrentUser() user: JwtPayload,
@Param('companyId', ParseUUIDPipe) companyId: string,
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateContractDto,
) {
const contract = await this.contractsService.update(
user.tenantId!,
companyId,
id,
dto,
);
return singleResponse(contract);
}
@Delete(':id')
@ApiOperation({ summary: 'Vertrag loeschen' })
@ApiParam({ name: 'companyId', type: 'string', format: 'uuid' })
@ApiParam({ name: 'id', type: 'string', format: 'uuid' })
async remove(
@CurrentUser() user: JwtPayload,
@Param('companyId', ParseUUIDPipe) companyId: string,
@Param('id', ParseUUIDPipe) id: string,
) {
const contract = await this.contractsService.remove(
user.tenantId!,
companyId,
id,
);
return singleResponse(contract);
}
// --- Contract Files ---
@Post(':contractId/files')
@HttpCode(HttpStatus.CREATED)
@UseInterceptors(FileInterceptor('file'))
@ApiConsumes('multipart/form-data')
@ApiOperation({ summary: 'Datei an Vertrag hochladen (PDF, Word, Excel)' })
@ApiParam({ name: 'companyId', type: 'string', format: 'uuid' })
@ApiParam({ name: 'contractId', type: 'string', format: 'uuid' })
async uploadFile(
@CurrentUser() user: JwtPayload,
@Param('companyId', ParseUUIDPipe) companyId: string,
@Param('contractId', ParseUUIDPipe) contractId: string,
@UploadedFile(
new ParseFilePipe({
validators: [
new MaxFileSizeValidator({ maxSize: 26_214_400 }), // 25 MB
],
}),
)
file: Express.Multer.File,
) {
const result = await this.contractsService.uploadFile(
user.tenantId!,
companyId,
contractId,
user.sub,
file,
);
return singleResponse(result);
}
@Get(':contractId/files')
@ApiOperation({ summary: 'Dateien eines Vertrags auflisten' })
@ApiParam({ name: 'companyId', type: 'string', format: 'uuid' })
@ApiParam({ name: 'contractId', type: 'string', format: 'uuid' })
async listFiles(
@CurrentUser() user: JwtPayload,
@Param('companyId', ParseUUIDPipe) companyId: string,
@Param('contractId', ParseUUIDPipe) contractId: string,
) {
const files = await this.contractsService.listFiles(
user.tenantId!,
companyId,
contractId,
);
return singleResponse(files);
}
@Get(':contractId/files/:fileId/download')
@ApiOperation({ summary: 'Datei herunterladen / inline anzeigen' })
@ApiParam({ name: 'companyId', type: 'string', format: 'uuid' })
@ApiParam({ name: 'contractId', type: 'string', format: 'uuid' })
@ApiParam({ name: 'fileId', type: 'string', format: 'uuid' })
@ApiQuery({ name: 'inline', required: false, type: 'boolean' })
async downloadFile(
@CurrentUser() user: JwtPayload,
@Param('companyId', ParseUUIDPipe) companyId: string,
@Param('contractId', ParseUUIDPipe) contractId: string,
@Param('fileId', ParseUUIDPipe) fileId: string,
@Query('inline') inline: string | undefined,
@Res() res: Response,
) {
const { file, absPath } = await this.contractsService.getFile(
user.tenantId!,
companyId,
contractId,
fileId,
);
const disposition =
inline === 'true'
? `inline; filename="${file.originalName}"`
: `attachment; filename="${file.originalName}"`;
res.setHeader('Content-Type', file.mimeType);
res.setHeader('Content-Disposition', disposition);
res.sendFile(absPath);
}
@Delete(':contractId/files/:fileId')
@ApiOperation({ summary: 'Datei eines Vertrags loeschen' })
@ApiParam({ name: 'companyId', type: 'string', format: 'uuid' })
@ApiParam({ name: 'contractId', type: 'string', format: 'uuid' })
@ApiParam({ name: 'fileId', type: 'string', format: 'uuid' })
async deleteFile(
@CurrentUser() user: JwtPayload,
@Param('companyId', ParseUUIDPipe) companyId: string,
@Param('contractId', ParseUUIDPipe) contractId: string,
@Param('fileId', ParseUUIDPipe) fileId: string,
) {
const result = await this.contractsService.deleteFile(
user.tenantId!,
companyId,
contractId,
fileId,
);
return singleResponse(result);
}
}

View file

@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { ContractsController } from './contracts.controller';
import { ContractsService } from './contracts.service';
@Module({
controllers: [ContractsController],
providers: [ContractsService],
exports: [ContractsService],
})
export class ContractsModule {}

View file

@ -0,0 +1,306 @@
import {
Injectable,
NotFoundException,
BadRequestException,
} from '@nestjs/common';
import { CrmPrismaService } from '../prisma/crm-prisma.service';
import { CreateContractDto } from './dto/create-contract.dto';
import { UpdateContractDto } from './dto/update-contract.dto';
import { QueryContractsDto } from './dto/query-contracts.dto';
import { Prisma } from '.prisma/crm-client';
import * as fs from 'fs';
import * as path from 'path';
import { randomUUID } from 'crypto';
const UPLOAD_BASE = '/app/uploads/contracts';
const MAX_FILES_PER_CONTRACT = 10;
const ALLOWED_MIME_TYPES = [
'application/pdf',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
];
@Injectable()
export class ContractsService {
constructor(private readonly prisma: CrmPrismaService) {}
async create(
tenantId: string,
companyId: string,
userId: string,
dto: CreateContractDto,
) {
// Unternehmen validieren
const company = await this.prisma.company.findFirst({
where: { id: companyId, tenantId },
});
if (!company) {
throw new NotFoundException('Unternehmen nicht gefunden');
}
return this.prisma.contract.create({
data: {
tenantId,
companyId,
title: dto.title,
status: dto.status ?? 'DRAFT',
startDate: dto.startDate ? new Date(dto.startDate) : undefined,
endDate: dto.endDate ? new Date(dto.endDate) : undefined,
value: dto.value,
currency: dto.currency ?? 'EUR',
notes: dto.notes,
createdBy: userId,
},
include: {
company: { select: { id: true, name: true } },
},
});
}
async findAll(tenantId: string, companyId: string, query: QueryContractsDto) {
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 25;
const where: Prisma.ContractWhereInput = { tenantId, companyId };
if (query.status) {
where.status = query.status;
}
if (query.search) {
where.title = { contains: query.search, mode: 'insensitive' };
}
const allowedSortFields = [
'createdAt',
'updatedAt',
'title',
'startDate',
'endDate',
'value',
'status',
];
const sortField = allowedSortFields.includes(query.sort ?? '')
? (query.sort as string)
: 'createdAt';
const [data, total] = await Promise.all([
this.prisma.contract.findMany({
where,
skip: (page - 1) * pageSize,
take: pageSize,
orderBy: { [sortField]: query.order ?? 'desc' },
include: {
company: { select: { id: true, name: true } },
},
}),
this.prisma.contract.count({ where }),
]);
return { data, total, page, pageSize };
}
async findOne(tenantId: string, companyId: string, id: string) {
const contract = await this.prisma.contract.findFirst({
where: { id, tenantId, companyId },
include: {
company: { select: { id: true, name: true } },
},
});
if (!contract) {
throw new NotFoundException('Vertrag nicht gefunden');
}
return contract;
}
async update(
tenantId: string,
companyId: string,
id: string,
dto: UpdateContractDto,
) {
await this.findOne(tenantId, companyId, id);
const data: Prisma.ContractUpdateInput = {};
if (dto.title !== undefined) data.title = dto.title;
if (dto.status !== undefined) data.status = dto.status;
if (dto.startDate !== undefined)
data.startDate = dto.startDate ? new Date(dto.startDate) : null;
if (dto.endDate !== undefined)
data.endDate = dto.endDate ? new Date(dto.endDate) : null;
if (dto.value !== undefined) data.value = dto.value;
if (dto.currency !== undefined) data.currency = dto.currency;
if (dto.notes !== undefined) data.notes = dto.notes;
return this.prisma.contract.update({
where: { id },
data,
include: {
company: { select: { id: true, name: true } },
},
});
}
async remove(tenantId: string, companyId: string, id: string) {
const contract = await this.findOne(tenantId, companyId, id);
// Dateien auf Disk loeschen bevor DB-Cascade die Records entfernt
const files = await this.prisma.contractFile.findMany({
where: { contractId: id, tenantId },
});
for (const file of files) {
const absPath = path.resolve(file.storagePath);
try {
fs.unlinkSync(absPath);
} catch {
// Datei bereits geloescht oder nicht vorhanden — ignorieren
}
}
await this.prisma.contract.delete({ where: { id } });
return contract;
}
// --- Contract Files ---
async uploadFile(
tenantId: string,
companyId: string,
contractId: string,
userId: string,
file: Express.Multer.File,
) {
// Vertrag validieren
await this.findOne(tenantId, companyId, contractId);
// MIME-Typ pruefen
if (!ALLOWED_MIME_TYPES.includes(file.mimetype)) {
throw new BadRequestException(
`Dateityp '${file.mimetype}' nicht erlaubt. Erlaubt: PDF, Word, Excel`,
);
}
// Max-Dateien pruefen
const fileCount = await this.prisma.contractFile.count({
where: { contractId, tenantId },
});
if (fileCount >= MAX_FILES_PER_CONTRACT) {
throw new BadRequestException(
`Maximal ${MAX_FILES_PER_CONTRACT} Dateien pro Vertrag erlaubt`,
);
}
// Verzeichnis erstellen
const dir = path.join(UPLOAD_BASE, tenantId, contractId);
fs.mkdirSync(dir, { recursive: true });
// Datei speichern (UUID-Prefix gegen Kollisionen + Path-Traversal)
const fileUuid = randomUUID();
const safeName = path.basename(file.originalname);
const diskName = `${fileUuid}-${safeName}`;
const storagePath = path.join(dir, diskName);
fs.writeFileSync(storagePath, file.buffer);
// DB-Record anlegen
return this.prisma.contractFile.create({
data: {
tenantId,
contractId,
originalName: file.originalname,
storagePath,
mimeType: file.mimetype,
size: file.size,
uploadedBy: userId,
},
select: {
id: true,
contractId: true,
originalName: true,
mimeType: true,
size: true,
uploadedBy: true,
createdAt: true,
},
});
}
async listFiles(tenantId: string, companyId: string, contractId: string) {
// Vertrag validieren
await this.findOne(tenantId, companyId, contractId);
return this.prisma.contractFile.findMany({
where: { contractId, tenantId },
select: {
id: true,
contractId: true,
originalName: true,
mimeType: true,
size: true,
uploadedBy: true,
createdAt: true,
},
orderBy: { createdAt: 'desc' },
});
}
async getFile(
tenantId: string,
companyId: string,
contractId: string,
fileId: string,
) {
// Vertrag validieren
await this.findOne(tenantId, companyId, contractId);
const file = await this.prisma.contractFile.findFirst({
where: { id: fileId, contractId, tenantId },
});
if (!file) {
throw new NotFoundException('Datei nicht gefunden');
}
const absPath = path.resolve(file.storagePath);
if (!fs.existsSync(absPath)) {
throw new NotFoundException('Datei auf Disk nicht gefunden');
}
return { file, absPath };
}
async deleteFile(
tenantId: string,
companyId: string,
contractId: string,
fileId: string,
) {
// Vertrag validieren
await this.findOne(tenantId, companyId, contractId);
const file = await this.prisma.contractFile.findFirst({
where: { id: fileId, contractId, tenantId },
});
if (!file) {
throw new NotFoundException('Datei nicht gefunden');
}
// DB-Record loeschen
await this.prisma.contractFile.delete({ where: { id: fileId } });
// Datei auf Disk loeschen (kein Fehler wenn nicht vorhanden)
try {
fs.unlinkSync(path.resolve(file.storagePath));
} catch {
// Datei bereits geloescht — ignorieren
}
return { id: fileId, deleted: true };
}
}

View file

@ -0,0 +1,64 @@
import {
IsString,
IsOptional,
IsEnum,
IsDateString,
IsNumber,
MaxLength,
Min,
} from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export enum ContractStatus {
DRAFT = 'DRAFT',
ACTIVE = 'ACTIVE',
EXPIRED = 'EXPIRED',
CANCELLED = 'CANCELLED',
}
export class CreateContractDto {
@ApiProperty({ maxLength: 255, description: 'Vertragsbezeichnung' })
@IsString()
@MaxLength(255)
title!: string;
@ApiPropertyOptional({
enum: ContractStatus,
default: ContractStatus.DRAFT,
description: 'Vertragsstatus',
})
@IsOptional()
@IsEnum(ContractStatus)
status?: ContractStatus;
@ApiPropertyOptional({ format: 'date-time', description: 'Vertragsbeginn' })
@IsOptional()
@IsDateString()
startDate?: string;
@ApiPropertyOptional({ format: 'date-time', description: 'Vertragsende' })
@IsOptional()
@IsDateString()
endDate?: string;
@ApiPropertyOptional({ description: 'Vertragswert', minimum: 0 })
@IsOptional()
@IsNumber({ maxDecimalPlaces: 2 })
@Min(0)
value?: number;
@ApiPropertyOptional({
default: 'EUR',
maxLength: 3,
description: 'Waehrung (ISO 4217)',
})
@IsOptional()
@IsString()
@MaxLength(3)
currency?: string;
@ApiPropertyOptional({ description: 'Notizen zum Vertrag' })
@IsOptional()
@IsString()
notes?: string;
}

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 { ContractStatus } from './create-contract.dto';
export class QueryContractsDto extends PaginationDto {
@ApiPropertyOptional({ enum: ContractStatus, description: 'Filter nach Status' })
@IsOptional()
@IsEnum(ContractStatus)
status?: ContractStatus;
@ApiPropertyOptional({ description: 'Suche in 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 { CreateContractDto } from './create-contract.dto';
export class UpdateContractDto extends PartialType(CreateContractDto) {}

View file

@ -22,6 +22,7 @@ import { DealsService } from './deals.service';
import { CreateDealDto } from './dto/create-deal.dto'; import { CreateDealDto } from './dto/create-deal.dto';
import { UpdateDealDto } from './dto/update-deal.dto'; import { UpdateDealDto } from './dto/update-deal.dto';
import { QueryDealsDto } from './dto/query-deals.dto'; import { QueryDealsDto } from './dto/query-deals.dto';
import { ForecastQueryDto } from './dto/forecast-query.dto';
import { AddOwnerDto } from '../common/dto/owner.dto'; import { AddOwnerDto } from '../common/dto/owner.dto';
import { OwnersService } from '../owners/owners.service'; import { OwnersService } from '../owners/owners.service';
import { CurrentUser, JwtPayload } from '../common/decorators'; import { CurrentUser, JwtPayload } from '../common/decorators';
@ -71,6 +72,16 @@ export class DealsController {
); );
} }
@Get('forecast')
@ApiOperation({ summary: 'Umsatz-Forecast (gewichtete Pipeline)' })
async forecast(
@CurrentUser() user: JwtPayload,
@Query() query: ForecastQueryDto,
) {
const result = await this.dealsService.forecast(user.tenantId!, query);
return singleResponse(result);
}
@Get(':id') @Get(':id')
@ApiOperation({ summary: 'Vorgangsdetails abrufen' }) @ApiOperation({ summary: 'Vorgangsdetails abrufen' })
@ApiParam({ name: 'id', type: 'string', format: 'uuid' }) @ApiParam({ name: 'id', type: 'string', format: 'uuid' })

View file

@ -7,6 +7,7 @@ import { CrmPrismaService } from '../prisma/crm-prisma.service';
import { CreateDealDto } from './dto/create-deal.dto'; import { CreateDealDto } from './dto/create-deal.dto';
import { UpdateDealDto } from './dto/update-deal.dto'; import { UpdateDealDto } from './dto/update-deal.dto';
import { QueryDealsDto } from './dto/query-deals.dto'; import { QueryDealsDto } from './dto/query-deals.dto';
import { ForecastQueryDto, ForecastPeriod } from './dto/forecast-query.dto';
import { CrmEventPublisher } from '../events/crm-event-publisher.service'; import { CrmEventPublisher } from '../events/crm-event-publisher.service';
import { CustomFieldsService } from '../custom-fields/custom-fields.service'; import { CustomFieldsService } from '../custom-fields/custom-fields.service';
import { CustomFieldEntityType } from '../custom-fields/dto/create-custom-field.dto'; import { CustomFieldEntityType } from '../custom-fields/dto/create-custom-field.dto';
@ -333,4 +334,123 @@ export class DealsService {
return this.prisma.deal.delete({ where: { id } }); return this.prisma.deal.delete({ where: { id } });
} }
// --------------------------------------------------------
// Forecast (gewichtete Pipeline-Aggregation)
// --------------------------------------------------------
async forecast(tenantId: string, query: ForecastQueryDto) {
const period = query.period ?? ForecastPeriod.QUARTER;
const { start, end } = this.getPeriodBounds(period);
const where: Prisma.DealWhereInput = {
tenantId,
status: 'OPEN',
expectedCloseDate: { gte: start, lte: end },
};
if (query.pipelineId) {
where.pipelineId = query.pipelineId;
}
const deals = await this.prisma.deal.findMany({
where,
include: {
stage: {
select: {
id: true,
name: true,
color: true,
probability: true,
sortOrder: true,
},
},
pipeline: { select: { id: true, name: true } },
},
});
// Nach Stage gruppieren
const stageMap = new Map<string, {
stageId: string;
stageName: string;
stageColor: string;
probability: number;
sortOrder: number;
pipelineId: string;
pipelineName: string;
dealCount: number;
totalValue: number;
weightedValue: number;
}>();
for (const deal of deals) {
const key = deal.stageId;
const value = deal.value?.toNumber() ?? 0;
const prob = deal.stage.probability?.toNumber() ?? 0;
if (!stageMap.has(key)) {
stageMap.set(key, {
stageId: deal.stage.id,
stageName: deal.stage.name,
stageColor: deal.stage.color,
probability: prob,
sortOrder: deal.stage.sortOrder,
pipelineId: deal.pipeline.id,
pipelineName: deal.pipeline.name,
dealCount: 0,
totalValue: 0,
weightedValue: 0,
});
}
const entry = stageMap.get(key)!;
entry.dealCount += 1;
entry.totalValue += value;
entry.weightedValue += value * prob;
}
const stages = Array.from(stageMap.values()).sort(
(a, b) => a.sortOrder - b.sortOrder,
);
const totalDeals = stages.reduce((sum, s) => sum + s.dealCount, 0);
const totalValue = stages.reduce((sum, s) => sum + s.totalValue, 0);
const totalWeightedValue = stages.reduce((sum, s) => sum + s.weightedValue, 0);
return {
period,
periodStart: start.toISOString(),
periodEnd: end.toISOString(),
currency: 'EUR',
stages,
totals: {
dealCount: totalDeals,
totalValue,
weightedValue: totalWeightedValue,
},
};
}
private getPeriodBounds(period: ForecastPeriod): { start: Date; end: Date } {
const now = new Date();
switch (period) {
case ForecastPeriod.MONTH: {
const start = new Date(now.getFullYear(), now.getMonth(), 1);
const end = new Date(now.getFullYear(), now.getMonth() + 1, 0, 23, 59, 59, 999);
return { start, end };
}
case ForecastPeriod.QUARTER: {
const quarterStart = Math.floor(now.getMonth() / 3) * 3;
const start = new Date(now.getFullYear(), quarterStart, 1);
const end = new Date(now.getFullYear(), quarterStart + 3, 0, 23, 59, 59, 999);
return { start, end };
}
case ForecastPeriod.YEAR: {
const start = new Date(now.getFullYear(), 0, 1);
const end = new Date(now.getFullYear(), 11, 31, 23, 59, 59, 999);
return { start, end };
}
}
}
} }

View file

@ -0,0 +1,24 @@
import { IsOptional, IsUUID, IsEnum } from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
export enum ForecastPeriod {
MONTH = 'month',
QUARTER = 'quarter',
YEAR = 'year',
}
export class ForecastQueryDto {
@ApiPropertyOptional({ format: 'uuid', description: 'Filter nach Pipeline' })
@IsOptional()
@IsUUID()
pipelineId?: string;
@ApiPropertyOptional({
enum: ForecastPeriod,
default: ForecastPeriod.QUARTER,
description: 'Zeitraum fuer den Forecast',
})
@IsOptional()
@IsEnum(ForecastPeriod)
period?: ForecastPeriod = ForecastPeriod.QUARTER;
}

View file

@ -0,0 +1,15 @@
export interface EnrichmentSuggestion {
current: string | null;
suggested: string | null;
source: string;
confidence?: 'HIGH' | 'MEDIUM' | 'LOW';
}
export interface EnrichmentResponse {
companyId: string;
companyName: string;
sources: string[];
suggestions: Record<string, EnrichmentSuggestion>;
enrichedAt: string;
warnings: string[];
}

View file

@ -0,0 +1,19 @@
import { IsString, IsOptional, IsBoolean } from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
export class UpdateNorthDataSettingsDto {
@ApiPropertyOptional({ description: 'North Data API Key' })
@IsOptional()
@IsString()
apiKey?: string;
@ApiPropertyOptional({ description: 'Integration aktiviert?' })
@IsOptional()
@IsBoolean()
enabled?: boolean;
}
export interface NorthDataSettings {
apiKey: string | null;
enabled: boolean;
}

View file

@ -0,0 +1,77 @@
import {
Controller,
Post,
Get,
Put,
Param,
Body,
ParseUUIDPipe,
UseGuards,
} from '@nestjs/common';
import {
ApiTags,
ApiOperation,
ApiBearerAuth,
ApiParam,
} from '@nestjs/swagger';
import { CurrentUser, JwtPayload } from '../common/decorators';
import { TenantGuard } from '../auth/guards/tenant.guard';
import { singleResponse } from '../common/dto/pagination.dto';
import { EnrichmentService } from './enrichment.service';
import { UpdateNorthDataSettingsDto } from './dto/enrichment-settings.dto';
@ApiTags('Datenanreicherung (Enrichment)')
@ApiBearerAuth('access-token')
@UseGuards(TenantGuard)
@Controller()
export class EnrichmentController {
constructor(private readonly enrichmentService: EnrichmentService) {}
@Post('companies/:id/enrich')
@ApiOperation({
summary: 'Unternehmensdaten anreichern (Vorschlaege, kein Auto-Write)',
})
@ApiParam({ name: 'id', type: 'string', format: 'uuid' })
async enrich(
@CurrentUser() user: JwtPayload,
@Param('id', ParseUUIDPipe) id: string,
) {
const result = await this.enrichmentService.enrichCompany(
user.tenantId!,
id,
);
return singleResponse(result);
}
@Get('settings/integrations/north-data')
@ApiOperation({ summary: 'North Data Einstellungen abrufen' })
async getSettings(@CurrentUser() user: JwtPayload) {
const settings = await this.enrichmentService.getNorthDataSettings(
user.tenantId!,
);
return singleResponse({
...settings,
apiKey: settings.apiKey
? `****${settings.apiKey.slice(-4)}`
: null,
});
}
@Put('settings/integrations/north-data')
@ApiOperation({ summary: 'North Data Einstellungen aktualisieren' })
async updateSettings(
@CurrentUser() user: JwtPayload,
@Body() dto: UpdateNorthDataSettingsDto,
) {
const settings = await this.enrichmentService.updateNorthDataSettings(
user.tenantId!,
dto,
);
return singleResponse({
...settings,
apiKey: settings.apiKey
? `****${settings.apiKey.slice(-4)}`
: null,
});
}
}

View file

@ -0,0 +1,16 @@
import { Module } from '@nestjs/common';
import { HttpModule } from '@nestjs/axios';
import { EnrichmentController } from './enrichment.controller';
import { EnrichmentService } from './enrichment.service';
@Module({
imports: [
HttpModule.register({
timeout: 15000,
}),
],
controllers: [EnrichmentController],
providers: [EnrichmentService],
exports: [EnrichmentService],
})
export class EnrichmentModule {}

View file

@ -0,0 +1,307 @@
import {
Injectable,
Logger,
NotFoundException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { HttpService } from '@nestjs/axios';
import { firstValueFrom } from 'rxjs';
import { CrmPrismaService } from '../prisma/crm-prisma.service';
import { RedisService } from '../redis/redis.service';
import {
EnrichmentResponse,
EnrichmentSuggestion,
} from './dto/enrich-response.dto';
import {
NorthDataSettings,
UpdateNorthDataSettingsDto,
} from './dto/enrichment-settings.dto';
const NORTH_DATA_KEY = (tenantId: string) =>
`crm:${tenantId}:integrations:north_data`;
@Injectable()
export class EnrichmentService {
private readonly logger = new Logger(EnrichmentService.name);
constructor(
private readonly prisma: CrmPrismaService,
private readonly redis: RedisService,
private readonly config: ConfigService,
private readonly httpService: HttpService,
) {}
// --------------------------------------------------------
// Unternehmen anreichern (Suggestion-Only)
// --------------------------------------------------------
async enrichCompany(
tenantId: string,
companyId: string,
): Promise<EnrichmentResponse> {
const company = await this.prisma.company.findFirst({
where: { id: companyId, tenantId },
});
if (!company) {
throw new NotFoundException('Unternehmen nicht gefunden');
}
const warnings: string[] = [];
const sources: string[] = [];
const suggestions: Record<string, EnrichmentSuggestion> = {};
const northDataSettings = await this.getNorthDataSettings(tenantId);
// Parallele Abfragen
const [registerResult, northDataResult] = await Promise.allSettled([
this.queryUnternehmensregister(company.name, company.city ?? undefined),
northDataSettings.enabled && northDataSettings.apiKey
? this.queryNorthData(company.name, northDataSettings.apiKey)
: Promise.resolve(null),
]);
// Unternehmensregister.de Ergebnisse verarbeiten
if (registerResult.status === 'fulfilled' && registerResult.value) {
sources.push('unternehmensregister.de');
const data = registerResult.value;
this.addSuggestion(suggestions, 'tradeRegisterNumber', company.tradeRegisterNumber, data.registerNumber, 'unternehmensregister.de');
this.addSuggestion(suggestions, 'registerCourt', company.registerCourt, data.registerCourt, 'unternehmensregister.de');
this.addSuggestion(suggestions, 'street', company.street, data.street, 'unternehmensregister.de');
this.addSuggestion(suggestions, 'zip', company.zip, data.zip, 'unternehmensregister.de');
this.addSuggestion(suggestions, 'city', company.city, data.city, 'unternehmensregister.de');
} else if (registerResult.status === 'rejected') {
warnings.push(`Unternehmensregister: ${String(registerResult.reason)}`);
}
// North Data Ergebnisse verarbeiten
if (northDataResult.status === 'fulfilled' && northDataResult.value) {
sources.push('northdata.de');
const data = northDataResult.value;
this.addSuggestion(suggestions, 'name', company.name, data.name, 'northdata.de');
this.addSuggestion(suggestions, 'vatId', company.vatId, data.vatId, 'northdata.de');
this.addSuggestion(suggestions, 'website', company.website, data.website, 'northdata.de');
this.addSuggestion(suggestions, 'phone', company.phone, data.phone, 'northdata.de');
this.addSuggestion(suggestions, 'street', company.street, data.street, 'northdata.de');
this.addSuggestion(suggestions, 'zip', company.zip, data.zip, 'northdata.de');
this.addSuggestion(suggestions, 'city', company.city, data.city, 'northdata.de');
this.addSuggestion(suggestions, 'industry', company.industry, data.industry, 'northdata.de');
} else if (northDataResult.status === 'rejected' && northDataSettings.enabled) {
warnings.push(`North Data: ${String(northDataResult.reason)}`);
} else if (!northDataSettings.enabled) {
warnings.push('North Data Integration ist nicht aktiviert.');
}
if (sources.length === 0) {
warnings.push('Keine Datenquelle konnte abgefragt werden.');
}
return {
companyId,
companyName: company.name,
sources,
suggestions,
enrichedAt: new Date().toISOString(),
warnings,
};
}
// --------------------------------------------------------
// Unternehmensregister.de Abfrage (kostenfrei)
// --------------------------------------------------------
private async queryUnternehmensregister(
companyName: string,
city?: string,
): Promise<{
registerNumber: string | null;
registerCourt: string | null;
street: string | null;
zip: string | null;
city: string | null;
} | null> {
try {
const searchUrl = 'https://www.unternehmensregister.de/trxweb/api/search';
const response = await firstValueFrom(
this.httpService.get(searchUrl, {
params: {
keyword: companyName,
...(city ? { location: city } : {}),
},
timeout: 10000,
}),
);
const data = response.data;
if (data && typeof data === 'object') {
return {
registerNumber: data.registerNumber ?? null,
registerCourt: data.registerCourt ?? null,
street: data.street ?? null,
zip: data.zip ?? null,
city: data.city ?? null,
};
}
return null;
} catch (err) {
this.logger.warn(
`Unternehmensregister-Abfrage fehlgeschlagen fuer "${companyName}": ${err instanceof Error ? err.message : String(err)}`,
);
throw new Error('Unternehmensregister nicht erreichbar');
}
}
// --------------------------------------------------------
// North Data API Abfrage (kostenpflichtig)
// --------------------------------------------------------
private async queryNorthData(
companyName: string,
apiKey: string,
): Promise<{
name: string | null;
vatId: string | null;
website: string | null;
phone: string | null;
street: string | null;
zip: string | null;
city: string | null;
industry: string | null;
} | null> {
try {
const baseUrl = this.config.get<string>(
'NORTH_DATA_API_URL',
'https://www.northdata.de/_api',
);
const response = await firstValueFrom(
this.httpService.get(`${baseUrl}/company/v1/company`, {
params: {
name: companyName,
address: 'DE',
},
headers: {
'X-Api-Key': apiKey,
},
timeout: 15000,
}),
);
const data = response.data;
if (data && typeof data === 'object') {
return {
name: data.name?.current ?? null,
vatId: data.vatId ?? null,
website: data.website ?? null,
phone: data.phone ?? null,
street: data.address?.street ?? null,
zip: data.address?.postalCode ?? null,
city: data.address?.city ?? null,
industry: data.industry?.description ?? null,
};
}
return null;
} catch (err) {
this.logger.warn(
`North Data Abfrage fehlgeschlagen fuer "${companyName}": ${err instanceof Error ? err.message : String(err)}`,
);
throw new Error('North Data API nicht erreichbar');
}
}
// --------------------------------------------------------
// Helfer: Suggestion hinzufuegen (nur wenn suggested != current)
// --------------------------------------------------------
private addSuggestion(
suggestions: Record<string, EnrichmentSuggestion>,
field: string,
current: string | null | undefined,
suggested: string | null | undefined,
source: string,
): void {
if (!suggested) return;
const currentVal = current ?? null;
const suggestedVal = suggested;
// Nur hinzufuegen wenn current leer oder verschieden
if (currentVal !== suggestedVal) {
// Erste Quelle gewinnt bei Duplikaten
if (!suggestions[field]) {
suggestions[field] = {
current: currentVal,
suggested: suggestedVal,
source,
};
}
}
}
// --------------------------------------------------------
// North Data Settings (Redis)
// --------------------------------------------------------
async getNorthDataSettings(tenantId: string): Promise<NorthDataSettings> {
const key = NORTH_DATA_KEY(tenantId);
const raw = await this.redis.get(key);
if (!raw) {
const envKey = this.config.get<string>('NORTH_DATA_API_KEY');
return {
apiKey: envKey ?? null,
enabled: !!envKey,
};
}
return JSON.parse(raw) as NorthDataSettings;
}
async updateNorthDataSettings(
tenantId: string,
dto: UpdateNorthDataSettingsDto,
): Promise<NorthDataSettings> {
const current = await this.getNorthDataSettings(tenantId);
const updated: NorthDataSettings = {
apiKey: dto.apiKey !== undefined ? dto.apiKey : current.apiKey,
enabled: dto.enabled !== undefined ? dto.enabled : current.enabled,
};
const key = NORTH_DATA_KEY(tenantId);
await this.redis.set(key, JSON.stringify(updated));
return updated;
}
// --------------------------------------------------------
// Health Check
// --------------------------------------------------------
async isHealthy(): Promise<'up' | 'down' | 'unconfigured'> {
const envKey = this.config.get<string>('NORTH_DATA_API_KEY');
if (!envKey) return 'unconfigured';
try {
const baseUrl = this.config.get<string>(
'NORTH_DATA_API_URL',
'https://www.northdata.de/_api',
);
await firstValueFrom(
this.httpService.get(`${baseUrl}/health`, {
headers: { 'X-Api-Key': envKey },
timeout: 5000,
}),
);
return 'up';
} catch {
return 'down';
}
}
}

View file

@ -4,6 +4,7 @@ import { Public } from '../common/decorators/public.decorator';
import { CrmPrismaService } from '../prisma/crm-prisma.service'; import { CrmPrismaService } from '../prisma/crm-prisma.service';
import { RedisService } from '../redis/redis.service'; import { RedisService } from '../redis/redis.service';
import { LexwareClientService } from '../lexware/lexware-client.service'; import { LexwareClientService } from '../lexware/lexware-client.service';
import { EnrichmentService } from '../enrichment/enrichment.service';
interface HealthResponse { interface HealthResponse {
status: 'ok' | 'error'; status: 'ok' | 'error';
@ -14,6 +15,7 @@ interface HealthResponse {
database: 'up' | 'down'; database: 'up' | 'down';
redis: 'up' | 'down'; redis: 'up' | 'down';
lexware: 'up' | 'down' | 'unconfigured'; lexware: 'up' | 'down' | 'unconfigured';
enrichment: 'up' | 'down' | 'unconfigured';
}; };
} }
@ -25,17 +27,21 @@ export class HealthController {
private readonly redis: RedisService, private readonly redis: RedisService,
@Optional() @Inject(LexwareClientService) @Optional() @Inject(LexwareClientService)
private readonly lexwareClient?: LexwareClientService, private readonly lexwareClient?: LexwareClientService,
@Optional() @Inject(EnrichmentService)
private readonly enrichmentService?: EnrichmentService,
) {} ) {}
@Get() @Get()
@Public() @Public()
@ApiOperation({ summary: 'Health-Check fuer CRM-Service' }) @ApiOperation({ summary: 'Health-Check fuer CRM-Service' })
async check(): Promise<HealthResponse> { async check(): Promise<HealthResponse> {
const [dbStatus, redisStatus, lexwareStatus] = await Promise.allSettled([ const [dbStatus, redisStatus, lexwareStatus, enrichmentStatus] =
this.checkDatabase(), await Promise.allSettled([
this.checkRedis(), this.checkDatabase(),
this.checkLexware(), this.checkRedis(),
]); this.checkLexware(),
this.checkEnrichment(),
]);
const dbOk = dbStatus.status === 'fulfilled' && dbStatus.value; const dbOk = dbStatus.status === 'fulfilled' && dbStatus.value;
const redisOk = redisStatus.status === 'fulfilled' && redisStatus.value; const redisOk = redisStatus.status === 'fulfilled' && redisStatus.value;
@ -43,19 +49,26 @@ export class HealthController {
lexwareStatus.status === 'fulfilled' lexwareStatus.status === 'fulfilled'
? lexwareStatus.value ? lexwareStatus.value
: 'down'; : 'down';
const enrichmentResult: 'up' | 'down' | 'unconfigured' =
enrichmentStatus.status === 'fulfilled'
? enrichmentStatus.value
: 'down';
// Lexware "unconfigured" ist kein Fehler // "unconfigured" ist kein Fehler
const allUp = dbOk && redisOk && lexwareResult !== 'down'; const allUp = dbOk && redisOk
&& lexwareResult !== 'down'
&& enrichmentResult !== 'down';
return { return {
status: allUp ? 'ok' : 'error', status: allUp ? 'ok' : 'error',
service: 'crm-service', service: 'crm-service',
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
version: '0.2.0', version: '0.3.0',
services: { services: {
database: dbOk ? 'up' : 'down', database: dbOk ? 'up' : 'down',
redis: redisOk ? 'up' : 'down', redis: redisOk ? 'up' : 'down',
lexware: lexwareResult, lexware: lexwareResult,
enrichment: enrichmentResult,
}, },
}; };
} }
@ -82,4 +95,9 @@ export class HealthController {
if (!this.lexwareClient) return 'unconfigured'; if (!this.lexwareClient) return 'unconfigured';
return this.lexwareClient.isHealthy(); return this.lexwareClient.isHealthy();
} }
private async checkEnrichment(): Promise<'up' | 'down' | 'unconfigured'> {
if (!this.enrichmentService) return 'unconfigured';
return this.enrichmentService.isHealthy();
}
} }

View file

@ -1,9 +1,10 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { HealthController } from './health.controller'; import { HealthController } from './health.controller';
import { LexwareModule } from '../lexware/lexware.module'; import { LexwareModule } from '../lexware/lexware.module';
import { EnrichmentModule } from '../enrichment/enrichment.module';
@Module({ @Module({
imports: [LexwareModule], imports: [LexwareModule, EnrichmentModule],
controllers: [HealthController], controllers: [HealthController],
}) })
export class HealthModule {} export class HealthModule {}

View file

@ -0,0 +1,52 @@
import {
IsString,
IsEnum,
IsArray,
IsOptional,
ValidateNested,
IsUUID,
} from 'class-validator';
import { Type } from 'class-transformer';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { ImportEntityType } from './import-preview.dto';
export enum DuplicateStrategy {
SKIP = 'SKIP',
UPDATE = 'UPDATE',
MARK = 'MARK',
}
export class FieldMappingDto {
@ApiProperty({ description: 'Spaltenname aus der Datei' })
@IsString()
sourceColumn!: string;
@ApiProperty({ description: 'Zielfeld im CRM (z.B. firstName, email, phone)' })
@IsString()
targetField!: string;
}
export class ImportExecuteDto {
@ApiProperty({ format: 'uuid', description: 'Import-ID aus der Preview' })
@IsUUID()
importId!: string;
@ApiProperty({ enum: ImportEntityType })
@IsEnum(ImportEntityType)
entityType!: ImportEntityType;
@ApiProperty({ type: [FieldMappingDto], description: 'Feld-Zuordnungen' })
@IsArray()
@ValidateNested({ each: true })
@Type(() => FieldMappingDto)
mapping!: FieldMappingDto[];
@ApiPropertyOptional({
enum: DuplicateStrategy,
default: DuplicateStrategy.SKIP,
description: 'Strategie bei Duplikaten (E-Mail-Abgleich)',
})
@IsOptional()
@IsEnum(DuplicateStrategy)
duplicateStrategy?: DuplicateStrategy = DuplicateStrategy.SKIP;
}

View file

@ -0,0 +1,23 @@
import { IsEnum, IsOptional, IsString } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export enum ImportEntityType {
CONTACT = 'contact',
COMPANY = 'company',
}
export class ImportPreviewQueryDto {
@ApiProperty({
enum: ImportEntityType,
description: 'Ziel-Entitaet fuer den Import',
})
@IsEnum(ImportEntityType)
entityType!: ImportEntityType;
@ApiPropertyOptional({
description: 'CSV-Trennzeichen (Standard: automatische Erkennung)',
})
@IsOptional()
@IsString()
delimiter?: string;
}

View file

@ -0,0 +1,103 @@
import {
Controller,
Post,
Body,
Query,
UseGuards,
UseInterceptors,
UploadedFile,
BadRequestException,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import {
ApiTags,
ApiOperation,
ApiBearerAuth,
ApiConsumes,
ApiBody,
} from '@nestjs/swagger';
import { CurrentUser, JwtPayload } from '../common/decorators';
import { TenantGuard } from '../auth/guards/tenant.guard';
import { singleResponse } from '../common/dto/pagination.dto';
import { ImportService } from './import.service';
import { ImportPreviewQueryDto } from './dto/import-preview.dto';
import { ImportExecuteDto } from './dto/import-execute.dto';
@ApiTags('Import (CSV/Excel)')
@ApiBearerAuth('access-token')
@UseGuards(TenantGuard)
@Controller('import')
export class ImportController {
constructor(private readonly importService: ImportService) {}
@Post('preview')
@ApiOperation({ summary: 'Datei-Vorschau (CSV/XLSX) mit Spalten-Erkennung' })
@ApiConsumes('multipart/form-data')
@ApiBody({
schema: {
type: 'object',
properties: {
file: { type: 'string', format: 'binary' },
entityType: { type: 'string', enum: ['contact', 'company'] },
delimiter: { type: 'string' },
},
required: ['file', 'entityType'],
},
})
@UseInterceptors(
FileInterceptor('file', {
limits: { fileSize: 10 * 1024 * 1024 }, // 10 MB
fileFilter: (
_req: unknown,
file: Express.Multer.File,
cb: (error: Error | null, acceptFile: boolean) => void,
) => {
const allowed = [
'text/csv',
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'text/plain',
];
if (allowed.includes(file.mimetype)) {
cb(null, true);
} else {
cb(
new BadRequestException(
`Dateityp "${file.mimetype}" nicht erlaubt. Nur CSV und Excel.`,
),
false,
);
}
},
}),
)
async preview(
@UploadedFile() file: Express.Multer.File,
@Query() query: ImportPreviewQueryDto,
) {
if (!file) {
throw new BadRequestException('Keine Datei hochgeladen');
}
const result = await this.importService.preview(
file,
query.entityType,
query.delimiter,
);
return singleResponse(result);
}
@Post('execute')
@ApiOperation({ summary: 'Import ausfuehren (nach Preview)' })
async execute(
@CurrentUser() user: JwtPayload,
@Body() dto: ImportExecuteDto,
) {
const result = await this.importService.execute(
user.tenantId!,
user.sub,
dto,
);
return singleResponse(result);
}
}

View file

@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { ImportController } from './import.controller';
import { ImportService } from './import.service';
@Module({
controllers: [ImportController],
providers: [ImportService],
})
export class ImportModule {}

View file

@ -0,0 +1,509 @@
import {
Injectable,
Logger,
BadRequestException,
NotFoundException,
} from '@nestjs/common';
import { CrmPrismaService } from '../prisma/crm-prisma.service';
import { ImportEntityType } from './dto/import-preview.dto';
import {
ImportExecuteDto,
DuplicateStrategy,
FieldMappingDto,
} from './dto/import-execute.dto';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { v4 as uuidv4 } from 'uuid';
import csvParser from 'csv-parser';
import * as XLSX from 'xlsx';
import { Readable } from 'stream';
const MAX_ROWS = 5000;
const PREVIEW_ROWS = 10;
const TARGET_FIELDS: Record<ImportEntityType, string[]> = {
[ImportEntityType.CONTACT]: [
'firstName', 'lastName', 'email', 'phone', 'mobile',
'companyName', 'position', 'department', 'website',
'street', 'zip', 'city', 'state', 'country',
'notes', 'tags', 'source', 'linkedinUrl',
],
[ImportEntityType.COMPANY]: [
'name', 'email', 'phone', 'website', 'industry',
'street', 'zip', 'city', 'state', 'country',
'vatId', 'taxId', 'tradeRegisterNumber', 'registerCourt',
'notes', 'tags',
],
};
interface ParsedFile {
columns: string[];
rows: Record<string, string>[];
totalRows: number;
format: 'csv' | 'xlsx';
}
export interface ImportError {
row: number;
field: string;
value: string;
message: string;
}
@Injectable()
export class ImportService {
private readonly logger = new Logger(ImportService.name);
constructor(private readonly prisma: CrmPrismaService) {}
// --------------------------------------------------------
// Preview: Datei parsen und Vorschau zurueckgeben
// --------------------------------------------------------
async preview(
file: Express.Multer.File,
entityType: ImportEntityType,
delimiter?: string,
) {
const ext = path.extname(file.originalname).toLowerCase();
if (!['.csv', '.xlsx', '.xls'].includes(ext)) {
throw new BadRequestException(
'Nicht unterstuetztes Dateiformat. Erlaubt: .csv, .xlsx, .xls',
);
}
const parsed = ext === '.csv'
? await this.parseCsv(file.buffer, delimiter)
: this.parseExcel(file.buffer);
if (parsed.totalRows > MAX_ROWS) {
throw new BadRequestException(
`Datei enthaelt ${parsed.totalRows} Zeilen. Maximum: ${MAX_ROWS}`,
);
}
// Temp-Datei speichern (GDPR: wird nach Execute geloescht)
const importId = uuidv4();
const tmpPath = path.join(os.tmpdir(), `crm-import-${importId}${ext}`);
fs.writeFileSync(tmpPath, file.buffer);
return {
importId,
format: parsed.format,
columns: parsed.columns,
rows: parsed.rows.slice(0, PREVIEW_ROWS),
totalRows: parsed.totalRows,
availableTargetFields: TARGET_FIELDS[entityType],
};
}
// --------------------------------------------------------
// Execute: Import ausfuehren
// --------------------------------------------------------
async execute(tenantId: string, userId: string, dto: ImportExecuteDto) {
const tmpDir = os.tmpdir();
const possibleExts = ['.csv', '.xlsx', '.xls'];
let tmpPath: string | null = null;
let ext = '';
for (const e of possibleExts) {
const candidate = path.join(tmpDir, `crm-import-${dto.importId}${e}`);
if (fs.existsSync(candidate)) {
tmpPath = candidate;
ext = e;
break;
}
}
if (!tmpPath) {
throw new NotFoundException(
'Import-Datei nicht gefunden. Bitte erneut hochladen (Preview abgelaufen).',
);
}
try {
const buffer = fs.readFileSync(tmpPath);
const parsed = ext === '.csv'
? await this.parseCsv(buffer)
: this.parseExcel(buffer);
this.validateMapping(dto.mapping, dto.entityType);
const result = dto.entityType === ImportEntityType.CONTACT
? await this.importContacts(tenantId, userId, parsed.rows, dto.mapping, dto.duplicateStrategy ?? DuplicateStrategy.SKIP)
: await this.importCompanies(tenantId, userId, parsed.rows, dto.mapping, dto.duplicateStrategy ?? DuplicateStrategy.SKIP);
return result;
} finally {
// GDPR: Temp-Datei immer loeschen
try {
fs.unlinkSync(tmpPath);
this.logger.debug(`Temp-Datei geloescht: ${tmpPath}`);
} catch {
this.logger.warn(`Temp-Datei konnte nicht geloescht werden: ${tmpPath}`);
}
}
}
// --------------------------------------------------------
// Privat: CSV parsen
// --------------------------------------------------------
private parseCsv(buffer: Buffer, delimiter?: string): Promise<ParsedFile> {
return new Promise((resolve, reject) => {
const rows: Record<string, string>[] = [];
let columns: string[] = [];
const stream = Readable.from(buffer);
stream
.pipe(csvParser({
separator: delimiter || undefined,
mapHeaders: ({ header }: { header: string }) => header.trim(),
}))
.on('headers', (headers: string[]) => {
columns = headers;
})
.on('data', (row: Record<string, string>) => {
if (rows.length < MAX_ROWS) {
rows.push(row);
}
})
.on('end', () => {
resolve({ columns, rows, totalRows: rows.length, format: 'csv' });
})
.on('error', (err: Error) => {
reject(new BadRequestException(`CSV-Parse-Fehler: ${err.message}`));
});
});
}
// --------------------------------------------------------
// Privat: Excel parsen
// --------------------------------------------------------
private parseExcel(buffer: Buffer): ParsedFile {
const workbook = XLSX.read(buffer, { type: 'buffer' });
const sheetName = workbook.SheetNames[0];
if (!sheetName) {
throw new BadRequestException('Excel-Datei enthaelt keine Arbeitsblaetter');
}
const sheet = workbook.Sheets[sheetName];
const jsonData = XLSX.utils.sheet_to_json<Record<string, string>>(sheet, {
defval: '',
raw: false,
});
if (jsonData.length === 0) {
throw new BadRequestException('Excel-Datei enthaelt keine Daten');
}
const columns = Object.keys(jsonData[0]);
const rows = jsonData.slice(0, MAX_ROWS);
return { columns, rows, totalRows: jsonData.length, format: 'xlsx' };
}
// --------------------------------------------------------
// Privat: Mapping validieren
// --------------------------------------------------------
private validateMapping(
mapping: FieldMappingDto[],
entityType: ImportEntityType,
): void {
const allowed = TARGET_FIELDS[entityType];
for (const m of mapping) {
if (!allowed.includes(m.targetField)) {
throw new BadRequestException(
`Ungueltiges Zielfeld "${m.targetField}" fuer ${entityType}. Erlaubt: ${allowed.join(', ')}`,
);
}
}
}
// --------------------------------------------------------
// Privat: Kontakte importieren
// --------------------------------------------------------
private async importContacts(
tenantId: string,
userId: string,
rows: Record<string, string>[],
mapping: FieldMappingDto[],
duplicateStrategy: DuplicateStrategy,
) {
let created = 0;
let updated = 0;
let skipped = 0;
const errors: ImportError[] = [];
for (let i = 0; i < rows.length; i++) {
const rowNum = i + 2; // +2 weil Header = Zeile 1
try {
const mapped = this.mapRow(rows[i], mapping);
const email = (mapped['email'] ?? '').trim().toLowerCase();
// Duplikat pruefen
let existingId: string | null = null;
if (email) {
const existing = await this.prisma.contact.findFirst({
where: {
tenantId,
email: { equals: email, mode: 'insensitive' },
},
});
existingId = existing?.id ?? null;
}
if (existingId) {
switch (duplicateStrategy) {
case DuplicateStrategy.SKIP:
skipped++;
continue;
case DuplicateStrategy.UPDATE:
await this.prisma.contact.update({
where: { id: existingId },
data: {
...this.buildContactData(mapped),
updatedBy: userId,
},
});
updated++;
continue;
case DuplicateStrategy.MARK:
mapped['_isDuplicate'] = 'true';
break;
}
}
const tags = mapped['_isDuplicate'] === 'true' ? ['DUPLIKAT'] : [];
if (mapped['tags']) {
tags.push(...mapped['tags'].split(',').map((t: string) => t.trim()));
}
await this.prisma.contact.create({
data: {
tenantId,
createdBy: userId,
firstName: mapped['firstName'] ?? null,
lastName: mapped['lastName'] ?? null,
email: mapped['email'] ?? null,
phone: mapped['phone'] ?? null,
mobile: mapped['mobile'] ?? null,
companyName: mapped['companyName'] ?? null,
position: mapped['position'] ?? null,
department: mapped['department'] ?? null,
website: mapped['website'] ?? null,
linkedinUrl: mapped['linkedinUrl'] ?? null,
street: mapped['street'] ?? null,
zip: mapped['zip'] ?? null,
city: mapped['city'] ?? null,
state: mapped['state'] ?? null,
country: mapped['country'] ?? 'DE',
notes: mapped['notes'] ?? null,
tags,
source: 'IMPORT',
owners: {
create: { tenantId, userId, role: 'OWNER' },
},
},
});
created++;
} catch (err) {
errors.push({
row: rowNum,
field: '',
value: '',
message: err instanceof Error ? err.message : 'Unbekannter Fehler',
});
}
}
return {
created,
updated,
skipped,
errors: errors.length,
totalProcessed: rows.length,
errorDetails: errors.slice(0, 50),
};
}
// --------------------------------------------------------
// Privat: Unternehmen importieren
// --------------------------------------------------------
private async importCompanies(
tenantId: string,
userId: string,
rows: Record<string, string>[],
mapping: FieldMappingDto[],
duplicateStrategy: DuplicateStrategy,
) {
let created = 0;
let updated = 0;
let skipped = 0;
const errors: ImportError[] = [];
for (let i = 0; i < rows.length; i++) {
const rowNum = i + 2;
try {
const mapped = this.mapRow(rows[i], mapping);
const email = (mapped['email'] ?? '').trim().toLowerCase();
let existingId: string | null = null;
if (email) {
const existing = await this.prisma.company.findFirst({
where: {
tenantId,
email: { equals: email, mode: 'insensitive' },
},
});
existingId = existing?.id ?? null;
}
if (existingId) {
switch (duplicateStrategy) {
case DuplicateStrategy.SKIP:
skipped++;
continue;
case DuplicateStrategy.UPDATE:
await this.prisma.company.update({
where: { id: existingId },
data: {
...this.buildCompanyData(mapped),
updatedBy: userId,
},
});
updated++;
continue;
case DuplicateStrategy.MARK:
mapped['_isDuplicate'] = 'true';
break;
}
}
const name = mapped['name'] ?? '';
if (!name) {
errors.push({
row: rowNum,
field: 'name',
value: '',
message: 'Unternehmensname ist Pflichtfeld',
});
continue;
}
const tags = mapped['_isDuplicate'] === 'true' ? ['DUPLIKAT'] : [];
if (mapped['tags']) {
tags.push(...mapped['tags'].split(',').map((t: string) => t.trim()));
}
await this.prisma.company.create({
data: {
tenantId,
createdBy: userId,
name,
email: mapped['email'] ?? null,
phone: mapped['phone'] ?? null,
website: mapped['website'] ?? null,
industry: mapped['industry'] ?? null,
vatId: mapped['vatId'] ?? null,
taxId: mapped['taxId'] ?? null,
tradeRegisterNumber: mapped['tradeRegisterNumber'] ?? null,
registerCourt: mapped['registerCourt'] ?? null,
street: mapped['street'] ?? null,
zip: mapped['zip'] ?? null,
city: mapped['city'] ?? null,
state: mapped['state'] ?? null,
country: mapped['country'] ?? 'DE',
notes: mapped['notes'] ?? null,
tags,
owners: {
create: { tenantId, userId, role: 'OWNER' },
},
},
});
created++;
} catch (err) {
errors.push({
row: rowNum,
field: '',
value: '',
message: err instanceof Error ? err.message : 'Unbekannter Fehler',
});
}
}
return {
created,
updated,
skipped,
errors: errors.length,
totalProcessed: rows.length,
errorDetails: errors.slice(0, 50),
};
}
// --------------------------------------------------------
// Helfer
// --------------------------------------------------------
private mapRow(
row: Record<string, string>,
mapping: FieldMappingDto[],
): Record<string, string> {
const result: Record<string, string> = {};
for (const m of mapping) {
const value = row[m.sourceColumn];
if (value !== undefined && value !== '') {
result[m.targetField] = value;
}
}
return result;
}
private buildContactData(mapped: Record<string, string>) {
return {
...(mapped['firstName'] && { firstName: mapped['firstName'] }),
...(mapped['lastName'] && { lastName: mapped['lastName'] }),
...(mapped['email'] && { email: mapped['email'] }),
...(mapped['phone'] && { phone: mapped['phone'] }),
...(mapped['mobile'] && { mobile: mapped['mobile'] }),
...(mapped['companyName'] && { companyName: mapped['companyName'] }),
...(mapped['position'] && { position: mapped['position'] }),
...(mapped['department'] && { department: mapped['department'] }),
...(mapped['website'] && { website: mapped['website'] }),
...(mapped['linkedinUrl'] && { linkedinUrl: mapped['linkedinUrl'] }),
...(mapped['street'] && { street: mapped['street'] }),
...(mapped['zip'] && { zip: mapped['zip'] }),
...(mapped['city'] && { city: mapped['city'] }),
...(mapped['state'] && { state: mapped['state'] }),
...(mapped['country'] && { country: mapped['country'] }),
...(mapped['notes'] && { notes: mapped['notes'] }),
};
}
private buildCompanyData(mapped: Record<string, string>) {
return {
...(mapped['name'] && { name: mapped['name'] }),
...(mapped['email'] && { email: mapped['email'] }),
...(mapped['phone'] && { phone: mapped['phone'] }),
...(mapped['website'] && { website: mapped['website'] }),
...(mapped['industry'] && { industry: mapped['industry'] }),
...(mapped['vatId'] && { vatId: mapped['vatId'] }),
...(mapped['taxId'] && { taxId: mapped['taxId'] }),
...(mapped['tradeRegisterNumber'] && { tradeRegisterNumber: mapped['tradeRegisterNumber'] }),
...(mapped['registerCourt'] && { registerCourt: mapped['registerCourt'] }),
...(mapped['street'] && { street: mapped['street'] }),
...(mapped['zip'] && { zip: mapped['zip'] }),
...(mapped['city'] && { city: mapped['city'] }),
...(mapped['state'] && { state: mapped['state'] }),
...(mapped['country'] && { country: mapped['country'] }),
...(mapped['notes'] && { notes: mapped['notes'] }),
};
}
}

View file

@ -5,7 +5,9 @@ import {
IsArray, IsArray,
ValidateNested, ValidateNested,
IsInt, IsInt,
IsNumber,
Min, Min,
Max,
MaxLength, MaxLength,
Matches, Matches,
} from 'class-validator'; } from 'class-validator';
@ -29,6 +31,18 @@ export class CreatePipelineStageDto {
@IsString() @IsString()
@Matches(/^#[0-9A-Fa-f]{6}$/) @Matches(/^#[0-9A-Fa-f]{6}$/)
color?: string; color?: string;
@ApiPropertyOptional({
default: 0,
description: 'Abschlusswahrscheinlichkeit (0.00 1.00)',
minimum: 0,
maximum: 1,
})
@IsOptional()
@IsNumber({ maxDecimalPlaces: 2 })
@Min(0)
@Max(1)
probability?: number;
} }
export class CreatePipelineDto { export class CreatePipelineDto {

View file

@ -2,7 +2,9 @@ import {
IsString, IsString,
IsOptional, IsOptional,
IsInt, IsInt,
IsNumber,
Min, Min,
Max,
MaxLength, MaxLength,
Matches, Matches,
} from 'class-validator'; } from 'class-validator';
@ -26,4 +28,15 @@ export class UpdateStageDto {
@IsString() @IsString()
@Matches(/^#[0-9A-Fa-f]{6}$/) @Matches(/^#[0-9A-Fa-f]{6}$/)
color?: string; color?: string;
@ApiPropertyOptional({
description: 'Abschlusswahrscheinlichkeit (0.00 1.00)',
minimum: 0,
maximum: 1,
})
@IsOptional()
@IsNumber({ maxDecimalPlaces: 2 })
@Min(0)
@Max(1)
probability?: number;
} }

View file

@ -113,6 +113,7 @@ export class PipelinesController {
dto.name, dto.name,
dto.sortOrder ?? 0, dto.sortOrder ?? 0,
dto.color, dto.color,
dto.probability,
); );
return singleResponse(stage); return singleResponse(stage);
} }

View file

@ -21,6 +21,7 @@ export class PipelinesService {
name: stage.name, name: stage.name,
sortOrder: stage.sortOrder ?? index, sortOrder: stage.sortOrder ?? index,
color: stage.color ?? '#6B7280', color: stage.color ?? '#6B7280',
probability: stage.probability ?? 0,
})), })),
} }
: undefined, : undefined,
@ -86,6 +87,7 @@ export class PipelinesService {
name: string, name: string,
sortOrder: number, sortOrder: number,
color?: string, color?: string,
probability?: number,
) { ) {
await this.findOne(tenantId, pipelineId); await this.findOne(tenantId, pipelineId);
@ -95,6 +97,7 @@ export class PipelinesService {
name, name,
sortOrder, sortOrder,
color: color ?? '#6B7280', color: color ?? '#6B7280',
probability: probability ?? 0,
}, },
}); });
} }
@ -121,6 +124,7 @@ export class PipelinesService {
...(dto.name !== undefined && { name: dto.name }), ...(dto.name !== undefined && { name: dto.name }),
...(dto.sortOrder !== undefined && { sortOrder: dto.sortOrder }), ...(dto.sortOrder !== undefined && { sortOrder: dto.sortOrder }),
...(dto.color !== undefined && { color: dto.color }), ...(dto.color !== undefined && { color: dto.color }),
...(dto.probability !== undefined && { probability: dto.probability }),
}, },
}); });
} }