mirror of
http://172.20.10.11:3000/gitadmin/INSIGHT-MVP.git
synced 2026-06-24 23:56:40 +02:00
feat(crm): Phase 1 backend schema expansion + frontend integration
Backend (CRM-Expert Phase 1): - New enums: ContactSource, EntityStatus, CompanySize, OwnerRole, LostReason, EmailType, PhoneType - Contact: add linkedinUrl, birthday, source, department, status - Company: add vatId, taxId, tradeRegisterNumber, registerCourt, companySize, deliveryAddress, dataEnrichedAt/Source, status - Deal: add lostReason + lostReasonText (required when status=LOST) - Multi-value emails/phones tables (contact_emails, contact_phones) - Owner m:n model (contact_owners, company_owners, deal_owners) - Redis Pub/Sub CRM events (crm.contact.created, crm.deal.won, etc.) - Activity due_soon scheduler (cron every 15 min) - SQL migration with data migration for existing records Frontend integration: - types.ts: all new enums, interfaces, label maps - api.ts: owner CRUD endpoints (add/remove for contacts/companies/deals) - hooks.ts: 6 new owner mutation hooks - ContactFormModal: LinkedIn, birthday, source, department, status fields - ContactDetailPage: display new fields (LinkedIn, department, birthday, source, status badge) - CompanyDetailPage: display vatId, taxId, trade register, company size, delivery address, data enrichment info - DealFormModal: lost reason dropdown + text (shown when status=LOST) - DealDetailPage: display lost reason with label - CompaniesPage: EntityStatus-aware status dots (ACTIVE/INACTIVE/BLOCKED) - ActivityType: add FOLLOWUP to all label maps Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
7d8847fafa
commit
48df3c3144
41 changed files with 2818 additions and 240 deletions
|
|
@ -1649,4 +1649,127 @@ Format: `## YYYY-MM-DD | CRM-Backend: [Betreff]`
|
|||
|
||||
---
|
||||
|
||||
## 2026-03-12 | CRM-Backend: Phase 1 — Schema-Expansion, Owner, Lost-Reason, Events
|
||||
|
||||
### Zusammenfassung
|
||||
|
||||
Umsetzung der Phase 1 ("SOFORT") gemaess Architektur-Briefing. Alle Arbeitspakete ohne externe Abhaengigkeiten implementiert.
|
||||
|
||||
### 1. Prisma Schema + SQL Migration
|
||||
|
||||
**Neue Enums (7):** `ContactSource`, `EntityStatus`, `CompanySize`, `OwnerRole`, `LostReason`, `EmailType`, `PhoneType` + `FOLLOWUP` zu ActivityType.
|
||||
|
||||
**Neue Felder auf Contact:** `linkedinUrl`, `birthday`, `source` (ContactSource), `department`, `status` (EntityStatus).
|
||||
|
||||
**Neue Felder auf Company:** `vatId`, `taxId`, `tradeRegisterNumber`, `registerCourt`, `companySize`, `deliveryStreet/Zip/City/Country`, `dataEnrichedAt`, `dataEnrichedSource`, `status` (EntityStatus).
|
||||
|
||||
**Neue Felder auf Deal:** `lostReason` (LostReason), `lostReasonText`.
|
||||
|
||||
**Neue Tabellen (5):** `contact_emails`, `contact_phones`, `contact_owners`, `company_owners`, `deal_owners`.
|
||||
|
||||
**Migration:** `prisma/migrations/20260312_phase1_schema_expansion/migration.sql` — inkl. Daten-Migration (isActive→status, email/phone→contact_emails/phones, ownerId→company_owners).
|
||||
|
||||
### 2. Multi-Value E-Mails & Telefone (Breaking Change)
|
||||
|
||||
Bisherige Einzel-Felder `email`, `phone`, `mobile` bleiben als **deprecated Legacy** erhalten. Neue Multi-Value Tabellen `contact_emails` und `contact_phones` dienen als primaere Datenquelle. Legacy-Felder werden automatisch aus Primary-Eintraegen synchronisiert.
|
||||
|
||||
**Neue Endpoints/Verhalten:**
|
||||
- `POST /contacts` akzeptiert jetzt `emails: [{email, type, isPrimary}]` und `phones: [{phone, type, isPrimary}]`
|
||||
- `PATCH /contacts/:id` mit Replace-Strategie (delete all + recreate)
|
||||
- Analog fuer Companies
|
||||
- Lexware-Import erzeugt automatisch Multi-Value Eintraege
|
||||
- `status` Feld (ACTIVE/INACTIVE/BLOCKED) ersetzt `isActive` Boolean — beide werden synchron gehalten
|
||||
|
||||
**DTOs:**
|
||||
- `CreateEmailDto`: `{email, type?: EmailType, isPrimary?: boolean}`
|
||||
- `CreatePhoneDto`: `{phone, type?: PhoneType, isPrimary?: boolean}`
|
||||
- `EntityStatus`: ACTIVE, INACTIVE, BLOCKED
|
||||
- `CompanySize`: SIZE_1_10, SIZE_11_50, SIZE_51_200, SIZE_201_500, SIZE_500_PLUS
|
||||
- `ContactSource`: TRADE_FAIR, REFERRAL, WEBSITE, COLD_CALL, IMPORT, BUSINESS_CARD, OTHER
|
||||
|
||||
### 3. Owner m:n Modell
|
||||
|
||||
Jeder Contact, Company und Deal hat jetzt ein mehrstufiges Ownership-Modell mit Rollen.
|
||||
|
||||
**Neue Endpoints:**
|
||||
```
|
||||
POST /crm/contacts/:id/owners Body: {userId, role?}
|
||||
DELETE /crm/contacts/:id/owners/:userId
|
||||
|
||||
POST /crm/companies/:id/owners Body: {userId, role?}
|
||||
DELETE /crm/companies/:id/owners/:userId
|
||||
|
||||
POST /crm/deals/:id/owners Body: {userId, role?}
|
||||
DELETE /crm/deals/:id/owners/:userId
|
||||
```
|
||||
|
||||
**OwnerRole Enum:** OWNER, MEMBER, WATCHER.
|
||||
|
||||
Bei `create()` wird der erstellende User automatisch als OWNER eingetragen. Upsert-Verhalten: Wenn Owner bereits existiert, wird nur die Rolle aktualisiert.
|
||||
|
||||
### 4. Lost-Reason bei Deals
|
||||
|
||||
**Neue Felder:** `lostReason` (Enum: PRICE, TIMING, COMPETITOR, NO_NEED, OTHER), `lostReasonText` (Freitext).
|
||||
|
||||
**Validierung:**
|
||||
- `PATCH /deals/:id` mit `status: 'LOST'` erfordert `lostReason` (im DTO oder bereits gesetzt) — sonst 400 BadRequest.
|
||||
- `PATCH /deals/:id` mit `status: 'WON'` loescht automatisch `lostReason` und `lostReasonText`.
|
||||
|
||||
### 5. Redis Pub/Sub Events
|
||||
|
||||
**CrmEventPublisher** (global verfuegbar): Publiziert Events auf Redis Channels im Format `app:crm:events:{type}`.
|
||||
|
||||
**Event-Typen:**
|
||||
- `crm.contact.created` — nach Contact-Erstellung
|
||||
- `crm.contact.updated` — nach Contact-Update
|
||||
- `crm.deal.created` — nach Deal-Erstellung
|
||||
- `crm.deal.stage_changed` — bei Stage-Aenderung (payload: previousStageId, newStageId)
|
||||
- `crm.deal.won` — bei Deal WON (payload: value, currency)
|
||||
- `crm.deal.lost` — bei Deal LOST (payload: lostReason)
|
||||
- `crm.activity.due_soon` — Activities faellig in 24h (Cron alle 15 Min)
|
||||
|
||||
**Event-Payload:**
|
||||
```json
|
||||
{
|
||||
"type": "crm.contact.created",
|
||||
"tenantId": "uuid",
|
||||
"entityId": "uuid",
|
||||
"userId": "uuid",
|
||||
"timestamp": "ISO-8601",
|
||||
"payload": {}
|
||||
}
|
||||
```
|
||||
|
||||
### Response-Aenderungen fuer Frontend
|
||||
|
||||
Alle Entity-Responses (Contact, Company, Deal) enthalten jetzt zusaetzlich:
|
||||
- `emails: [{id, email, type, isPrimary, createdAt}]`
|
||||
- `phones: [{id, phone, type, isPrimary, createdAt}]`
|
||||
- `owners: [{id, tenantId, userId, role, createdAt}]`
|
||||
- `status: 'ACTIVE' | 'INACTIVE' | 'BLOCKED'`
|
||||
|
||||
Contact zusaetzlich: `linkedinUrl`, `birthday`, `source`, `department`.
|
||||
Company zusaetzlich: `vatId`, `taxId`, `tradeRegisterNumber`, `registerCourt`, `companySize`, `deliveryStreet/Zip/City/Country`.
|
||||
Deal zusaetzlich: `lostReason`, `lostReasonText`.
|
||||
|
||||
### Geaenderte Dateien (18) + Neue Dateien (10)
|
||||
|
||||
Siehe Plan-Datei fuer komplette Dateiliste. Wichtigste neue Dateien:
|
||||
- `src/common/dto/contact-info.dto.ts` — Shared DTOs + Enums
|
||||
- `src/common/dto/owner.dto.ts` — AddOwnerDto + OwnerRole
|
||||
- `src/owners/owners.service.ts` + `owners.module.ts` — Shared Owner CRUD
|
||||
- `src/events/crm-event-publisher.service.ts` — CRM Event Publisher
|
||||
- `src/events/crm-events.module.ts` — Global Events Module
|
||||
- `src/events/activity-due-soon.scheduler.ts` — Cron-Job
|
||||
|
||||
### TODO fuer Frontend
|
||||
|
||||
1. `types.ts` um neue Felder erweitern (emails[], phones[], owners[], status, linkedinUrl, birthday, source, department, lostReason, etc.)
|
||||
2. Contact/Company Formulare um Multi-Value Email/Phone Eingabe erweitern
|
||||
3. Owner-Management UI (Zuweisen/Entfernen von Owners)
|
||||
4. Deal-Detail: Lost-Reason Feld bei Status LOST einblenden
|
||||
5. EntityStatus Filter in Listen verwenden (statt isActive)
|
||||
|
||||
---
|
||||
|
||||
*Bitte neue Eintraege unten anfuegen. Format: `## YYYY-MM-DD | Absender: Betreff`*
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# CRM-Service - Zusammenfassung
|
||||
|
||||
## Stand: 2026-03-11
|
||||
## Stand: 2026-03-12
|
||||
|
||||
### Was wurde erstellt
|
||||
|
||||
|
|
@ -25,12 +25,14 @@ packages/crm-service/
|
|||
prisma/ — CrmPrismaService (eigener Client)
|
||||
redis/ — RedisService (Token-Blocklist, Cache, Distributed Locks)
|
||||
auth/ — JWT Strategy (RS256), JwtAuthGuard, RolesGuard, TenantGuard
|
||||
common/ — Decorators (@Public, @Roles, @CurrentUser), Pagination, ExceptionFilter
|
||||
companies/ — CRUD: Unternehmen (mit Lexware ERP-Push, industryId, accountTypeId, ownerId)
|
||||
contacts/ — CRUD: Kontakte (mit Lexware ERP-Push bei Update)
|
||||
activities/ — CRUD: Aktivitaeten (NOTE, CALL, EMAIL, MEETING, TASK; contactId+companyId optional)
|
||||
common/ — Decorators (@Public, @Roles, @CurrentUser), Pagination, ExceptionFilter, Shared DTOs (contact-info, owner)
|
||||
companies/ — CRUD: Unternehmen (Multi-Value emails/phones, Owner m:n, Status, Lexware ERP-Push)
|
||||
contacts/ — CRUD: Kontakte (Multi-Value emails/phones, Owner m:n, Status, Events)
|
||||
activities/ — CRUD: Aktivitaeten (NOTE, CALL, EMAIL, MEETING, TASK, FOLLOWUP; contactId+companyId optional)
|
||||
pipelines/ — CRUD: Sales-Pipelines mit Stages (inkl. Stage-Update)
|
||||
deals/ — CRUD: Vorgaenge mit Pipeline/Stage/Contact/Company + DealVouchers
|
||||
deals/ — CRUD: Vorgaenge mit Pipeline/Stage/Contact/Company + DealVouchers + LostReason + Owner m:n + Events
|
||||
owners/ — Shared Owner-Service (Contact/Company/Deal Owners, Upsert, Rollen)
|
||||
events/ — CRM Event Publisher (Redis Pub/Sub) + Activity Due-Soon Scheduler
|
||||
industries/ — CRUD: Branchen (admin-konfigurierbar, mit Farbe)
|
||||
account-types/ — CRUD: Kontotypen (admin-konfigurierbar)
|
||||
relationship-types/ — CRUD: Beziehungstypen (admin-konfigurierbar)
|
||||
|
|
@ -53,12 +55,17 @@ packages/crm-service/
|
|||
|
||||
### Datenbank-Modelle (app_crm Schema)
|
||||
|
||||
- **Company** — Unternehmen mit industryId, accountTypeId, ownerId, Lexware-Verknuepfung
|
||||
- **Contact** — Kontakte mit optionaler Lexware-Verknuepfung
|
||||
- **Activity** — Aktivitaeten verknuepft mit Kontakten UND/ODER Companies (contactId + companyId beide optional, min. 1)
|
||||
- **Company** — Unternehmen mit industryId, accountTypeId, Multi-Value emails/phones, Owner m:n, EntityStatus, vatId/taxId/tradeRegisterNumber/companySize, 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
|
||||
- **Pipeline** — Konfigurierbare Sales-Pipelines pro Tenant
|
||||
- **PipelineStage** — Stufen innerhalb einer Pipeline
|
||||
- **Deal** — Vorgaenge mit dealVouchers-Relation zu Lexware-Belegen
|
||||
- **Deal** — Vorgaenge mit dealVouchers-Relation, Owner m:n, LostReason/LostReasonText, Events
|
||||
- **ContactEmail** — Multi-Value E-Mail-Adressen (Contact/Company, Typ: WORK/PERSONAL/OTHER)
|
||||
- **ContactPhone** — Multi-Value Telefonnummern (Contact/Company, Typ: OFFICE/MOBILE/FAX)
|
||||
- **ContactOwner** — Owner m:n fuer Contacts (Rollen: OWNER/MEMBER/WATCHER)
|
||||
- **CompanyOwner** — Owner m:n fuer Companies
|
||||
- **DealOwner** — Owner m:n fuer Deals
|
||||
- **Industry** — Admin-konfigurierbare Branchen mit Farbe (unique pro Tenant)
|
||||
- **AccountType** — Admin-konfigurierbare Kontotypen (unique pro Tenant)
|
||||
- **RelationshipType** — Admin-konfigurierbare Beziehungstypen (unique pro Tenant)
|
||||
|
|
@ -97,6 +104,12 @@ RelationshipType (1) --< (n) CompanyRelationship — relationshipTypeId
|
|||
| GET/PATCH/DELETE | /api/v1/crm/companies/:id | Detail / Update / Delete |
|
||||
| GET/POST | /api/v1/crm/contacts | Liste / Erstellen |
|
||||
| GET/PATCH/DELETE | /api/v1/crm/contacts/:id | Detail / Update / Delete |
|
||||
| POST | /api/v1/crm/contacts/:id/owners | Owner hinzufuegen |
|
||||
| DELETE | /api/v1/crm/contacts/:id/owners/:userId | Owner entfernen |
|
||||
| POST | /api/v1/crm/companies/:id/owners | Owner hinzufuegen |
|
||||
| DELETE | /api/v1/crm/companies/:id/owners/:userId | Owner entfernen |
|
||||
| POST | /api/v1/crm/deals/:id/owners | Owner hinzufuegen |
|
||||
| DELETE | /api/v1/crm/deals/:id/owners/:userId | Owner entfernen |
|
||||
| GET/POST | /api/v1/crm/activities | Liste / Erstellen (companyId+includeContacts fuer aggregierten Feed) |
|
||||
| GET/PATCH/DELETE | /api/v1/crm/activities/:id | Detail / Update / Delete |
|
||||
| GET/POST | /api/v1/crm/industries | Branchen verwalten |
|
||||
|
|
@ -162,20 +175,24 @@ RelationshipType (1) --< (n) CompanyRelationship — relationshipTypeId
|
|||
|
||||
**Erfolgreich deployed auf insight-dev-01 (172.20.10.59) am 2026-03-10**
|
||||
|
||||
- Prisma Migrationen:
|
||||
- `20260310163211_init` — Initiales Schema
|
||||
- `20260310183117_add_companies` — Company-Entity
|
||||
- `20260310_add_lexware_integration` — Lexware Office Integration
|
||||
- `20260311_add_company_detail_overhaul` — Company Detail Overhaul (Industry, AccountType, RelationshipType, CompanyRelationship, Contract, Activity companyId)
|
||||
- Prisma Migrationen: siehe Abschnitt "Migrationen" oben
|
||||
|
||||
### Migrationen
|
||||
|
||||
- `20260310163211_init` — Initiales Schema
|
||||
- `20260310183117_add_companies` — Company-Entity
|
||||
- `20260310_add_lexware_integration` — Lexware Office Integration
|
||||
- `20260311_add_company_detail_overhaul` — Company Detail Overhaul
|
||||
- `20260312_phase1_schema_expansion` — Phase 1: Enums, Multi-Value, Owner, LostReason
|
||||
|
||||
### Naechste Schritte
|
||||
|
||||
1. Migration `20260311_add_company_detail_overhaul` auf Server anwenden
|
||||
2. Seed-Data (Industries, AccountTypes, RelationshipTypes) ausfuehren
|
||||
3. Container neu bauen und deployen
|
||||
4. Frontend testen: CompanyDetailPage 3-Spalten Layout
|
||||
5. CRM-Einstellungen: Branchen/Kontotypen/Beziehungstypen verwalten
|
||||
6. CompanyFormModal: Dropdowns testen
|
||||
7. Activity Feed: Aggregierten Feed testen
|
||||
8. Kanban-Board fuer Vorgaenge
|
||||
9. Vertraege-UI implementieren (DB-Modell bereits vorhanden)
|
||||
1. Migration `20260312_phase1_schema_expansion` auf Server anwenden
|
||||
2. Container neu bauen und deployen
|
||||
3. Frontend: Multi-Value Email/Phone UI implementieren
|
||||
4. Frontend: Owner-Management UI
|
||||
5. Frontend: EntityStatus statt isActive verwenden
|
||||
6. Frontend: LostReason bei Deal-Verlust einblenden
|
||||
7. Phase 2: Office 365 E-Mail Integration (Planer-Briefing vorhanden)
|
||||
8. Phase 3: Kontakt-Zusammenfuehrung (Merge)
|
||||
9. Phase 4: Aktivitaets-Erweiterung + Dashboard Widgets
|
||||
|
|
|
|||
|
|
@ -91,23 +91,42 @@ model Company {
|
|||
ownerId String? @map("owner_id") @db.Uuid
|
||||
ownerName String? @map("owner_name") @db.VarChar(200)
|
||||
|
||||
// Kontaktdaten
|
||||
// Phase 1: Registerdaten
|
||||
vatId String? @map("vat_id") @db.VarChar(50)
|
||||
taxId String? @map("tax_id") @db.VarChar(50)
|
||||
tradeRegisterNumber String? @map("trade_register_number") @db.VarChar(100)
|
||||
registerCourt String? @map("register_court") @db.VarChar(200)
|
||||
companySize CompanySize? @map("company_size")
|
||||
|
||||
// Kontaktdaten (Legacy-Einzelfelder, deprecated — siehe emails[]/phones[])
|
||||
email String? @db.VarChar(255)
|
||||
phone String? @db.VarChar(50)
|
||||
website String? @db.VarChar(500)
|
||||
|
||||
// Adresse
|
||||
// Adresse (Hauptsitz)
|
||||
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)
|
||||
|
||||
// Adresse (Lieferung)
|
||||
deliveryStreet String? @map("delivery_street") @db.VarChar(200)
|
||||
deliveryZip String? @map("delivery_zip") @db.VarChar(20)
|
||||
deliveryCity String? @map("delivery_city") @db.VarChar(100)
|
||||
deliveryCountry String? @map("delivery_country") @db.VarChar(2)
|
||||
|
||||
// Zusaetzlich
|
||||
notes String? @db.Text
|
||||
tags String[] @default([])
|
||||
|
||||
isActive Boolean @default(true) @map("is_active")
|
||||
// Status (Phase 1: EntityStatus Enum ersetzt isActive)
|
||||
status EntityStatus @default(ACTIVE)
|
||||
isActive Boolean @default(true) @map("is_active") // DEPRECATED: Synchron gehalten
|
||||
|
||||
// Datenanreicherung
|
||||
dataEnrichedAt DateTime? @map("data_enriched_at")
|
||||
dataEnrichedSource String? @map("data_enriched_source") @db.VarChar(200)
|
||||
|
||||
// Lexware Office Integration
|
||||
lexwareContactId String? @map("lexware_contact_id") @db.VarChar(36)
|
||||
|
|
@ -131,6 +150,9 @@ model Company {
|
|||
relationships CompanyRelationship[] @relation("companyRelationships")
|
||||
relatedRelationships CompanyRelationship[] @relation("relatedCompanyRelationships")
|
||||
contracts Contract[]
|
||||
emails ContactEmail[]
|
||||
phones ContactPhone[]
|
||||
owners CompanyOwner[]
|
||||
|
||||
@@unique([tenantId, lexwareContactId])
|
||||
@@index([tenantId])
|
||||
|
|
@ -139,6 +161,7 @@ model Company {
|
|||
@@index([tenantId, industryId])
|
||||
@@index([tenantId, accountTypeId])
|
||||
@@index([tenantId, isActive])
|
||||
@@index([tenantId, status])
|
||||
@@map("companies")
|
||||
@@schema("app_crm")
|
||||
}
|
||||
|
|
@ -160,12 +183,18 @@ model Contact {
|
|||
companyName String? @map("company_name") @db.VarChar(200)
|
||||
position String? @db.VarChar(200)
|
||||
|
||||
// Kontaktdaten
|
||||
// Kontaktdaten (Legacy-Einzelfelder, deprecated — siehe emails[]/phones[])
|
||||
email String? @db.VarChar(255)
|
||||
phone String? @db.VarChar(50)
|
||||
mobile String? @db.VarChar(50)
|
||||
website String? @db.VarChar(500)
|
||||
|
||||
// Phase 1: Neue Felder
|
||||
linkedinUrl String? @map("linkedin_url") @db.VarChar(500)
|
||||
birthday DateTime?
|
||||
source ContactSource?
|
||||
department String? @db.VarChar(200)
|
||||
|
||||
// Adresse
|
||||
street String? @db.VarChar(200)
|
||||
zip String? @db.VarChar(20)
|
||||
|
|
@ -177,7 +206,9 @@ model Contact {
|
|||
notes String? @db.Text
|
||||
tags String[] @default([])
|
||||
|
||||
isActive Boolean @default(true) @map("is_active")
|
||||
// Status (Phase 1: EntityStatus Enum ersetzt isActive)
|
||||
status EntityStatus @default(ACTIVE)
|
||||
isActive Boolean @default(true) @map("is_active") // DEPRECATED: Synchron gehalten
|
||||
|
||||
// Lexware Office Integration
|
||||
lexwareContactId String? @map("lexware_contact_id") @db.VarChar(36)
|
||||
|
|
@ -192,10 +223,13 @@ model Contact {
|
|||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
// Relationen
|
||||
company Company? @relation(fields: [companyId], references: [id], onDelete: SetNull)
|
||||
company Company? @relation(fields: [companyId], references: [id], onDelete: SetNull)
|
||||
activities Activity[]
|
||||
deals Deal[]
|
||||
lexwareVouchers LexwareVoucher[]
|
||||
emails ContactEmail[]
|
||||
phones ContactPhone[]
|
||||
owners ContactOwner[]
|
||||
|
||||
@@unique([tenantId, lexwareContactId])
|
||||
@@index([tenantId])
|
||||
|
|
@ -204,6 +238,7 @@ model Contact {
|
|||
@@index([tenantId, companyName])
|
||||
@@index([tenantId, lastName, firstName])
|
||||
@@index([tenantId, isActive])
|
||||
@@index([tenantId, status])
|
||||
@@map("contacts")
|
||||
@@schema("app_crm")
|
||||
}
|
||||
|
|
@ -257,6 +292,74 @@ enum ActivityType {
|
|||
EMAIL
|
||||
MEETING
|
||||
TASK
|
||||
FOLLOWUP
|
||||
|
||||
@@schema("app_crm")
|
||||
}
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Phase 1 Enums — Kontakt-Herkunft, Status, Owner, etc.
|
||||
// --------------------------------------------------------
|
||||
enum ContactSource {
|
||||
TRADE_FAIR
|
||||
REFERRAL
|
||||
WEBSITE
|
||||
COLD_CALL
|
||||
IMPORT
|
||||
BUSINESS_CARD
|
||||
OTHER
|
||||
|
||||
@@schema("app_crm")
|
||||
}
|
||||
|
||||
enum EntityStatus {
|
||||
ACTIVE
|
||||
INACTIVE
|
||||
BLOCKED
|
||||
|
||||
@@schema("app_crm")
|
||||
}
|
||||
|
||||
enum CompanySize {
|
||||
SIZE_1_10
|
||||
SIZE_11_50
|
||||
SIZE_51_200
|
||||
SIZE_201_500
|
||||
SIZE_500_PLUS
|
||||
|
||||
@@schema("app_crm")
|
||||
}
|
||||
|
||||
enum OwnerRole {
|
||||
OWNER
|
||||
MEMBER
|
||||
WATCHER
|
||||
|
||||
@@schema("app_crm")
|
||||
}
|
||||
|
||||
enum LostReason {
|
||||
PRICE
|
||||
TIMING
|
||||
COMPETITOR
|
||||
NO_NEED
|
||||
OTHER
|
||||
|
||||
@@schema("app_crm")
|
||||
}
|
||||
|
||||
enum EmailType {
|
||||
WORK
|
||||
PERSONAL
|
||||
OTHER
|
||||
|
||||
@@schema("app_crm")
|
||||
}
|
||||
|
||||
enum PhoneType {
|
||||
OFFICE
|
||||
MOBILE
|
||||
FAX
|
||||
|
||||
@@schema("app_crm")
|
||||
}
|
||||
|
|
@ -392,6 +495,10 @@ model Deal {
|
|||
closedAt DateTime? @map("closed_at")
|
||||
notes String? @db.Text
|
||||
|
||||
// Phase 1: Lost-Reason
|
||||
lostReason LostReason? @map("lost_reason")
|
||||
lostReasonText String? @map("lost_reason_text") @db.Text
|
||||
|
||||
// Audit-Trail
|
||||
createdBy String @map("created_by") @db.Uuid
|
||||
updatedBy String? @map("updated_by") @db.Uuid
|
||||
|
|
@ -405,6 +512,7 @@ model Deal {
|
|||
contact Contact? @relation(fields: [contactId], references: [id], onDelete: SetNull)
|
||||
company Company? @relation(fields: [companyId], references: [id], onDelete: SetNull)
|
||||
dealVouchers DealVoucher[]
|
||||
owners DealOwner[]
|
||||
|
||||
@@index([tenantId])
|
||||
@@index([tenantId, pipelineId])
|
||||
|
|
@ -507,6 +615,108 @@ model DealVoucher {
|
|||
@@schema("app_crm")
|
||||
}
|
||||
|
||||
// --------------------------------------------------------
|
||||
// ContactEmail - Multi-Value E-Mail-Adressen (Phase 1)
|
||||
// --------------------------------------------------------
|
||||
model ContactEmail {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
contactId String? @map("contact_id") @db.Uuid
|
||||
companyId String? @map("company_id") @db.Uuid
|
||||
email String @db.VarChar(255)
|
||||
type EmailType @default(WORK)
|
||||
isPrimary Boolean @default(false) @map("is_primary")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
contact Contact? @relation(fields: [contactId], references: [id], onDelete: Cascade)
|
||||
company Company? @relation(fields: [companyId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([contactId])
|
||||
@@index([companyId])
|
||||
@@map("contact_emails")
|
||||
@@schema("app_crm")
|
||||
}
|
||||
|
||||
// --------------------------------------------------------
|
||||
// ContactPhone - Multi-Value Telefonnummern (Phase 1)
|
||||
// --------------------------------------------------------
|
||||
model ContactPhone {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
contactId String? @map("contact_id") @db.Uuid
|
||||
companyId String? @map("company_id") @db.Uuid
|
||||
phone String @db.VarChar(50)
|
||||
type PhoneType @default(OFFICE)
|
||||
isPrimary Boolean @default(false) @map("is_primary")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
contact Contact? @relation(fields: [contactId], references: [id], onDelete: Cascade)
|
||||
company Company? @relation(fields: [companyId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([contactId])
|
||||
@@index([companyId])
|
||||
@@map("contact_phones")
|
||||
@@schema("app_crm")
|
||||
}
|
||||
|
||||
// --------------------------------------------------------
|
||||
// ContactOwner - Zustaendige pro Kontakt (m:n, Phase 1)
|
||||
// --------------------------------------------------------
|
||||
model ContactOwner {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
tenantId String @map("tenant_id") @db.Uuid
|
||||
contactId String @map("contact_id") @db.Uuid
|
||||
userId String @map("user_id") @db.Uuid
|
||||
role OwnerRole @default(OWNER)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
contact Contact @relation(fields: [contactId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([contactId, userId])
|
||||
@@index([tenantId])
|
||||
@@index([tenantId, contactId])
|
||||
@@map("contact_owners")
|
||||
@@schema("app_crm")
|
||||
}
|
||||
|
||||
// --------------------------------------------------------
|
||||
// CompanyOwner - Zustaendige pro Unternehmen (m:n, Phase 1)
|
||||
// --------------------------------------------------------
|
||||
model CompanyOwner {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
tenantId String @map("tenant_id") @db.Uuid
|
||||
companyId String @map("company_id") @db.Uuid
|
||||
userId String @map("user_id") @db.Uuid
|
||||
role OwnerRole @default(OWNER)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([companyId, userId])
|
||||
@@index([tenantId])
|
||||
@@index([tenantId, companyId])
|
||||
@@map("company_owners")
|
||||
@@schema("app_crm")
|
||||
}
|
||||
|
||||
// --------------------------------------------------------
|
||||
// DealOwner - Zustaendige pro Deal (m:n, Phase 1)
|
||||
// --------------------------------------------------------
|
||||
model DealOwner {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
tenantId String @map("tenant_id") @db.Uuid
|
||||
dealId String @map("deal_id") @db.Uuid
|
||||
userId String @map("user_id") @db.Uuid
|
||||
role OwnerRole @default(OWNER)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
deal Deal @relation(fields: [dealId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([dealId, userId])
|
||||
@@index([tenantId])
|
||||
@@index([tenantId, dealId])
|
||||
@@map("deal_owners")
|
||||
@@schema("app_crm")
|
||||
}
|
||||
|
||||
// --------------------------------------------------------
|
||||
// TradeEvent - Messe-/Event-Timer (admin-konfigurierbar)
|
||||
// --------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -0,0 +1,270 @@
|
|||
-- ============================================================
|
||||
-- Phase 1: Schema Expansion
|
||||
-- Neue Enums, Felder, Multi-Value Tabellen, Owner-Tabellen
|
||||
-- ============================================================
|
||||
|
||||
-- WICHTIG: ALTER TYPE ... ADD VALUE kann NICHT in einer Transaction laufen.
|
||||
-- Diese Migration muss daher ausserhalb von BEGIN/COMMIT ausgefuehrt werden,
|
||||
-- oder der ADD VALUE muss separat laufen.
|
||||
|
||||
-- --------------------------------------------------------
|
||||
-- 1. Neue Enums erstellen
|
||||
-- --------------------------------------------------------
|
||||
|
||||
CREATE TYPE "app_crm"."ContactSource" AS ENUM (
|
||||
'TRADE_FAIR', 'REFERRAL', 'WEBSITE', 'COLD_CALL',
|
||||
'IMPORT', 'BUSINESS_CARD', 'OTHER'
|
||||
);
|
||||
|
||||
CREATE TYPE "app_crm"."EntityStatus" AS ENUM (
|
||||
'ACTIVE', 'INACTIVE', 'BLOCKED'
|
||||
);
|
||||
|
||||
CREATE TYPE "app_crm"."CompanySize" AS ENUM (
|
||||
'SIZE_1_10', 'SIZE_11_50', 'SIZE_51_200', 'SIZE_201_500', 'SIZE_500_PLUS'
|
||||
);
|
||||
|
||||
CREATE TYPE "app_crm"."OwnerRole" AS ENUM (
|
||||
'OWNER', 'MEMBER', 'WATCHER'
|
||||
);
|
||||
|
||||
CREATE TYPE "app_crm"."LostReason" AS ENUM (
|
||||
'PRICE', 'TIMING', 'COMPETITOR', 'NO_NEED', 'OTHER'
|
||||
);
|
||||
|
||||
CREATE TYPE "app_crm"."EmailType" AS ENUM (
|
||||
'WORK', 'PERSONAL', 'OTHER'
|
||||
);
|
||||
|
||||
CREATE TYPE "app_crm"."PhoneType" AS ENUM (
|
||||
'OFFICE', 'MOBILE', 'FAX'
|
||||
);
|
||||
|
||||
-- FOLLOWUP zum ActivityType Enum hinzufuegen (NICHT transaktionsfaehig!)
|
||||
ALTER TYPE "app_crm"."ActivityType" ADD VALUE IF NOT EXISTS 'FOLLOWUP';
|
||||
|
||||
-- --------------------------------------------------------
|
||||
-- 2. Neue Spalten auf contacts
|
||||
-- --------------------------------------------------------
|
||||
|
||||
ALTER TABLE "app_crm"."contacts"
|
||||
ADD COLUMN "linkedin_url" VARCHAR(500),
|
||||
ADD COLUMN "birthday" TIMESTAMP(3),
|
||||
ADD COLUMN "source" "app_crm"."ContactSource",
|
||||
ADD COLUMN "department" VARCHAR(200),
|
||||
ADD COLUMN "status" "app_crm"."EntityStatus" NOT NULL DEFAULT 'ACTIVE';
|
||||
|
||||
-- --------------------------------------------------------
|
||||
-- 3. Neue Spalten auf companies
|
||||
-- --------------------------------------------------------
|
||||
|
||||
ALTER TABLE "app_crm"."companies"
|
||||
ADD COLUMN "vat_id" VARCHAR(50),
|
||||
ADD COLUMN "tax_id" VARCHAR(50),
|
||||
ADD COLUMN "trade_register_number" VARCHAR(100),
|
||||
ADD COLUMN "register_court" VARCHAR(200),
|
||||
ADD COLUMN "company_size" "app_crm"."CompanySize",
|
||||
ADD COLUMN "delivery_street" VARCHAR(200),
|
||||
ADD COLUMN "delivery_zip" VARCHAR(20),
|
||||
ADD COLUMN "delivery_city" VARCHAR(100),
|
||||
ADD COLUMN "delivery_country" VARCHAR(2),
|
||||
ADD COLUMN "status" "app_crm"."EntityStatus" NOT NULL DEFAULT 'ACTIVE',
|
||||
ADD COLUMN "data_enriched_at" TIMESTAMP(3),
|
||||
ADD COLUMN "data_enriched_source" VARCHAR(200);
|
||||
|
||||
-- --------------------------------------------------------
|
||||
-- 4. Neue Spalten auf deals (Lost-Reason)
|
||||
-- --------------------------------------------------------
|
||||
|
||||
ALTER TABLE "app_crm"."deals"
|
||||
ADD COLUMN "lost_reason" "app_crm"."LostReason",
|
||||
ADD COLUMN "lost_reason_text" TEXT;
|
||||
|
||||
-- --------------------------------------------------------
|
||||
-- 5. Daten-Migration: isActive → status
|
||||
-- --------------------------------------------------------
|
||||
|
||||
UPDATE "app_crm"."contacts"
|
||||
SET "status" = 'INACTIVE'
|
||||
WHERE "is_active" = false;
|
||||
|
||||
UPDATE "app_crm"."companies"
|
||||
SET "status" = 'INACTIVE'
|
||||
WHERE "is_active" = false;
|
||||
|
||||
-- --------------------------------------------------------
|
||||
-- 6. Multi-Value: contact_emails Tabelle
|
||||
-- --------------------------------------------------------
|
||||
|
||||
CREATE TABLE "app_crm"."contact_emails" (
|
||||
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||
"contact_id" UUID,
|
||||
"company_id" UUID,
|
||||
"email" VARCHAR(255) NOT NULL,
|
||||
"type" "app_crm"."EmailType" NOT NULL DEFAULT 'WORK',
|
||||
"is_primary" BOOLEAN NOT NULL DEFAULT false,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "contact_emails_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE INDEX "contact_emails_contact_id_idx" ON "app_crm"."contact_emails"("contact_id");
|
||||
CREATE INDEX "contact_emails_company_id_idx" ON "app_crm"."contact_emails"("company_id");
|
||||
|
||||
ALTER TABLE "app_crm"."contact_emails"
|
||||
ADD CONSTRAINT "contact_emails_contact_id_fkey"
|
||||
FOREIGN KEY ("contact_id") REFERENCES "app_crm"."contacts"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE "app_crm"."contact_emails"
|
||||
ADD CONSTRAINT "contact_emails_company_id_fkey"
|
||||
FOREIGN KEY ("company_id") REFERENCES "app_crm"."companies"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- --------------------------------------------------------
|
||||
-- 7. Multi-Value: contact_phones Tabelle
|
||||
-- --------------------------------------------------------
|
||||
|
||||
CREATE TABLE "app_crm"."contact_phones" (
|
||||
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||
"contact_id" UUID,
|
||||
"company_id" UUID,
|
||||
"phone" VARCHAR(50) NOT NULL,
|
||||
"type" "app_crm"."PhoneType" NOT NULL DEFAULT 'OFFICE',
|
||||
"is_primary" BOOLEAN NOT NULL DEFAULT false,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "contact_phones_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE INDEX "contact_phones_contact_id_idx" ON "app_crm"."contact_phones"("contact_id");
|
||||
CREATE INDEX "contact_phones_company_id_idx" ON "app_crm"."contact_phones"("company_id");
|
||||
|
||||
ALTER TABLE "app_crm"."contact_phones"
|
||||
ADD CONSTRAINT "contact_phones_contact_id_fkey"
|
||||
FOREIGN KEY ("contact_id") REFERENCES "app_crm"."contacts"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE "app_crm"."contact_phones"
|
||||
ADD CONSTRAINT "contact_phones_company_id_fkey"
|
||||
FOREIGN KEY ("company_id") REFERENCES "app_crm"."companies"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- --------------------------------------------------------
|
||||
-- 8. Daten-Migration: Bestehende Emails/Phones migrieren
|
||||
-- --------------------------------------------------------
|
||||
|
||||
-- Contact Emails
|
||||
INSERT INTO "app_crm"."contact_emails" ("id", "contact_id", "email", "type", "is_primary")
|
||||
SELECT gen_random_uuid(), "id", "email", 'WORK'::"app_crm"."EmailType", true
|
||||
FROM "app_crm"."contacts"
|
||||
WHERE "email" IS NOT NULL AND "email" != '';
|
||||
|
||||
-- Contact Phones (phone → OFFICE, mobile → MOBILE)
|
||||
INSERT INTO "app_crm"."contact_phones" ("id", "contact_id", "phone", "type", "is_primary")
|
||||
SELECT gen_random_uuid(), "id", "phone", 'OFFICE'::"app_crm"."PhoneType", true
|
||||
FROM "app_crm"."contacts"
|
||||
WHERE "phone" IS NOT NULL AND "phone" != '';
|
||||
|
||||
INSERT INTO "app_crm"."contact_phones" ("id", "contact_id", "phone", "type", "is_primary")
|
||||
SELECT gen_random_uuid(), "id", "mobile", 'MOBILE'::"app_crm"."PhoneType", false
|
||||
FROM "app_crm"."contacts"
|
||||
WHERE "mobile" IS NOT NULL AND "mobile" != '';
|
||||
|
||||
-- Company Emails
|
||||
INSERT INTO "app_crm"."contact_emails" ("id", "company_id", "email", "type", "is_primary")
|
||||
SELECT gen_random_uuid(), "id", "email", 'WORK'::"app_crm"."EmailType", true
|
||||
FROM "app_crm"."companies"
|
||||
WHERE "email" IS NOT NULL AND "email" != '';
|
||||
|
||||
-- Company Phones
|
||||
INSERT INTO "app_crm"."contact_phones" ("id", "company_id", "phone", "type", "is_primary")
|
||||
SELECT gen_random_uuid(), "id", "phone", 'OFFICE'::"app_crm"."PhoneType", true
|
||||
FROM "app_crm"."companies"
|
||||
WHERE "phone" IS NOT NULL AND "phone" != '';
|
||||
|
||||
-- Hinweis: Legacy-Spalten email, phone, mobile bleiben bestehen (deprecated)
|
||||
|
||||
-- --------------------------------------------------------
|
||||
-- 9. Owner-Tabellen: contact_owners
|
||||
-- --------------------------------------------------------
|
||||
|
||||
CREATE TABLE "app_crm"."contact_owners" (
|
||||
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||
"tenant_id" UUID NOT NULL,
|
||||
"contact_id" UUID NOT NULL,
|
||||
"user_id" UUID NOT NULL,
|
||||
"role" "app_crm"."OwnerRole" NOT NULL DEFAULT 'OWNER',
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "contact_owners_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "contact_owners_contact_id_user_id_key"
|
||||
ON "app_crm"."contact_owners"("contact_id", "user_id");
|
||||
CREATE INDEX "contact_owners_tenant_id_idx"
|
||||
ON "app_crm"."contact_owners"("tenant_id");
|
||||
CREATE INDEX "contact_owners_tenant_id_contact_id_idx"
|
||||
ON "app_crm"."contact_owners"("tenant_id", "contact_id");
|
||||
|
||||
ALTER TABLE "app_crm"."contact_owners"
|
||||
ADD CONSTRAINT "contact_owners_contact_id_fkey"
|
||||
FOREIGN KEY ("contact_id") REFERENCES "app_crm"."contacts"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- --------------------------------------------------------
|
||||
-- 10. Owner-Tabellen: company_owners
|
||||
-- --------------------------------------------------------
|
||||
|
||||
CREATE TABLE "app_crm"."company_owners" (
|
||||
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||
"tenant_id" UUID NOT NULL,
|
||||
"company_id" UUID NOT NULL,
|
||||
"user_id" UUID NOT NULL,
|
||||
"role" "app_crm"."OwnerRole" NOT NULL DEFAULT 'OWNER',
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "company_owners_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "company_owners_company_id_user_id_key"
|
||||
ON "app_crm"."company_owners"("company_id", "user_id");
|
||||
CREATE INDEX "company_owners_tenant_id_idx"
|
||||
ON "app_crm"."company_owners"("tenant_id");
|
||||
CREATE INDEX "company_owners_tenant_id_company_id_idx"
|
||||
ON "app_crm"."company_owners"("tenant_id", "company_id");
|
||||
|
||||
ALTER TABLE "app_crm"."company_owners"
|
||||
ADD CONSTRAINT "company_owners_company_id_fkey"
|
||||
FOREIGN KEY ("company_id") REFERENCES "app_crm"."companies"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- Bestehende Company.ownerId → company_owners migrieren
|
||||
INSERT INTO "app_crm"."company_owners" ("id", "tenant_id", "company_id", "user_id", "role")
|
||||
SELECT gen_random_uuid(), "tenant_id", "id", "owner_id", 'OWNER'::"app_crm"."OwnerRole"
|
||||
FROM "app_crm"."companies"
|
||||
WHERE "owner_id" IS NOT NULL;
|
||||
|
||||
-- --------------------------------------------------------
|
||||
-- 11. Owner-Tabellen: deal_owners
|
||||
-- --------------------------------------------------------
|
||||
|
||||
CREATE TABLE "app_crm"."deal_owners" (
|
||||
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||
"tenant_id" UUID NOT NULL,
|
||||
"deal_id" UUID NOT NULL,
|
||||
"user_id" UUID NOT NULL,
|
||||
"role" "app_crm"."OwnerRole" NOT NULL DEFAULT 'OWNER',
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "deal_owners_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "deal_owners_deal_id_user_id_key"
|
||||
ON "app_crm"."deal_owners"("deal_id", "user_id");
|
||||
CREATE INDEX "deal_owners_tenant_id_idx"
|
||||
ON "app_crm"."deal_owners"("tenant_id");
|
||||
CREATE INDEX "deal_owners_tenant_id_deal_id_idx"
|
||||
ON "app_crm"."deal_owners"("tenant_id", "deal_id");
|
||||
|
||||
ALTER TABLE "app_crm"."deal_owners"
|
||||
ADD CONSTRAINT "deal_owners_deal_id_fkey"
|
||||
FOREIGN KEY ("deal_id") REFERENCES "app_crm"."deals"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- --------------------------------------------------------
|
||||
-- 12. Zusaetzliche Indexes fuer status
|
||||
-- --------------------------------------------------------
|
||||
|
||||
CREATE INDEX "contacts_tenant_id_status_idx"
|
||||
ON "app_crm"."contacts"("tenant_id", "status");
|
||||
CREATE INDEX "companies_tenant_id_status_idx"
|
||||
ON "app_crm"."companies"("tenant_id", "status");
|
||||
|
|
@ -20,6 +20,7 @@ import { AccountTypesModule } from './account-types/account-types.module';
|
|||
import { RelationshipTypesModule } from './relationship-types/relationship-types.module';
|
||||
import { CompanyRelationshipsModule } from './company-relationships/company-relationships.module';
|
||||
import { TradeEventsModule } from './trade-events/trade-events.module';
|
||||
import { CrmEventsModule } from './events/crm-events.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
|
|
@ -43,6 +44,7 @@ import { TradeEventsModule } from './trade-events/trade-events.module';
|
|||
RelationshipTypesModule,
|
||||
CompanyRelationshipsModule,
|
||||
TradeEventsModule,
|
||||
CrmEventsModule,
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
|
|
|
|||
91
packages/crm-service/src/common/dto/contact-info.dto.ts
Normal file
91
packages/crm-service/src/common/dto/contact-info.dto.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
// ============================================================
|
||||
// Shared DTOs fuer Multi-Value E-Mails und Telefonnummern
|
||||
// ============================================================
|
||||
|
||||
import {
|
||||
IsString,
|
||||
IsOptional,
|
||||
IsEnum,
|
||||
IsBoolean,
|
||||
IsEmail,
|
||||
MaxLength,
|
||||
} from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Enums (TS-Pendants zu Prisma Enums)
|
||||
// --------------------------------------------------------
|
||||
|
||||
export enum EmailType {
|
||||
WORK = 'WORK',
|
||||
PERSONAL = 'PERSONAL',
|
||||
OTHER = 'OTHER',
|
||||
}
|
||||
|
||||
export enum PhoneType {
|
||||
OFFICE = 'OFFICE',
|
||||
MOBILE = 'MOBILE',
|
||||
FAX = 'FAX',
|
||||
}
|
||||
|
||||
export enum ContactSource {
|
||||
TRADE_FAIR = 'TRADE_FAIR',
|
||||
REFERRAL = 'REFERRAL',
|
||||
WEBSITE = 'WEBSITE',
|
||||
COLD_CALL = 'COLD_CALL',
|
||||
IMPORT = 'IMPORT',
|
||||
BUSINESS_CARD = 'BUSINESS_CARD',
|
||||
OTHER = 'OTHER',
|
||||
}
|
||||
|
||||
export enum EntityStatus {
|
||||
ACTIVE = 'ACTIVE',
|
||||
INACTIVE = 'INACTIVE',
|
||||
BLOCKED = 'BLOCKED',
|
||||
}
|
||||
|
||||
export enum CompanySize {
|
||||
SIZE_1_10 = 'SIZE_1_10',
|
||||
SIZE_11_50 = 'SIZE_11_50',
|
||||
SIZE_51_200 = 'SIZE_51_200',
|
||||
SIZE_201_500 = 'SIZE_201_500',
|
||||
SIZE_500_PLUS = 'SIZE_500_PLUS',
|
||||
}
|
||||
|
||||
// --------------------------------------------------------
|
||||
// DTOs
|
||||
// --------------------------------------------------------
|
||||
|
||||
export class CreateEmailDto {
|
||||
@ApiProperty({ maxLength: 255, description: 'E-Mail-Adresse' })
|
||||
@IsEmail()
|
||||
@MaxLength(255)
|
||||
email!: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: EmailType, default: EmailType.WORK })
|
||||
@IsOptional()
|
||||
@IsEnum(EmailType)
|
||||
type?: EmailType;
|
||||
|
||||
@ApiPropertyOptional({ default: false })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isPrimary?: boolean;
|
||||
}
|
||||
|
||||
export class CreatePhoneDto {
|
||||
@ApiProperty({ maxLength: 50, description: 'Telefonnummer' })
|
||||
@IsString()
|
||||
@MaxLength(50)
|
||||
phone!: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: PhoneType, default: PhoneType.OFFICE })
|
||||
@IsOptional()
|
||||
@IsEnum(PhoneType)
|
||||
type?: PhoneType;
|
||||
|
||||
@ApiPropertyOptional({ default: false })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isPrimary?: boolean;
|
||||
}
|
||||
23
packages/crm-service/src/common/dto/owner.dto.ts
Normal file
23
packages/crm-service/src/common/dto/owner.dto.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
// ============================================================
|
||||
// Shared DTO fuer Owner-Operationen (Contact, Company, Deal)
|
||||
// ============================================================
|
||||
|
||||
import { IsUUID, IsOptional, IsEnum } from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export enum OwnerRole {
|
||||
OWNER = 'OWNER',
|
||||
MEMBER = 'MEMBER',
|
||||
WATCHER = 'WATCHER',
|
||||
}
|
||||
|
||||
export class AddOwnerDto {
|
||||
@ApiProperty({ format: 'uuid', description: 'User-ID des Owners (aus platform_core)' })
|
||||
@IsUUID()
|
||||
userId!: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: OwnerRole, default: OwnerRole.OWNER })
|
||||
@IsOptional()
|
||||
@IsEnum(OwnerRole)
|
||||
role?: OwnerRole;
|
||||
}
|
||||
|
|
@ -23,6 +23,8 @@ import { CompaniesService } from './companies.service';
|
|||
import { CreateCompanyDto } from './dto/create-company.dto';
|
||||
import { UpdateCompanyDto } from './dto/update-company.dto';
|
||||
import { QueryCompaniesDto } from './dto/query-companies.dto';
|
||||
import { AddOwnerDto } from '../common/dto/owner.dto';
|
||||
import { OwnersService } from '../owners/owners.service';
|
||||
import { CurrentUser, JwtPayload } from '../common/decorators';
|
||||
import { TenantGuard } from '../auth/guards/tenant.guard';
|
||||
import {
|
||||
|
|
@ -35,7 +37,10 @@ import {
|
|||
@UseGuards(TenantGuard)
|
||||
@Controller('companies')
|
||||
export class CompaniesController {
|
||||
constructor(private readonly companiesService: CompaniesService) {}
|
||||
constructor(
|
||||
private readonly companiesService: CompaniesService,
|
||||
private readonly ownersService: OwnersService,
|
||||
) {}
|
||||
|
||||
@Post()
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
|
|
@ -116,4 +121,42 @@ export class CompaniesController {
|
|||
const company = await this.companiesService.remove(user.tenantId!, id);
|
||||
return singleResponse(company);
|
||||
}
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Owner Endpoints
|
||||
// --------------------------------------------------------
|
||||
|
||||
@Post(':id/owners')
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
@ApiOperation({ summary: 'Owner zu Unternehmen hinzufuegen' })
|
||||
@ApiParam({ name: 'id', type: 'string', format: 'uuid' })
|
||||
async addOwner(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: AddOwnerDto,
|
||||
) {
|
||||
const owner = await this.ownersService.addCompanyOwner(
|
||||
user.tenantId!,
|
||||
id,
|
||||
dto,
|
||||
);
|
||||
return singleResponse(owner);
|
||||
}
|
||||
|
||||
@Delete(':id/owners/:userId')
|
||||
@ApiOperation({ summary: 'Owner von Unternehmen entfernen' })
|
||||
@ApiParam({ name: 'id', type: 'string', format: 'uuid' })
|
||||
@ApiParam({ name: 'userId', type: 'string', format: 'uuid' })
|
||||
async removeOwner(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Param('userId', ParseUUIDPipe) userId: string,
|
||||
) {
|
||||
const owner = await this.ownersService.removeCompanyOwner(
|
||||
user.tenantId!,
|
||||
id,
|
||||
userId,
|
||||
);
|
||||
return singleResponse(owner);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,9 +3,10 @@ import { CompaniesController } from './companies.controller';
|
|||
import { CompaniesService } from './companies.service';
|
||||
import { CrmPrismaModule } from '../prisma/crm-prisma.module';
|
||||
import { LexwareModule } from '../lexware/lexware.module';
|
||||
import { OwnersModule } from '../owners/owners.module';
|
||||
|
||||
@Module({
|
||||
imports: [CrmPrismaModule, LexwareModule],
|
||||
imports: [CrmPrismaModule, LexwareModule, OwnersModule],
|
||||
controllers: [CompaniesController],
|
||||
providers: [CompaniesService],
|
||||
exports: [CompaniesService],
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { UpdateCompanyDto } from './dto/update-company.dto';
|
|||
import { QueryCompaniesDto } from './dto/query-companies.dto';
|
||||
import { LexwareContactsService } from '../lexware/lexware-contacts.service';
|
||||
import { Prisma } from '.prisma/crm-client';
|
||||
import { EntityStatus } from '../common/dto/contact-info.dto';
|
||||
|
||||
@Injectable()
|
||||
export class CompaniesService {
|
||||
|
|
@ -16,6 +17,46 @@ export class CompaniesService {
|
|||
) {}
|
||||
|
||||
async create(tenantId: string, userId: string, dto: CreateCompanyDto) {
|
||||
// Status aus DTO ableiten (status hat Vorrang vor isActive)
|
||||
const status = dto.status
|
||||
?? (dto.isActive === false ? EntityStatus.INACTIVE : EntityStatus.ACTIVE);
|
||||
const isActive = status !== EntityStatus.INACTIVE && status !== EntityStatus.BLOCKED;
|
||||
|
||||
// Legacy email/phone: aus Multi-Value Primary ableiten oder aus DTO
|
||||
const primaryEmail = dto.emails?.find((e) => e.isPrimary)?.email
|
||||
?? dto.emails?.[0]?.email ?? dto.email;
|
||||
const primaryPhone = dto.phones?.find((p) => p.isPrimary)?.phone
|
||||
?? dto.phones?.find((p) => p.type === 'OFFICE')?.phone
|
||||
?? dto.phones?.[0]?.phone ?? dto.phone;
|
||||
|
||||
// Multi-Value emails vorbereiten
|
||||
const emailsCreate: Prisma.ContactEmailCreateWithoutCompanyInput[] = [];
|
||||
if (dto.emails && dto.emails.length > 0) {
|
||||
dto.emails.forEach((e) => {
|
||||
emailsCreate.push({
|
||||
email: e.email,
|
||||
type: e.type ?? 'WORK',
|
||||
isPrimary: e.isPrimary ?? false,
|
||||
});
|
||||
});
|
||||
} else if (dto.email) {
|
||||
emailsCreate.push({ email: dto.email, type: 'WORK', isPrimary: true });
|
||||
}
|
||||
|
||||
// Multi-Value phones vorbereiten
|
||||
const phonesCreate: Prisma.ContactPhoneCreateWithoutCompanyInput[] = [];
|
||||
if (dto.phones && dto.phones.length > 0) {
|
||||
dto.phones.forEach((p) => {
|
||||
phonesCreate.push({
|
||||
phone: p.phone,
|
||||
type: p.type ?? 'OFFICE',
|
||||
isPrimary: p.isPrimary ?? false,
|
||||
});
|
||||
});
|
||||
} else if (dto.phone) {
|
||||
phonesCreate.push({ phone: dto.phone, type: 'OFFICE', isPrimary: true });
|
||||
}
|
||||
|
||||
return this.prisma.company.create({
|
||||
data: {
|
||||
tenantId,
|
||||
|
|
@ -26,21 +67,39 @@ export class CompaniesService {
|
|||
accountTypeId: dto.accountTypeId,
|
||||
ownerId: dto.ownerId,
|
||||
ownerName: dto.ownerName,
|
||||
email: dto.email,
|
||||
phone: dto.phone,
|
||||
vatId: dto.vatId,
|
||||
taxId: dto.taxId,
|
||||
tradeRegisterNumber: dto.tradeRegisterNumber,
|
||||
registerCourt: dto.registerCourt,
|
||||
companySize: dto.companySize,
|
||||
email: primaryEmail,
|
||||
phone: primaryPhone,
|
||||
website: dto.website,
|
||||
street: dto.street,
|
||||
zip: dto.zip,
|
||||
city: dto.city,
|
||||
state: dto.state,
|
||||
country: dto.country,
|
||||
deliveryStreet: dto.deliveryStreet,
|
||||
deliveryZip: dto.deliveryZip,
|
||||
deliveryCity: dto.deliveryCity,
|
||||
deliveryCountry: dto.deliveryCountry,
|
||||
notes: dto.notes,
|
||||
tags: dto.tags ?? [],
|
||||
isActive: dto.isActive ?? true,
|
||||
status,
|
||||
isActive,
|
||||
emails: emailsCreate.length > 0 ? { create: emailsCreate } : undefined,
|
||||
phones: phonesCreate.length > 0 ? { create: phonesCreate } : undefined,
|
||||
owners: {
|
||||
create: { tenantId, userId, role: 'OWNER' },
|
||||
},
|
||||
},
|
||||
include: {
|
||||
industryRef: true,
|
||||
accountType: true,
|
||||
emails: true,
|
||||
phones: true,
|
||||
owners: true,
|
||||
_count: { select: { contacts: true, deals: true } },
|
||||
},
|
||||
});
|
||||
|
|
@ -56,6 +115,10 @@ export class CompaniesService {
|
|||
where.industry = { contains: query.industry, mode: 'insensitive' };
|
||||
}
|
||||
|
||||
if (query.status) {
|
||||
where.status = query.status;
|
||||
}
|
||||
|
||||
if (query.search) {
|
||||
where.OR = [
|
||||
{ name: { contains: query.search, mode: 'insensitive' } },
|
||||
|
|
@ -85,6 +148,8 @@ export class CompaniesService {
|
|||
include: {
|
||||
industryRef: true,
|
||||
accountType: true,
|
||||
emails: true,
|
||||
phones: true,
|
||||
_count: { select: { contacts: true, deals: true } },
|
||||
},
|
||||
}),
|
||||
|
|
@ -100,8 +165,11 @@ export class CompaniesService {
|
|||
include: {
|
||||
industryRef: true,
|
||||
accountType: true,
|
||||
emails: true,
|
||||
phones: true,
|
||||
owners: true,
|
||||
contacts: {
|
||||
where: { isActive: true },
|
||||
where: { status: 'ACTIVE' },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 20,
|
||||
select: {
|
||||
|
|
@ -112,6 +180,7 @@ export class CompaniesService {
|
|||
phone: true,
|
||||
position: true,
|
||||
isActive: true,
|
||||
status: true,
|
||||
},
|
||||
},
|
||||
deals: {
|
||||
|
|
@ -169,17 +238,88 @@ export class CompaniesService {
|
|||
) {
|
||||
await this.findOne(tenantId, id);
|
||||
|
||||
const updated = await this.prisma.company.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...dto,
|
||||
// DTO-Felder aufteilen: Prisma-Scalar-Felder vs. nested Relations
|
||||
const { emails, phones, isActive, status: dtoStatus, ...scalarFields } = dto;
|
||||
|
||||
// Status ↔ isActive synchron halten
|
||||
let status: EntityStatus | undefined;
|
||||
let isActiveBool: boolean | undefined;
|
||||
if (dtoStatus !== undefined) {
|
||||
status = dtoStatus;
|
||||
isActiveBool = dtoStatus !== EntityStatus.INACTIVE && dtoStatus !== EntityStatus.BLOCKED;
|
||||
} else if (isActive !== undefined) {
|
||||
isActiveBool = isActive;
|
||||
status = isActive ? EntityStatus.ACTIVE : EntityStatus.INACTIVE;
|
||||
}
|
||||
|
||||
// Replace-Strategie fuer emails/phones in Transaction
|
||||
const updated = await this.prisma.$transaction(async (tx) => {
|
||||
// Emails: Alle loeschen und neu anlegen
|
||||
if (emails !== undefined) {
|
||||
await tx.contactEmail.deleteMany({ where: { companyId: id } });
|
||||
if (emails.length > 0) {
|
||||
await tx.contactEmail.createMany({
|
||||
data: emails.map((e) => ({
|
||||
companyId: id,
|
||||
email: e.email,
|
||||
type: e.type ?? 'WORK',
|
||||
isPrimary: e.isPrimary ?? false,
|
||||
})),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Phones: Alle loeschen und neu anlegen
|
||||
if (phones !== undefined) {
|
||||
await tx.contactPhone.deleteMany({ where: { companyId: id } });
|
||||
if (phones.length > 0) {
|
||||
await tx.contactPhone.createMany({
|
||||
data: phones.map((p) => ({
|
||||
companyId: id,
|
||||
phone: p.phone,
|
||||
type: p.type ?? 'OFFICE',
|
||||
isPrimary: p.isPrimary ?? false,
|
||||
})),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy-Felder aus Multi-Value aktualisieren
|
||||
let legacyEmail: string | null | undefined;
|
||||
let legacyPhone: string | null | undefined;
|
||||
|
||||
if (emails !== undefined) {
|
||||
legacyEmail = emails.find((e) => e.isPrimary)?.email
|
||||
?? emails[0]?.email ?? null;
|
||||
}
|
||||
if (phones !== undefined) {
|
||||
legacyPhone = phones.find((p) => p.isPrimary)?.phone
|
||||
?? phones.find((p) => p.type === 'OFFICE')?.phone
|
||||
?? phones[0]?.phone ?? null;
|
||||
}
|
||||
|
||||
const updateData: Prisma.CompanyUpdateInput = {
|
||||
...scalarFields,
|
||||
updatedBy: userId,
|
||||
},
|
||||
include: {
|
||||
industryRef: true,
|
||||
accountType: true,
|
||||
_count: { select: { contacts: true, deals: true } },
|
||||
},
|
||||
};
|
||||
|
||||
if (status !== undefined) updateData.status = status;
|
||||
if (isActiveBool !== undefined) updateData.isActive = isActiveBool;
|
||||
if (legacyEmail !== undefined) updateData.email = legacyEmail;
|
||||
if (legacyPhone !== undefined) updateData.phone = legacyPhone;
|
||||
|
||||
return tx.company.update({
|
||||
where: { id },
|
||||
data: updateData,
|
||||
include: {
|
||||
industryRef: true,
|
||||
accountType: true,
|
||||
emails: true,
|
||||
phones: true,
|
||||
owners: true,
|
||||
_count: { select: { contacts: true, deals: true } },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// ERP-Push: Wenn Lexware verknuepft UND "ERP"-Tag gesetzt → async Push
|
||||
|
|
|
|||
|
|
@ -6,9 +6,13 @@ import {
|
|||
IsUrl,
|
||||
IsUUID,
|
||||
IsArray,
|
||||
IsEnum,
|
||||
MaxLength,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { CreateEmailDto, CreatePhoneDto, EntityStatus, CompanySize } from '../../common/dto/contact-info.dto';
|
||||
|
||||
export class CreateCompanyDto {
|
||||
@ApiProperty({ maxLength: 200 })
|
||||
|
|
@ -43,6 +47,35 @@ export class CreateCompanyDto {
|
|||
@MaxLength(200)
|
||||
ownerName?: string;
|
||||
|
||||
@ApiPropertyOptional({ maxLength: 50, description: 'USt-IdNr.' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(50)
|
||||
vatId?: string;
|
||||
|
||||
@ApiPropertyOptional({ maxLength: 50, description: 'Steuernummer' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(50)
|
||||
taxId?: string;
|
||||
|
||||
@ApiPropertyOptional({ maxLength: 100, description: 'Handelsregisternummer (z.B. HRB 12345)' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
tradeRegisterNumber?: string;
|
||||
|
||||
@ApiPropertyOptional({ maxLength: 200, description: 'Registergericht' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
registerCourt?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: CompanySize, description: 'Unternehmensgroesse' })
|
||||
@IsOptional()
|
||||
@IsEnum(CompanySize)
|
||||
companySize?: CompanySize;
|
||||
|
||||
@ApiPropertyOptional({ maxLength: 255 })
|
||||
@IsOptional()
|
||||
@IsEmail()
|
||||
|
|
@ -91,6 +124,30 @@ export class CreateCompanyDto {
|
|||
@MaxLength(2)
|
||||
country?: string;
|
||||
|
||||
@ApiPropertyOptional({ maxLength: 200, description: 'Lieferadresse: Strasse' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
deliveryStreet?: string;
|
||||
|
||||
@ApiPropertyOptional({ maxLength: 20, description: 'Lieferadresse: PLZ' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(20)
|
||||
deliveryZip?: string;
|
||||
|
||||
@ApiPropertyOptional({ maxLength: 100, description: 'Lieferadresse: Stadt' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
deliveryCity?: string;
|
||||
|
||||
@ApiPropertyOptional({ maxLength: 2, description: 'Lieferadresse: Land (ISO 2-Zeichen)' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(2)
|
||||
deliveryCountry?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
|
|
@ -102,8 +159,27 @@ export class CreateCompanyDto {
|
|||
@IsString({ each: true })
|
||||
tags?: string[];
|
||||
|
||||
@ApiPropertyOptional({ default: true })
|
||||
@ApiPropertyOptional({ enum: EntityStatus, default: EntityStatus.ACTIVE, description: 'Status (ersetzt isActive)' })
|
||||
@IsOptional()
|
||||
@IsEnum(EntityStatus)
|
||||
status?: EntityStatus;
|
||||
|
||||
@ApiPropertyOptional({ default: true, deprecated: true, description: 'DEPRECATED: Bitte status verwenden' })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
|
||||
@ApiPropertyOptional({ type: [CreateEmailDto], description: 'E-Mail-Adressen (Multi-Value)' })
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => CreateEmailDto)
|
||||
emails?: CreateEmailDto[];
|
||||
|
||||
@ApiPropertyOptional({ type: [CreatePhoneDto], description: 'Telefonnummern (Multi-Value)' })
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => CreatePhoneDto)
|
||||
phones?: CreatePhoneDto[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { IsOptional, IsString, IsInt, Min, Max, IsIn } from 'class-validator';
|
||||
import { IsOptional, IsString, IsInt, Min, Max, IsIn, IsEnum } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { EntityStatus } from '../../common/dto/contact-info.dto';
|
||||
|
||||
export class QueryCompaniesDto {
|
||||
@ApiPropertyOptional({ default: 1 })
|
||||
|
|
@ -28,6 +29,11 @@ export class QueryCompaniesDto {
|
|||
@IsString()
|
||||
industry?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: EntityStatus, description: 'Filter nach Status' })
|
||||
@IsOptional()
|
||||
@IsEnum(EntityStatus)
|
||||
status?: EntityStatus;
|
||||
|
||||
@ApiPropertyOptional({ default: 'createdAt' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
|
|
|
|||
|
|
@ -1,110 +1,4 @@
|
|||
import {
|
||||
IsString,
|
||||
IsOptional,
|
||||
IsBoolean,
|
||||
IsEmail,
|
||||
IsUrl,
|
||||
IsUUID,
|
||||
IsArray,
|
||||
MaxLength,
|
||||
} from 'class-validator';
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { PartialType } from '@nestjs/swagger';
|
||||
import { CreateCompanyDto } from './create-company.dto';
|
||||
|
||||
export class UpdateCompanyDto {
|
||||
@ApiPropertyOptional({ maxLength: 200 })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
name?: string;
|
||||
|
||||
@ApiPropertyOptional({ maxLength: 100 })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
industry?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid', description: 'Branchen-ID' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
industryId?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid', description: 'Kontotyp-ID' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
accountTypeId?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid', description: 'Zustaendiger User (Owner-ID)' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
ownerId?: string;
|
||||
|
||||
@ApiPropertyOptional({ maxLength: 200, description: 'Name des zustaendigen Users' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
ownerName?: string;
|
||||
|
||||
@ApiPropertyOptional({ maxLength: 255 })
|
||||
@IsOptional()
|
||||
@IsEmail()
|
||||
@MaxLength(255)
|
||||
email?: string;
|
||||
|
||||
@ApiPropertyOptional({ maxLength: 50 })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(50)
|
||||
phone?: 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 })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(2)
|
||||
country?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
notes?: string;
|
||||
|
||||
@ApiPropertyOptional({ type: [String] })
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
tags?: string[];
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
}
|
||||
export class UpdateCompanyDto extends PartialType(CreateCompanyDto) {}
|
||||
|
|
|
|||
|
|
@ -22,6 +22,8 @@ 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 { AddOwnerDto } from '../common/dto/owner.dto';
|
||||
import { OwnersService } from '../owners/owners.service';
|
||||
import { CurrentUser, JwtPayload } from '../common/decorators';
|
||||
import { TenantGuard } from '../auth/guards/tenant.guard';
|
||||
import {
|
||||
|
|
@ -34,7 +36,10 @@ import {
|
|||
@UseGuards(TenantGuard)
|
||||
@Controller('contacts')
|
||||
export class ContactsController {
|
||||
constructor(private readonly contactsService: ContactsService) {}
|
||||
constructor(
|
||||
private readonly contactsService: ContactsService,
|
||||
private readonly ownersService: OwnersService,
|
||||
) {}
|
||||
|
||||
@Post()
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
|
|
@ -104,4 +109,42 @@ export class ContactsController {
|
|||
const contact = await this.contactsService.remove(user.tenantId!, id);
|
||||
return singleResponse(contact);
|
||||
}
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Owner Endpoints
|
||||
// --------------------------------------------------------
|
||||
|
||||
@Post(':id/owners')
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
@ApiOperation({ summary: 'Owner zu Kontakt hinzufuegen' })
|
||||
@ApiParam({ name: 'id', type: 'string', format: 'uuid' })
|
||||
async addOwner(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: AddOwnerDto,
|
||||
) {
|
||||
const owner = await this.ownersService.addContactOwner(
|
||||
user.tenantId!,
|
||||
id,
|
||||
dto,
|
||||
);
|
||||
return singleResponse(owner);
|
||||
}
|
||||
|
||||
@Delete(':id/owners/:userId')
|
||||
@ApiOperation({ summary: 'Owner von Kontakt entfernen' })
|
||||
@ApiParam({ name: 'id', type: 'string', format: 'uuid' })
|
||||
@ApiParam({ name: 'userId', type: 'string', format: 'uuid' })
|
||||
async removeOwner(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Param('userId', ParseUUIDPipe) userId: string,
|
||||
) {
|
||||
const owner = await this.ownersService.removeContactOwner(
|
||||
user.tenantId!,
|
||||
id,
|
||||
userId,
|
||||
);
|
||||
return singleResponse(owner);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,9 +2,10 @@ import { Module } from '@nestjs/common';
|
|||
import { ContactsController } from './contacts.controller';
|
||||
import { ContactsService } from './contacts.service';
|
||||
import { LexwareModule } from '../lexware/lexware.module';
|
||||
import { OwnersModule } from '../owners/owners.module';
|
||||
|
||||
@Module({
|
||||
imports: [LexwareModule],
|
||||
imports: [LexwareModule, OwnersModule],
|
||||
controllers: [ContactsController],
|
||||
providers: [ContactsService],
|
||||
exports: [ContactsService],
|
||||
|
|
|
|||
|
|
@ -4,7 +4,9 @@ import { CreateContactDto } from './dto/create-contact.dto';
|
|||
import { UpdateContactDto } from './dto/update-contact.dto';
|
||||
import { QueryContactsDto } from './dto/query-contacts.dto';
|
||||
import { LexwareContactsService } from '../lexware/lexware-contacts.service';
|
||||
import { CrmEventPublisher } from '../events/crm-event-publisher.service';
|
||||
import { Prisma } from '.prisma/crm-client';
|
||||
import { EntityStatus } from '../common/dto/contact-info.dto';
|
||||
|
||||
@Injectable()
|
||||
export class ContactsService {
|
||||
|
|
@ -13,10 +15,58 @@ export class ContactsService {
|
|||
constructor(
|
||||
private readonly prisma: CrmPrismaService,
|
||||
private readonly lexwareContacts: LexwareContactsService,
|
||||
private readonly eventPublisher: CrmEventPublisher,
|
||||
) {}
|
||||
|
||||
async create(tenantId: string, userId: string, dto: CreateContactDto) {
|
||||
return this.prisma.contact.create({
|
||||
// Status aus DTO ableiten (status hat Vorrang vor isActive)
|
||||
const status = dto.status
|
||||
?? (dto.isActive === false ? EntityStatus.INACTIVE : EntityStatus.ACTIVE);
|
||||
const isActive = status !== EntityStatus.INACTIVE && status !== EntityStatus.BLOCKED;
|
||||
|
||||
// Legacy email/phone/mobile: aus Multi-Value Primary ableiten oder aus DTO
|
||||
const primaryEmail = dto.emails?.find((e) => e.isPrimary)?.email
|
||||
?? dto.emails?.[0]?.email ?? dto.email;
|
||||
const primaryPhone = dto.phones?.find((p) => p.isPrimary)?.phone
|
||||
?? dto.phones?.find((p) => p.type === 'OFFICE')?.phone
|
||||
?? dto.phones?.[0]?.phone ?? dto.phone;
|
||||
const primaryMobile = dto.phones?.find((p) => p.type === 'MOBILE')?.phone
|
||||
?? dto.mobile;
|
||||
|
||||
// Multi-Value emails vorbereiten
|
||||
const emailsCreate: Prisma.ContactEmailCreateWithoutContactInput[] = [];
|
||||
if (dto.emails && dto.emails.length > 0) {
|
||||
dto.emails.forEach((e) => {
|
||||
emailsCreate.push({
|
||||
email: e.email,
|
||||
type: e.type ?? 'WORK',
|
||||
isPrimary: e.isPrimary ?? false,
|
||||
});
|
||||
});
|
||||
} else if (dto.email) {
|
||||
emailsCreate.push({ email: dto.email, type: 'WORK', isPrimary: true });
|
||||
}
|
||||
|
||||
// Multi-Value phones vorbereiten
|
||||
const phonesCreate: Prisma.ContactPhoneCreateWithoutContactInput[] = [];
|
||||
if (dto.phones && dto.phones.length > 0) {
|
||||
dto.phones.forEach((p) => {
|
||||
phonesCreate.push({
|
||||
phone: p.phone,
|
||||
type: p.type ?? 'OFFICE',
|
||||
isPrimary: p.isPrimary ?? false,
|
||||
});
|
||||
});
|
||||
} else {
|
||||
if (dto.phone) {
|
||||
phonesCreate.push({ phone: dto.phone, type: 'OFFICE', isPrimary: true });
|
||||
}
|
||||
if (dto.mobile) {
|
||||
phonesCreate.push({ phone: dto.mobile, type: 'MOBILE', isPrimary: false });
|
||||
}
|
||||
}
|
||||
|
||||
const contact = await this.prisma.contact.create({
|
||||
data: {
|
||||
tenantId,
|
||||
createdBy: userId,
|
||||
|
|
@ -26,10 +76,14 @@ export class ContactsService {
|
|||
companyId: dto.companyId,
|
||||
companyName: dto.companyName,
|
||||
position: dto.position,
|
||||
email: dto.email,
|
||||
phone: dto.phone,
|
||||
mobile: dto.mobile,
|
||||
email: primaryEmail,
|
||||
phone: primaryPhone,
|
||||
mobile: primaryMobile,
|
||||
website: dto.website,
|
||||
linkedinUrl: dto.linkedinUrl,
|
||||
birthday: dto.birthday ? new Date(dto.birthday) : undefined,
|
||||
source: dto.source,
|
||||
department: dto.department,
|
||||
street: dto.street,
|
||||
zip: dto.zip,
|
||||
city: dto.city,
|
||||
|
|
@ -37,9 +91,26 @@ export class ContactsService {
|
|||
country: dto.country,
|
||||
notes: dto.notes,
|
||||
tags: dto.tags ?? [],
|
||||
isActive: dto.isActive ?? true,
|
||||
status,
|
||||
isActive,
|
||||
emails: emailsCreate.length > 0 ? { create: emailsCreate } : undefined,
|
||||
phones: phonesCreate.length > 0 ? { create: phonesCreate } : undefined,
|
||||
owners: {
|
||||
create: { tenantId, userId, role: 'OWNER' },
|
||||
},
|
||||
},
|
||||
include: {
|
||||
emails: true,
|
||||
phones: true,
|
||||
owners: true,
|
||||
company: { select: { id: true, name: true, industry: true } },
|
||||
},
|
||||
});
|
||||
|
||||
// Event publizieren (async, nicht blockierend)
|
||||
this.eventPublisher.contactCreated(tenantId, contact.id, userId).catch(() => {});
|
||||
|
||||
return contact;
|
||||
}
|
||||
|
||||
async findAll(tenantId: string, query: QueryContactsDto) {
|
||||
|
|
@ -56,6 +127,10 @@ export class ContactsService {
|
|||
where.companyId = query.companyId;
|
||||
}
|
||||
|
||||
if (query.status) {
|
||||
where.status = query.status;
|
||||
}
|
||||
|
||||
if (query.search) {
|
||||
where.OR = [
|
||||
{ firstName: { contains: query.search, mode: 'insensitive' } },
|
||||
|
|
@ -85,6 +160,8 @@ export class ContactsService {
|
|||
orderBy: { [sortField]: query.order ?? 'desc' },
|
||||
include: {
|
||||
company: { select: { id: true, name: true, industry: true } },
|
||||
emails: true,
|
||||
phones: true,
|
||||
},
|
||||
}),
|
||||
this.prisma.contact.count({ where }),
|
||||
|
|
@ -99,6 +176,9 @@ export class ContactsService {
|
|||
include: {
|
||||
company: { select: { id: true, name: true, industry: true, city: true, website: true } },
|
||||
activities: { orderBy: { createdAt: 'desc' }, take: 10 },
|
||||
emails: true,
|
||||
phones: true,
|
||||
owners: true,
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -117,14 +197,97 @@ export class ContactsService {
|
|||
) {
|
||||
await this.findOne(tenantId, id);
|
||||
|
||||
const updated = await this.prisma.contact.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...dto,
|
||||
// DTO-Felder aufteilen: Prisma-Scalar-Felder vs. nested Relations
|
||||
const { emails, phones, isActive, status: dtoStatus, birthday, ...scalarFields } = dto;
|
||||
|
||||
// Status ↔ isActive synchron halten
|
||||
let status: EntityStatus | undefined;
|
||||
let isActiveBool: boolean | undefined;
|
||||
if (dtoStatus !== undefined) {
|
||||
status = dtoStatus;
|
||||
isActiveBool = dtoStatus !== EntityStatus.INACTIVE && dtoStatus !== EntityStatus.BLOCKED;
|
||||
} else if (isActive !== undefined) {
|
||||
isActiveBool = isActive;
|
||||
status = isActive ? EntityStatus.ACTIVE : EntityStatus.INACTIVE;
|
||||
}
|
||||
|
||||
// Replace-Strategie fuer emails/phones in Transaction
|
||||
const updated = await this.prisma.$transaction(async (tx) => {
|
||||
// Emails: Alle loeschen und neu anlegen
|
||||
if (emails !== undefined) {
|
||||
await tx.contactEmail.deleteMany({ where: { contactId: id } });
|
||||
if (emails.length > 0) {
|
||||
await tx.contactEmail.createMany({
|
||||
data: emails.map((e) => ({
|
||||
contactId: id,
|
||||
email: e.email,
|
||||
type: e.type ?? 'WORK',
|
||||
isPrimary: e.isPrimary ?? false,
|
||||
})),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Phones: Alle loeschen und neu anlegen
|
||||
if (phones !== undefined) {
|
||||
await tx.contactPhone.deleteMany({ where: { contactId: id } });
|
||||
if (phones.length > 0) {
|
||||
await tx.contactPhone.createMany({
|
||||
data: phones.map((p) => ({
|
||||
contactId: id,
|
||||
phone: p.phone,
|
||||
type: p.type ?? 'OFFICE',
|
||||
isPrimary: p.isPrimary ?? false,
|
||||
})),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy-Felder aus Multi-Value aktualisieren
|
||||
let legacyEmail: string | null | undefined;
|
||||
let legacyPhone: string | null | undefined;
|
||||
let legacyMobile: string | null | undefined;
|
||||
|
||||
if (emails !== undefined) {
|
||||
legacyEmail = emails.find((e) => e.isPrimary)?.email
|
||||
?? emails[0]?.email ?? null;
|
||||
}
|
||||
if (phones !== undefined) {
|
||||
legacyPhone = phones.find((p) => p.isPrimary)?.phone
|
||||
?? phones.find((p) => p.type === 'OFFICE')?.phone
|
||||
?? phones[0]?.phone ?? null;
|
||||
legacyMobile = phones.find((p) => p.type === 'MOBILE')?.phone ?? null;
|
||||
}
|
||||
|
||||
const updateData: Prisma.ContactUpdateInput = {
|
||||
...scalarFields,
|
||||
updatedBy: userId,
|
||||
},
|
||||
};
|
||||
|
||||
if (birthday !== undefined) {
|
||||
updateData.birthday = birthday ? new Date(birthday) : null;
|
||||
}
|
||||
if (status !== undefined) updateData.status = status;
|
||||
if (isActiveBool !== undefined) updateData.isActive = isActiveBool;
|
||||
if (legacyEmail !== undefined) updateData.email = legacyEmail;
|
||||
if (legacyPhone !== undefined) updateData.phone = legacyPhone;
|
||||
if (legacyMobile !== undefined) updateData.mobile = legacyMobile;
|
||||
|
||||
return tx.contact.update({
|
||||
where: { id },
|
||||
data: updateData,
|
||||
include: {
|
||||
emails: true,
|
||||
phones: true,
|
||||
owners: true,
|
||||
company: { select: { id: true, name: true, industry: true } },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// Event publizieren (async, nicht blockierend)
|
||||
this.eventPublisher.contactUpdated(tenantId, id, userId).catch(() => {});
|
||||
|
||||
// ERP-Push: Wenn Lexware verknuepft UND "ERP"-Tag gesetzt → async Push
|
||||
if (updated.lexwareContactId && updated.tags.includes('ERP')) {
|
||||
this.lexwareContacts
|
||||
|
|
|
|||
|
|
@ -8,8 +8,17 @@ import {
|
|||
MaxLength,
|
||||
IsEmail,
|
||||
IsUrl,
|
||||
IsDateString,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import {
|
||||
CreateEmailDto,
|
||||
CreatePhoneDto,
|
||||
ContactSource,
|
||||
EntityStatus,
|
||||
} from '../../common/dto/contact-info.dto';
|
||||
|
||||
export enum ContactType {
|
||||
PERSON = 'PERSON',
|
||||
|
|
@ -75,6 +84,28 @@ export class CreateContactDto {
|
|||
@MaxLength(500)
|
||||
website?: string;
|
||||
|
||||
@ApiPropertyOptional({ maxLength: 500, description: 'LinkedIn-Profil-URL' })
|
||||
@IsOptional()
|
||||
@IsUrl()
|
||||
@MaxLength(500)
|
||||
linkedinUrl?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Geburtsdatum (ISO 8601)' })
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
birthday?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: ContactSource, description: 'Herkunft/Quelle des Kontakts' })
|
||||
@IsOptional()
|
||||
@IsEnum(ContactSource)
|
||||
source?: ContactSource;
|
||||
|
||||
@ApiPropertyOptional({ maxLength: 200, description: 'Abteilung' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
department?: string;
|
||||
|
||||
@ApiPropertyOptional({ maxLength: 200 })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
|
|
@ -116,8 +147,27 @@ export class CreateContactDto {
|
|||
@IsString({ each: true })
|
||||
tags?: string[];
|
||||
|
||||
@ApiPropertyOptional({ default: true })
|
||||
@ApiPropertyOptional({ enum: EntityStatus, default: EntityStatus.ACTIVE, description: 'Status (ersetzt isActive)' })
|
||||
@IsOptional()
|
||||
@IsEnum(EntityStatus)
|
||||
status?: EntityStatus;
|
||||
|
||||
@ApiPropertyOptional({ default: true, deprecated: true, description: 'DEPRECATED: Bitte status verwenden' })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
|
||||
@ApiPropertyOptional({ type: [CreateEmailDto], description: 'E-Mail-Adressen (Multi-Value)' })
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => CreateEmailDto)
|
||||
emails?: CreateEmailDto[];
|
||||
|
||||
@ApiPropertyOptional({ type: [CreatePhoneDto], description: 'Telefonnummern (Multi-Value)' })
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => CreatePhoneDto)
|
||||
phones?: CreatePhoneDto[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { IsString, IsOptional, IsEnum, IsUUID } from 'class-validator';
|
|||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { PaginationDto } from '../../common/dto/pagination.dto';
|
||||
import { ContactType } from './create-contact.dto';
|
||||
import { EntityStatus } from '../../common/dto/contact-info.dto';
|
||||
|
||||
export class QueryContactsDto extends PaginationDto {
|
||||
@ApiPropertyOptional({ description: 'Suchbegriff (Name, Firma, E-Mail)' })
|
||||
|
|
@ -19,6 +20,11 @@ export class QueryContactsDto extends PaginationDto {
|
|||
@IsEnum(ContactType)
|
||||
type?: ContactType;
|
||||
|
||||
@ApiPropertyOptional({ enum: EntityStatus, description: 'Filter nach Status' })
|
||||
@IsOptional()
|
||||
@IsEnum(EntityStatus)
|
||||
status?: EntityStatus;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Sortierfeld', default: 'createdAt' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
|
|
|
|||
|
|
@ -22,6 +22,8 @@ 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 { AddOwnerDto } from '../common/dto/owner.dto';
|
||||
import { OwnersService } from '../owners/owners.service';
|
||||
import { CurrentUser, JwtPayload } from '../common/decorators';
|
||||
import { TenantGuard } from '../auth/guards/tenant.guard';
|
||||
import {
|
||||
|
|
@ -34,7 +36,10 @@ import {
|
|||
@UseGuards(TenantGuard)
|
||||
@Controller('deals')
|
||||
export class DealsController {
|
||||
constructor(private readonly dealsService: DealsService) {}
|
||||
constructor(
|
||||
private readonly dealsService: DealsService,
|
||||
private readonly ownersService: OwnersService,
|
||||
) {}
|
||||
|
||||
@Post()
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
|
|
@ -104,4 +109,42 @@ export class DealsController {
|
|||
const deal = await this.dealsService.remove(user.tenantId!, id);
|
||||
return singleResponse(deal);
|
||||
}
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Owner Endpoints
|
||||
// --------------------------------------------------------
|
||||
|
||||
@Post(':id/owners')
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
@ApiOperation({ summary: 'Owner zu Vorgang hinzufuegen' })
|
||||
@ApiParam({ name: 'id', type: 'string', format: 'uuid' })
|
||||
async addOwner(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: AddOwnerDto,
|
||||
) {
|
||||
const owner = await this.ownersService.addDealOwner(
|
||||
user.tenantId!,
|
||||
id,
|
||||
dto,
|
||||
);
|
||||
return singleResponse(owner);
|
||||
}
|
||||
|
||||
@Delete(':id/owners/:userId')
|
||||
@ApiOperation({ summary: 'Owner von Vorgang entfernen' })
|
||||
@ApiParam({ name: 'id', type: 'string', format: 'uuid' })
|
||||
@ApiParam({ name: 'userId', type: 'string', format: 'uuid' })
|
||||
async removeOwner(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Param('userId', ParseUUIDPipe) userId: string,
|
||||
) {
|
||||
const owner = await this.ownersService.removeDealOwner(
|
||||
user.tenantId!,
|
||||
id,
|
||||
userId,
|
||||
);
|
||||
return singleResponse(owner);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { DealsController } from './deals.controller';
|
||||
import { DealsService } from './deals.service';
|
||||
import { OwnersModule } from '../owners/owners.module';
|
||||
|
||||
@Module({
|
||||
imports: [OwnersModule],
|
||||
controllers: [DealsController],
|
||||
providers: [DealsService],
|
||||
exports: [DealsService],
|
||||
|
|
|
|||
|
|
@ -1,13 +1,21 @@
|
|||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
BadRequestException,
|
||||
} 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 { CrmEventPublisher } from '../events/crm-event-publisher.service';
|
||||
import { Prisma } from '.prisma/crm-client';
|
||||
|
||||
@Injectable()
|
||||
export class DealsService {
|
||||
constructor(private readonly prisma: CrmPrismaService) {}
|
||||
constructor(
|
||||
private readonly prisma: CrmPrismaService,
|
||||
private readonly eventPublisher: CrmEventPublisher,
|
||||
) {}
|
||||
|
||||
async create(tenantId: string, userId: string, dto: CreateDealDto) {
|
||||
// Pipeline und Stage validieren
|
||||
|
|
@ -45,7 +53,7 @@ export class DealsService {
|
|||
}
|
||||
}
|
||||
|
||||
return this.prisma.deal.create({
|
||||
const deal = await this.prisma.deal.create({
|
||||
data: {
|
||||
tenantId,
|
||||
pipelineId: dto.pipelineId,
|
||||
|
|
@ -60,7 +68,12 @@ export class DealsService {
|
|||
? new Date(dto.expectedCloseDate)
|
||||
: undefined,
|
||||
notes: dto.notes,
|
||||
lostReason: dto.lostReason,
|
||||
lostReasonText: dto.lostReasonText,
|
||||
createdBy: userId,
|
||||
owners: {
|
||||
create: { tenantId, userId, role: 'OWNER' },
|
||||
},
|
||||
},
|
||||
include: {
|
||||
pipeline: { select: { id: true, name: true } },
|
||||
|
|
@ -79,8 +92,14 @@ export class DealsService {
|
|||
name: true,
|
||||
},
|
||||
},
|
||||
owners: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Event publizieren (async, nicht blockierend)
|
||||
this.eventPublisher.dealCreated(tenantId, deal.id, userId).catch(() => {});
|
||||
|
||||
return deal;
|
||||
}
|
||||
|
||||
async findAll(tenantId: string, query: QueryDealsDto) {
|
||||
|
|
@ -142,6 +161,7 @@ export class DealsService {
|
|||
name: true,
|
||||
},
|
||||
},
|
||||
owners: true,
|
||||
},
|
||||
}),
|
||||
this.prisma.deal.count({ where }),
|
||||
|
|
@ -158,6 +178,7 @@ export class DealsService {
|
|||
stage: true,
|
||||
contact: true,
|
||||
company: true,
|
||||
owners: true,
|
||||
dealVouchers: {
|
||||
include: {
|
||||
voucher: {
|
||||
|
|
@ -192,12 +213,11 @@ export class DealsService {
|
|||
userId: string,
|
||||
dto: UpdateDealDto,
|
||||
) {
|
||||
await this.findOne(tenantId, id);
|
||||
const existing = 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 pipelineId = dto.pipelineId ?? existing.pipelineId;
|
||||
const stage = await this.prisma.pipelineStage.findFirst({
|
||||
where: { id: dto.stageId, pipelineId },
|
||||
});
|
||||
|
|
@ -206,20 +226,46 @@ export class DealsService {
|
|||
}
|
||||
}
|
||||
|
||||
// Lost-Reason Validation
|
||||
if (dto.status === 'LOST') {
|
||||
// Wenn LOST gesetzt wird, muss lostReason vorhanden sein (im DTO oder bereits gesetzt)
|
||||
if (!dto.lostReason && !existing.lostReason) {
|
||||
throw new BadRequestException(
|
||||
'Verlustgrund (lostReason) ist Pflicht wenn Deal auf LOST gesetzt wird',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const { lostReason, lostReasonText, ...restDto } = dto;
|
||||
|
||||
const updateData: Prisma.DealUpdateInput = {
|
||||
...dto,
|
||||
...restDto,
|
||||
expectedCloseDate: dto.expectedCloseDate
|
||||
? new Date(dto.expectedCloseDate)
|
||||
: undefined,
|
||||
updatedBy: userId,
|
||||
};
|
||||
|
||||
// Lost-Reason Felder setzen
|
||||
if (lostReason !== undefined) {
|
||||
updateData.lostReason = lostReason;
|
||||
}
|
||||
if (lostReasonText !== undefined) {
|
||||
updateData.lostReasonText = lostReasonText;
|
||||
}
|
||||
|
||||
// Wenn Deal gewonnen → lostReason und lostReasonText loeschen
|
||||
if (dto.status === 'WON') {
|
||||
updateData.lostReason = null;
|
||||
updateData.lostReasonText = null;
|
||||
}
|
||||
|
||||
// Wenn Deal gewonnen/verloren, closedAt setzen
|
||||
if (dto.status === 'WON' || dto.status === 'LOST') {
|
||||
updateData.closedAt = new Date();
|
||||
}
|
||||
|
||||
return this.prisma.deal.update({
|
||||
const updated = await this.prisma.deal.update({
|
||||
where: { id },
|
||||
data: updateData,
|
||||
include: {
|
||||
|
|
@ -233,8 +279,38 @@ export class DealsService {
|
|||
companyName: true,
|
||||
},
|
||||
},
|
||||
company: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
owners: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Events publizieren (async, nicht blockierend)
|
||||
if (dto.stageId && dto.stageId !== existing.stageId) {
|
||||
this.eventPublisher.dealStageChanged(tenantId, id, userId, {
|
||||
previousStageId: existing.stageId,
|
||||
newStageId: dto.stageId,
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
if (dto.status === 'WON') {
|
||||
this.eventPublisher.dealWon(tenantId, id, userId, {
|
||||
value: updated.value?.toNumber() ?? null,
|
||||
currency: updated.currency ?? 'EUR',
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
if (dto.status === 'LOST') {
|
||||
this.eventPublisher.dealLost(tenantId, id, userId, {
|
||||
lostReason: updated.lostReason ?? null,
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
async remove(tenantId: string, id: string) {
|
||||
|
|
|
|||
|
|
@ -16,6 +16,14 @@ export enum DealStatus {
|
|||
LOST = 'LOST',
|
||||
}
|
||||
|
||||
export enum LostReason {
|
||||
PRICE = 'PRICE',
|
||||
TIMING = 'TIMING',
|
||||
COMPETITOR = 'COMPETITOR',
|
||||
NO_NEED = 'NO_NEED',
|
||||
OTHER = 'OTHER',
|
||||
}
|
||||
|
||||
export class CreateDealDto {
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
@IsUUID()
|
||||
|
|
@ -66,4 +74,14 @@ export class CreateDealDto {
|
|||
@IsOptional()
|
||||
@IsString()
|
||||
notes?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: LostReason, description: 'Verlustgrund (Pflicht bei Status LOST)' })
|
||||
@IsOptional()
|
||||
@IsEnum(LostReason)
|
||||
lostReason?: LostReason;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Freitext-Begruendung zum Verlustgrund' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
lostReasonText?: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,85 @@
|
|||
// ============================================================
|
||||
// Scheduler: Activity Due Soon - alle 15 Minuten
|
||||
// ============================================================
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Cron } from '@nestjs/schedule';
|
||||
import { CrmPrismaService } from '../prisma/crm-prisma.service';
|
||||
import { RedisService } from '../redis/redis.service';
|
||||
import { CrmEventPublisher } from './crm-event-publisher.service';
|
||||
|
||||
@Injectable()
|
||||
export class ActivityDueSoonScheduler {
|
||||
private readonly logger = new Logger(ActivityDueSoonScheduler.name);
|
||||
private static readonly LOCK_KEY = 'lock:activity-due-soon';
|
||||
private static readonly LOCK_TTL = 600; // 10 Minuten
|
||||
|
||||
constructor(
|
||||
private readonly prisma: CrmPrismaService,
|
||||
private readonly redis: RedisService,
|
||||
private readonly eventPublisher: CrmEventPublisher,
|
||||
) {}
|
||||
|
||||
@Cron('0 */15 * * * *')
|
||||
async checkDueSoon(): Promise<void> {
|
||||
// Distributed Lock um parallele Ausfuehrung zu verhindern
|
||||
const lockAcquired = await this.redis.setNx(
|
||||
ActivityDueSoonScheduler.LOCK_KEY,
|
||||
process.pid.toString(),
|
||||
ActivityDueSoonScheduler.LOCK_TTL,
|
||||
);
|
||||
|
||||
if (!lockAcquired) {
|
||||
this.logger.debug('Activity Due Soon Check: Lock bereits vergeben, ueberspringe.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const now = new Date();
|
||||
const in24h = new Date(now.getTime() + 24 * 60 * 60 * 1000);
|
||||
|
||||
// Activities die in den naechsten 24h faellig sind und nicht completed
|
||||
const dueActivities = await this.prisma.activity.findMany({
|
||||
where: {
|
||||
scheduledAt: {
|
||||
gte: now,
|
||||
lte: in24h,
|
||||
},
|
||||
completedAt: null,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
tenantId: true,
|
||||
scheduledAt: true,
|
||||
contactId: true,
|
||||
companyId: true,
|
||||
},
|
||||
take: 100, // Batch-Limit
|
||||
});
|
||||
|
||||
if (dueActivities.length === 0) {
|
||||
this.logger.debug('Keine faelligen Activities gefunden.');
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`${dueActivities.length} faellige Activities gefunden, publiziere Events...`,
|
||||
);
|
||||
|
||||
for (const activity of dueActivities) {
|
||||
await this.eventPublisher.activityDueSoon(
|
||||
activity.tenantId,
|
||||
activity.id,
|
||||
{
|
||||
scheduledAt: activity.scheduledAt?.toISOString() ?? '',
|
||||
contactId: activity.contactId ?? undefined,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
this.logger.log(`${dueActivities.length} activity.due_soon Events publiziert.`);
|
||||
} catch (err) {
|
||||
this.logger.error(`Fehler im Activity Due Soon Scheduler: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
184
packages/crm-service/src/events/crm-event-publisher.service.ts
Normal file
184
packages/crm-service/src/events/crm-event-publisher.service.ts
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
// ============================================================
|
||||
// CRM Event Publisher - Redis Pub/Sub
|
||||
// ============================================================
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { RedisService } from '../redis/redis.service';
|
||||
|
||||
export interface CrmEvent {
|
||||
type: string;
|
||||
tenantId: string;
|
||||
entityId: string;
|
||||
userId: string;
|
||||
timestamp: string;
|
||||
payload: Record<string, unknown>;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class CrmEventPublisher {
|
||||
private readonly logger = new Logger(CrmEventPublisher.name);
|
||||
|
||||
constructor(private readonly redis: RedisService) {}
|
||||
|
||||
/**
|
||||
* Generische Publish-Methode.
|
||||
* Channel-Format: app:crm:events:{type}
|
||||
*/
|
||||
private async publish(event: CrmEvent): Promise<void> {
|
||||
const channel = `app:crm:events:${event.type}`;
|
||||
try {
|
||||
const receivers = await this.redis.publish(channel, event as unknown as Record<string, unknown>);
|
||||
this.logger.debug(
|
||||
`Event "${event.type}" publiziert auf "${channel}" (${receivers} Empfaenger)`,
|
||||
);
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Fehler beim Publizieren von Event "${event.type}": ${String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Contact Events
|
||||
// --------------------------------------------------------
|
||||
|
||||
async contactCreated(tenantId: string, contactId: string, userId: string): Promise<void> {
|
||||
await this.publish({
|
||||
type: 'crm.contact.created',
|
||||
tenantId,
|
||||
entityId: contactId,
|
||||
userId,
|
||||
timestamp: new Date().toISOString(),
|
||||
payload: {},
|
||||
});
|
||||
}
|
||||
|
||||
async contactUpdated(
|
||||
tenantId: string,
|
||||
contactId: string,
|
||||
userId: string,
|
||||
payload: Record<string, unknown> = {},
|
||||
): Promise<void> {
|
||||
await this.publish({
|
||||
type: 'crm.contact.updated',
|
||||
tenantId,
|
||||
entityId: contactId,
|
||||
userId,
|
||||
timestamp: new Date().toISOString(),
|
||||
payload,
|
||||
});
|
||||
}
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Company Events
|
||||
// --------------------------------------------------------
|
||||
|
||||
async companyCreated(tenantId: string, companyId: string, userId: string): Promise<void> {
|
||||
await this.publish({
|
||||
type: 'crm.company.created',
|
||||
tenantId,
|
||||
entityId: companyId,
|
||||
userId,
|
||||
timestamp: new Date().toISOString(),
|
||||
payload: {},
|
||||
});
|
||||
}
|
||||
|
||||
async companyUpdated(
|
||||
tenantId: string,
|
||||
companyId: string,
|
||||
userId: string,
|
||||
payload: Record<string, unknown> = {},
|
||||
): Promise<void> {
|
||||
await this.publish({
|
||||
type: 'crm.company.updated',
|
||||
tenantId,
|
||||
entityId: companyId,
|
||||
userId,
|
||||
timestamp: new Date().toISOString(),
|
||||
payload,
|
||||
});
|
||||
}
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Deal Events
|
||||
// --------------------------------------------------------
|
||||
|
||||
async dealCreated(tenantId: string, dealId: string, userId: string): Promise<void> {
|
||||
await this.publish({
|
||||
type: 'crm.deal.created',
|
||||
tenantId,
|
||||
entityId: dealId,
|
||||
userId,
|
||||
timestamp: new Date().toISOString(),
|
||||
payload: {},
|
||||
});
|
||||
}
|
||||
|
||||
async dealStageChanged(
|
||||
tenantId: string,
|
||||
dealId: string,
|
||||
userId: string,
|
||||
payload: { previousStageId: string; newStageId: string },
|
||||
): Promise<void> {
|
||||
await this.publish({
|
||||
type: 'crm.deal.stage_changed',
|
||||
tenantId,
|
||||
entityId: dealId,
|
||||
userId,
|
||||
timestamp: new Date().toISOString(),
|
||||
payload,
|
||||
});
|
||||
}
|
||||
|
||||
async dealWon(
|
||||
tenantId: string,
|
||||
dealId: string,
|
||||
userId: string,
|
||||
payload: { value?: number | null; currency?: string },
|
||||
): Promise<void> {
|
||||
await this.publish({
|
||||
type: 'crm.deal.won',
|
||||
tenantId,
|
||||
entityId: dealId,
|
||||
userId,
|
||||
timestamp: new Date().toISOString(),
|
||||
payload,
|
||||
});
|
||||
}
|
||||
|
||||
async dealLost(
|
||||
tenantId: string,
|
||||
dealId: string,
|
||||
userId: string,
|
||||
payload: { lostReason?: string | null },
|
||||
): Promise<void> {
|
||||
await this.publish({
|
||||
type: 'crm.deal.lost',
|
||||
tenantId,
|
||||
entityId: dealId,
|
||||
userId,
|
||||
timestamp: new Date().toISOString(),
|
||||
payload,
|
||||
});
|
||||
}
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Activity Events
|
||||
// --------------------------------------------------------
|
||||
|
||||
async activityDueSoon(
|
||||
tenantId: string,
|
||||
activityId: string,
|
||||
payload: { scheduledAt: string; contactId?: string; dealId?: string },
|
||||
): Promise<void> {
|
||||
await this.publish({
|
||||
type: 'crm.activity.due_soon',
|
||||
tenantId,
|
||||
entityId: activityId,
|
||||
userId: 'system',
|
||||
timestamp: new Date().toISOString(),
|
||||
payload,
|
||||
});
|
||||
}
|
||||
}
|
||||
10
packages/crm-service/src/events/crm-events.module.ts
Normal file
10
packages/crm-service/src/events/crm-events.module.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { Global, Module } from '@nestjs/common';
|
||||
import { CrmEventPublisher } from './crm-event-publisher.service';
|
||||
import { ActivityDueSoonScheduler } from './activity-due-soon.scheduler';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [CrmEventPublisher, ActivityDueSoonScheduler],
|
||||
exports: [CrmEventPublisher],
|
||||
})
|
||||
export class CrmEventsModule {}
|
||||
|
|
@ -232,17 +232,38 @@ export class LexwareContactsService {
|
|||
|
||||
const lexwareContact = await this.getContact(lexwareContactId);
|
||||
const companyData = lexwareContactToCompanyData(lexwareContact);
|
||||
const { emails, phones, ...scalarData } = companyData;
|
||||
|
||||
return this.prisma.company.create({
|
||||
data: {
|
||||
tenantId,
|
||||
...companyData,
|
||||
...scalarData,
|
||||
lexwareContactId,
|
||||
lexwareContactVersion: lexwareContact.version,
|
||||
lexwareSyncedAt: new Date(),
|
||||
createdBy: userId,
|
||||
emails: emails.length > 0 ? {
|
||||
create: emails.map((e) => ({
|
||||
email: e.email,
|
||||
type: e.type,
|
||||
isPrimary: e.isPrimary,
|
||||
})),
|
||||
} : undefined,
|
||||
phones: phones.length > 0 ? {
|
||||
create: phones.map((p) => ({
|
||||
phone: p.phone,
|
||||
type: p.type,
|
||||
isPrimary: p.isPrimary,
|
||||
})),
|
||||
} : undefined,
|
||||
owners: {
|
||||
create: { tenantId, userId, role: 'OWNER' },
|
||||
},
|
||||
},
|
||||
include: {
|
||||
emails: true,
|
||||
phones: true,
|
||||
owners: true,
|
||||
_count: { select: { contacts: true, deals: true } },
|
||||
},
|
||||
});
|
||||
|
|
@ -270,15 +291,38 @@ export class LexwareContactsService {
|
|||
|
||||
const lexwareContact = await this.getContact(lexwareContactId);
|
||||
const contactData = lexwareContactToContactData(lexwareContact);
|
||||
const { emails, phones, ...scalarData } = contactData;
|
||||
|
||||
return this.prisma.contact.create({
|
||||
data: {
|
||||
tenantId,
|
||||
...contactData,
|
||||
...scalarData,
|
||||
lexwareContactId,
|
||||
lexwareContactVersion: lexwareContact.version,
|
||||
lexwareSyncedAt: new Date(),
|
||||
createdBy: userId,
|
||||
emails: emails.length > 0 ? {
|
||||
create: emails.map((e) => ({
|
||||
email: e.email,
|
||||
type: e.type,
|
||||
isPrimary: e.isPrimary,
|
||||
})),
|
||||
} : undefined,
|
||||
phones: phones.length > 0 ? {
|
||||
create: phones.map((p) => ({
|
||||
phone: p.phone,
|
||||
type: p.type,
|
||||
isPrimary: p.isPrimary,
|
||||
})),
|
||||
} : undefined,
|
||||
owners: {
|
||||
create: { tenantId, userId, role: 'OWNER' },
|
||||
},
|
||||
},
|
||||
include: {
|
||||
emails: true,
|
||||
phones: true,
|
||||
owners: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -290,6 +334,7 @@ export class LexwareContactsService {
|
|||
async pushCompanyToLexware(tenantId: string, companyId: string) {
|
||||
const company = await this.prisma.company.findFirst({
|
||||
where: { id: companyId, tenantId },
|
||||
include: { emails: true, phones: true },
|
||||
});
|
||||
if (!company) {
|
||||
throw new NotFoundException('Unternehmen nicht gefunden');
|
||||
|
|
@ -347,6 +392,7 @@ export class LexwareContactsService {
|
|||
async pushContactToLexware(tenantId: string, contactId: string) {
|
||||
const contact = await this.prisma.contact.findFirst({
|
||||
where: { id: contactId, tenantId },
|
||||
include: { emails: true, phones: true },
|
||||
});
|
||||
if (!contact) {
|
||||
throw new NotFoundException('Kontakt nicht gefunden');
|
||||
|
|
@ -422,15 +468,49 @@ export class LexwareContactsService {
|
|||
|
||||
const lexwareContact = await this.getContact(company.lexwareContactId);
|
||||
const companyData = lexwareContactToCompanyData(lexwareContact);
|
||||
const { emails, phones, ...scalarData } = companyData;
|
||||
|
||||
return this.prisma.company.update({
|
||||
where: { id: companyId },
|
||||
data: {
|
||||
...companyData,
|
||||
lexwareContactVersion: lexwareContact.version,
|
||||
lexwareSyncedAt: new Date(),
|
||||
updatedBy: userId,
|
||||
},
|
||||
// Transaction: Scalar-Update + emails/phones Replace
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
// Bestehende emails/phones loeschen
|
||||
await tx.contactEmail.deleteMany({ where: { companyId } });
|
||||
await tx.contactPhone.deleteMany({ where: { companyId } });
|
||||
|
||||
// Neue emails/phones anlegen
|
||||
if (emails.length > 0) {
|
||||
await tx.contactEmail.createMany({
|
||||
data: emails.map((e) => ({
|
||||
companyId,
|
||||
email: e.email,
|
||||
type: e.type,
|
||||
isPrimary: e.isPrimary,
|
||||
})),
|
||||
});
|
||||
}
|
||||
if (phones.length > 0) {
|
||||
await tx.contactPhone.createMany({
|
||||
data: phones.map((p) => ({
|
||||
companyId,
|
||||
phone: p.phone,
|
||||
type: p.type,
|
||||
isPrimary: p.isPrimary,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
return tx.company.update({
|
||||
where: { id: companyId },
|
||||
data: {
|
||||
...scalarData,
|
||||
lexwareContactVersion: lexwareContact.version,
|
||||
lexwareSyncedAt: new Date(),
|
||||
updatedBy: userId,
|
||||
},
|
||||
include: {
|
||||
emails: true,
|
||||
phones: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -451,15 +531,49 @@ export class LexwareContactsService {
|
|||
|
||||
const lexwareContact = await this.getContact(contact.lexwareContactId);
|
||||
const contactData = lexwareContactToContactData(lexwareContact);
|
||||
const { emails, phones, ...scalarData } = contactData;
|
||||
|
||||
return this.prisma.contact.update({
|
||||
where: { id: contactId },
|
||||
data: {
|
||||
...contactData,
|
||||
lexwareContactVersion: lexwareContact.version,
|
||||
lexwareSyncedAt: new Date(),
|
||||
updatedBy: userId,
|
||||
},
|
||||
// Transaction: Scalar-Update + emails/phones Replace
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
// Bestehende emails/phones loeschen
|
||||
await tx.contactEmail.deleteMany({ where: { contactId } });
|
||||
await tx.contactPhone.deleteMany({ where: { contactId } });
|
||||
|
||||
// Neue emails/phones anlegen
|
||||
if (emails.length > 0) {
|
||||
await tx.contactEmail.createMany({
|
||||
data: emails.map((e) => ({
|
||||
contactId,
|
||||
email: e.email,
|
||||
type: e.type,
|
||||
isPrimary: e.isPrimary,
|
||||
})),
|
||||
});
|
||||
}
|
||||
if (phones.length > 0) {
|
||||
await tx.contactPhone.createMany({
|
||||
data: phones.map((p) => ({
|
||||
contactId,
|
||||
phone: p.phone,
|
||||
type: p.type,
|
||||
isPrimary: p.isPrimary,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
return tx.contact.update({
|
||||
where: { id: contactId },
|
||||
data: {
|
||||
...scalarData,
|
||||
lexwareContactVersion: lexwareContact.version,
|
||||
lexwareSyncedAt: new Date(),
|
||||
updatedBy: userId,
|
||||
},
|
||||
include: {
|
||||
emails: true,
|
||||
phones: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,21 @@ import {
|
|||
LexwareVoucherListItem,
|
||||
} from '../interfaces/lexware-api.interfaces';
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Typen fuer Multi-Value Email/Phone Arrays
|
||||
// --------------------------------------------------------
|
||||
interface EmailEntry {
|
||||
email: string;
|
||||
type: 'WORK' | 'PERSONAL' | 'OTHER';
|
||||
isPrimary: boolean;
|
||||
}
|
||||
|
||||
interface PhoneEntry {
|
||||
phone: string;
|
||||
type: 'OFFICE' | 'MOBILE' | 'FAX';
|
||||
isPrimary: boolean;
|
||||
}
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Lexware Contact -> CRM Company
|
||||
// --------------------------------------------------------
|
||||
|
|
@ -22,6 +37,8 @@ export function lexwareContactToCompanyData(lc: LexwareContact): {
|
|||
city?: string;
|
||||
country?: string;
|
||||
notes?: string;
|
||||
emails: EmailEntry[];
|
||||
phones: PhoneEntry[];
|
||||
} {
|
||||
const name =
|
||||
lc.company?.name || [lc.person?.firstName, lc.person?.lastName].filter(Boolean).join(' ') || 'Unbekannt';
|
||||
|
|
@ -39,6 +56,8 @@ export function lexwareContactToCompanyData(lc: LexwareContact): {
|
|||
city: billingAddr?.city || undefined,
|
||||
country: billingAddr?.countryCode || 'DE',
|
||||
notes: lc.note || undefined,
|
||||
emails: extractAllEmails(lc),
|
||||
phones: extractAllPhones(lc),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -59,12 +78,16 @@ export function lexwareContactToContactData(lc: LexwareContact): {
|
|||
country?: string;
|
||||
notes?: string;
|
||||
type: 'PERSON' | 'ORGANIZATION';
|
||||
emails: EmailEntry[];
|
||||
phones: PhoneEntry[];
|
||||
} {
|
||||
const isPerson = !!lc.person;
|
||||
const billingAddr = lc.addresses?.billing?.[0];
|
||||
const email = getFirstEmail(lc);
|
||||
const phone = getFirstPhone(lc);
|
||||
const mobile = lc.phoneNumbers?.mobile?.[0];
|
||||
const emails = extractAllEmails(lc);
|
||||
const phones = extractAllPhones(lc);
|
||||
|
||||
if (isPerson) {
|
||||
return {
|
||||
|
|
@ -83,6 +106,8 @@ export function lexwareContactToContactData(lc: LexwareContact): {
|
|||
city: billingAddr?.city || undefined,
|
||||
country: billingAddr?.countryCode || 'DE',
|
||||
notes: lc.note || undefined,
|
||||
emails,
|
||||
phones,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -100,6 +125,8 @@ export function lexwareContactToContactData(lc: LexwareContact): {
|
|||
city: billingAddr?.city || undefined,
|
||||
country: billingAddr?.countryCode || 'DE',
|
||||
notes: lc.note || undefined,
|
||||
emails,
|
||||
phones,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -115,6 +142,8 @@ export function companyToLexwareContactBody(company: {
|
|||
city?: string | null;
|
||||
country?: string | null;
|
||||
notes?: string | null;
|
||||
emails?: Array<{ email: string; type: string }>;
|
||||
phones?: Array<{ phone: string; type: string }>;
|
||||
}): Record<string, unknown> {
|
||||
const body: Record<string, unknown> = {
|
||||
version: 0,
|
||||
|
|
@ -135,11 +164,29 @@ export function companyToLexwareContactBody(company: {
|
|||
};
|
||||
}
|
||||
|
||||
if (company.email) {
|
||||
// Multi-Value emails → Lexware categories
|
||||
if (company.emails && company.emails.length > 0) {
|
||||
const emailAddresses: Record<string, string[]> = {};
|
||||
for (const e of company.emails) {
|
||||
const category = emailTypeToLexwareCategory(e.type);
|
||||
if (!emailAddresses[category]) emailAddresses[category] = [];
|
||||
emailAddresses[category].push(e.email);
|
||||
}
|
||||
body.emailAddresses = emailAddresses;
|
||||
} else if (company.email) {
|
||||
body.emailAddresses = { business: [company.email] };
|
||||
}
|
||||
|
||||
if (company.phone) {
|
||||
// Multi-Value phones → Lexware categories
|
||||
if (company.phones && company.phones.length > 0) {
|
||||
const phoneNumbers: Record<string, string[]> = {};
|
||||
for (const p of company.phones) {
|
||||
const category = phoneTypeToLexwareCategory(p.type);
|
||||
if (!phoneNumbers[category]) phoneNumbers[category] = [];
|
||||
phoneNumbers[category].push(p.phone);
|
||||
}
|
||||
body.phoneNumbers = phoneNumbers;
|
||||
} else if (company.phone) {
|
||||
body.phoneNumbers = { business: [company.phone] };
|
||||
}
|
||||
|
||||
|
|
@ -166,6 +213,8 @@ export function contactToLexwareContactBody(contact: {
|
|||
country?: string | null;
|
||||
notes?: string | null;
|
||||
type: string;
|
||||
emails?: Array<{ email: string; type: string }>;
|
||||
phones?: Array<{ phone: string; type: string }>;
|
||||
}): Record<string, unknown> {
|
||||
const body: Record<string, unknown> = {
|
||||
version: 0,
|
||||
|
|
@ -194,15 +243,35 @@ export function contactToLexwareContactBody(contact: {
|
|||
};
|
||||
}
|
||||
|
||||
if (contact.email) {
|
||||
// Multi-Value emails → Lexware categories
|
||||
if (contact.emails && contact.emails.length > 0) {
|
||||
const emailAddresses: Record<string, string[]> = {};
|
||||
for (const e of contact.emails) {
|
||||
const category = emailTypeToLexwareCategory(e.type);
|
||||
if (!emailAddresses[category]) emailAddresses[category] = [];
|
||||
emailAddresses[category].push(e.email);
|
||||
}
|
||||
body.emailAddresses = emailAddresses;
|
||||
} else if (contact.email) {
|
||||
body.emailAddresses = { business: [contact.email] };
|
||||
}
|
||||
|
||||
const phoneNumbers: Record<string, string[]> = {};
|
||||
if (contact.phone) phoneNumbers.business = [contact.phone];
|
||||
if (contact.mobile) phoneNumbers.mobile = [contact.mobile];
|
||||
if (Object.keys(phoneNumbers).length > 0) {
|
||||
// Multi-Value phones → Lexware categories
|
||||
if (contact.phones && contact.phones.length > 0) {
|
||||
const phoneNumbers: Record<string, string[]> = {};
|
||||
for (const p of contact.phones) {
|
||||
const category = phoneTypeToLexwareCategory(p.type);
|
||||
if (!phoneNumbers[category]) phoneNumbers[category] = [];
|
||||
phoneNumbers[category].push(p.phone);
|
||||
}
|
||||
body.phoneNumbers = phoneNumbers;
|
||||
} else {
|
||||
const phoneNumbers: Record<string, string[]> = {};
|
||||
if (contact.phone) phoneNumbers.business = [contact.phone];
|
||||
if (contact.mobile) phoneNumbers.mobile = [contact.mobile];
|
||||
if (Object.keys(phoneNumbers).length > 0) {
|
||||
body.phoneNumbers = phoneNumbers;
|
||||
}
|
||||
}
|
||||
|
||||
if (contact.notes) {
|
||||
|
|
@ -324,6 +393,84 @@ function getFirstPhone(lc: LexwareContact): string | undefined {
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Alle Lexware-Emails in Multi-Value Array konvertieren.
|
||||
* Mapping: business/office → WORK, private → PERSONAL, other → OTHER
|
||||
*/
|
||||
function extractAllEmails(lc: LexwareContact): EmailEntry[] {
|
||||
const result: EmailEntry[] = [];
|
||||
const emails = lc.emailAddresses;
|
||||
if (!emails) return result;
|
||||
|
||||
let isFirst = true;
|
||||
const addEmails = (addresses: string[] | undefined, type: EmailEntry['type']) => {
|
||||
if (!addresses) return;
|
||||
for (const addr of addresses) {
|
||||
result.push({ email: addr, type, isPrimary: isFirst });
|
||||
isFirst = false;
|
||||
}
|
||||
};
|
||||
|
||||
addEmails(emails.business, 'WORK');
|
||||
addEmails(emails.office, 'WORK');
|
||||
addEmails(emails.private, 'PERSONAL');
|
||||
addEmails(emails.other, 'OTHER');
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Alle Lexware-Phones in Multi-Value Array konvertieren.
|
||||
* Mapping: business/office → OFFICE, mobile → MOBILE, fax → FAX, private/other → OFFICE
|
||||
*/
|
||||
function extractAllPhones(lc: LexwareContact): PhoneEntry[] {
|
||||
const result: PhoneEntry[] = [];
|
||||
const phones = lc.phoneNumbers;
|
||||
if (!phones) return result;
|
||||
|
||||
let isFirst = true;
|
||||
const addPhones = (numbers: string[] | undefined, type: PhoneEntry['type']) => {
|
||||
if (!numbers) return;
|
||||
for (const num of numbers) {
|
||||
result.push({ phone: num, type, isPrimary: isFirst });
|
||||
isFirst = false;
|
||||
}
|
||||
};
|
||||
|
||||
addPhones(phones.business, 'OFFICE');
|
||||
addPhones(phones.office, 'OFFICE');
|
||||
addPhones(phones.mobile, 'MOBILE');
|
||||
addPhones(phones.fax, 'FAX');
|
||||
addPhones(phones.private, 'OFFICE');
|
||||
addPhones(phones.other, 'OFFICE');
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* CRM EmailType → Lexware Kategorie
|
||||
*/
|
||||
function emailTypeToLexwareCategory(type: string): string {
|
||||
switch (type) {
|
||||
case 'WORK': return 'business';
|
||||
case 'PERSONAL': return 'private';
|
||||
case 'OTHER': return 'other';
|
||||
default: return 'business';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* CRM PhoneType → Lexware Kategorie
|
||||
*/
|
||||
function phoneTypeToLexwareCategory(type: string): string {
|
||||
switch (type) {
|
||||
case 'OFFICE': return 'business';
|
||||
case 'MOBILE': return 'mobile';
|
||||
case 'FAX': return 'fax';
|
||||
default: return 'business';
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Lexware VoucherList Item -> Voucher Type
|
||||
// --------------------------------------------------------
|
||||
|
|
|
|||
8
packages/crm-service/src/owners/owners.module.ts
Normal file
8
packages/crm-service/src/owners/owners.module.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { OwnersService } from './owners.service';
|
||||
|
||||
@Module({
|
||||
providers: [OwnersService],
|
||||
exports: [OwnersService],
|
||||
})
|
||||
export class OwnersModule {}
|
||||
166
packages/crm-service/src/owners/owners.service.ts
Normal file
166
packages/crm-service/src/owners/owners.service.ts
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
// ============================================================
|
||||
// Shared Owner Service fuer Contact, Company, Deal
|
||||
// ============================================================
|
||||
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { CrmPrismaService } from '../prisma/crm-prisma.service';
|
||||
import { AddOwnerDto } from '../common/dto/owner.dto';
|
||||
|
||||
@Injectable()
|
||||
export class OwnersService {
|
||||
constructor(private readonly prisma: CrmPrismaService) {}
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Contact Owners
|
||||
// --------------------------------------------------------
|
||||
|
||||
async addContactOwner(tenantId: string, contactId: string, dto: AddOwnerDto) {
|
||||
// Contact existiert + gehoert zum Tenant
|
||||
const contact = await this.prisma.contact.findFirst({
|
||||
where: { id: contactId, tenantId },
|
||||
});
|
||||
if (!contact) {
|
||||
throw new NotFoundException('Kontakt nicht gefunden');
|
||||
}
|
||||
|
||||
// Upsert: Falls Owner bereits existiert, Rolle aktualisieren
|
||||
return this.prisma.contactOwner.upsert({
|
||||
where: {
|
||||
contactId_userId: { contactId, userId: dto.userId },
|
||||
},
|
||||
create: {
|
||||
tenantId,
|
||||
contactId,
|
||||
userId: dto.userId,
|
||||
role: dto.role ?? 'OWNER',
|
||||
},
|
||||
update: {
|
||||
role: dto.role ?? 'OWNER',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async removeContactOwner(tenantId: string, contactId: string, userId: string) {
|
||||
// Contact existiert + gehoert zum Tenant
|
||||
const contact = await this.prisma.contact.findFirst({
|
||||
where: { id: contactId, tenantId },
|
||||
});
|
||||
if (!contact) {
|
||||
throw new NotFoundException('Kontakt nicht gefunden');
|
||||
}
|
||||
|
||||
// Owner suchen
|
||||
const owner = await this.prisma.contactOwner.findUnique({
|
||||
where: {
|
||||
contactId_userId: { contactId, userId },
|
||||
},
|
||||
});
|
||||
if (!owner) {
|
||||
throw new NotFoundException('Owner nicht gefunden');
|
||||
}
|
||||
|
||||
return this.prisma.contactOwner.delete({
|
||||
where: { id: owner.id },
|
||||
});
|
||||
}
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Company Owners
|
||||
// --------------------------------------------------------
|
||||
|
||||
async addCompanyOwner(tenantId: string, companyId: string, dto: AddOwnerDto) {
|
||||
const company = await this.prisma.company.findFirst({
|
||||
where: { id: companyId, tenantId },
|
||||
});
|
||||
if (!company) {
|
||||
throw new NotFoundException('Unternehmen nicht gefunden');
|
||||
}
|
||||
|
||||
return this.prisma.companyOwner.upsert({
|
||||
where: {
|
||||
companyId_userId: { companyId, userId: dto.userId },
|
||||
},
|
||||
create: {
|
||||
tenantId,
|
||||
companyId,
|
||||
userId: dto.userId,
|
||||
role: dto.role ?? 'OWNER',
|
||||
},
|
||||
update: {
|
||||
role: dto.role ?? 'OWNER',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async removeCompanyOwner(tenantId: string, companyId: string, userId: string) {
|
||||
const company = await this.prisma.company.findFirst({
|
||||
where: { id: companyId, tenantId },
|
||||
});
|
||||
if (!company) {
|
||||
throw new NotFoundException('Unternehmen nicht gefunden');
|
||||
}
|
||||
|
||||
const owner = await this.prisma.companyOwner.findUnique({
|
||||
where: {
|
||||
companyId_userId: { companyId, userId },
|
||||
},
|
||||
});
|
||||
if (!owner) {
|
||||
throw new NotFoundException('Owner nicht gefunden');
|
||||
}
|
||||
|
||||
return this.prisma.companyOwner.delete({
|
||||
where: { id: owner.id },
|
||||
});
|
||||
}
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Deal Owners
|
||||
// --------------------------------------------------------
|
||||
|
||||
async addDealOwner(tenantId: string, dealId: string, dto: AddOwnerDto) {
|
||||
const deal = await this.prisma.deal.findFirst({
|
||||
where: { id: dealId, tenantId },
|
||||
});
|
||||
if (!deal) {
|
||||
throw new NotFoundException('Vorgang nicht gefunden');
|
||||
}
|
||||
|
||||
return this.prisma.dealOwner.upsert({
|
||||
where: {
|
||||
dealId_userId: { dealId, userId: dto.userId },
|
||||
},
|
||||
create: {
|
||||
tenantId,
|
||||
dealId,
|
||||
userId: dto.userId,
|
||||
role: dto.role ?? 'OWNER',
|
||||
},
|
||||
update: {
|
||||
role: dto.role ?? 'OWNER',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async removeDealOwner(tenantId: string, dealId: string, userId: string) {
|
||||
const deal = await this.prisma.deal.findFirst({
|
||||
where: { id: dealId, tenantId },
|
||||
});
|
||||
if (!deal) {
|
||||
throw new NotFoundException('Vorgang nicht gefunden');
|
||||
}
|
||||
|
||||
const owner = await this.prisma.dealOwner.findUnique({
|
||||
where: {
|
||||
dealId_userId: { dealId, userId },
|
||||
},
|
||||
});
|
||||
if (!owner) {
|
||||
throw new NotFoundException('Owner nicht gefunden');
|
||||
}
|
||||
|
||||
return this.prisma.dealOwner.delete({
|
||||
where: { id: owner.id },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -11,6 +11,7 @@ import Redis from 'ioredis';
|
|||
export class RedisService implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly logger = new Logger(RedisService.name);
|
||||
private client!: Redis;
|
||||
private subscriber!: Redis;
|
||||
|
||||
constructor(private readonly config: ConfigService) {}
|
||||
|
||||
|
|
@ -19,7 +20,7 @@ export class RedisService implements OnModuleInit, OnModuleDestroy {
|
|||
const port = this.config.get<number>('REDIS_PORT', 6379);
|
||||
const password = this.config.get<string>('REDIS_PASSWORD');
|
||||
|
||||
this.client = new Redis({
|
||||
const redisOptions = {
|
||||
host,
|
||||
port,
|
||||
password: password || undefined,
|
||||
|
|
@ -34,7 +35,10 @@ export class RedisService implements OnModuleInit, OnModuleDestroy {
|
|||
return Math.min(times * 200, 5000);
|
||||
},
|
||||
lazyConnect: true,
|
||||
});
|
||||
};
|
||||
|
||||
// Haupt-Client fuer Commands + Publish
|
||||
this.client = new Redis(redisOptions);
|
||||
|
||||
this.client.on('error', (err: Error) => {
|
||||
this.logger.error(`Redis Fehler: ${err.message}`);
|
||||
|
|
@ -45,11 +49,27 @@ export class RedisService implements OnModuleInit, OnModuleDestroy {
|
|||
});
|
||||
|
||||
await this.client.connect();
|
||||
|
||||
// Subscriber-Client (ioredis erfordert separaten Client fuer subscribe-Modus)
|
||||
this.subscriber = new Redis(redisOptions);
|
||||
|
||||
this.subscriber.on('error', (err: Error) => {
|
||||
this.logger.error(`Redis Subscriber Fehler: ${err.message}`);
|
||||
});
|
||||
|
||||
this.subscriber.on('connect', () => {
|
||||
this.logger.log('Redis Subscriber Verbindung hergestellt.');
|
||||
});
|
||||
|
||||
await this.subscriber.connect();
|
||||
}
|
||||
|
||||
async onModuleDestroy(): Promise<void> {
|
||||
this.logger.log('Trenne Redis Verbindung...');
|
||||
await this.client.quit();
|
||||
this.logger.log('Trenne Redis Verbindungen...');
|
||||
await Promise.all([
|
||||
this.client.quit(),
|
||||
this.subscriber.quit(),
|
||||
]);
|
||||
}
|
||||
|
||||
async ping(): Promise<string> {
|
||||
|
|
@ -85,4 +105,40 @@ export class RedisService implements OnModuleInit, OnModuleDestroy {
|
|||
const result = await this.client.set(key, value, 'EX', ttlSeconds, 'NX');
|
||||
return result === 'OK';
|
||||
}
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Pub/Sub
|
||||
// --------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Publiziert eine Nachricht auf einem Redis Channel.
|
||||
* Gibt die Anzahl der Empfaenger zurueck.
|
||||
*/
|
||||
async publish(channel: string, payload: Record<string, unknown>): Promise<number> {
|
||||
return this.client.publish(channel, JSON.stringify(payload));
|
||||
}
|
||||
|
||||
/**
|
||||
* Abonniert einen Redis Channel.
|
||||
* Callback wird bei jeder Nachricht aufgerufen.
|
||||
*/
|
||||
async subscribe(
|
||||
channel: string,
|
||||
callback: (message: Record<string, unknown>) => void,
|
||||
): Promise<void> {
|
||||
await this.subscriber.subscribe(channel);
|
||||
this.subscriber.on('message', (ch: string, message: string) => {
|
||||
if (ch === channel) {
|
||||
try {
|
||||
const parsed = JSON.parse(message) as Record<string, unknown>;
|
||||
callback(parsed);
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Redis Subscriber: Fehler beim Parsen der Nachricht auf "${channel}": ${String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
this.logger.log(`Redis: Abonniert auf Channel "${channel}"`);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ const ACTIVITY_TYPE_LABELS: Record<ActivityType, string> = {
|
|||
EMAIL: 'E-Mail',
|
||||
MEETING: 'Meeting',
|
||||
TASK: 'Aufgabe',
|
||||
FOLLOWUP: 'Follow-Up',
|
||||
};
|
||||
|
||||
const ACTIVITY_TYPES: ActivityType[] = [
|
||||
|
|
|
|||
|
|
@ -46,6 +46,8 @@ import type {
|
|||
TradeEvent,
|
||||
CreateTradeEventPayload,
|
||||
UpdateTradeEventPayload,
|
||||
EntityOwner,
|
||||
AddOwnerPayload,
|
||||
PaginatedResponse,
|
||||
SingleResponse,
|
||||
} from './types';
|
||||
|
|
@ -220,6 +222,40 @@ export const companiesApi = {
|
|||
.then((r) => r.data),
|
||||
};
|
||||
|
||||
// --- Owners (Contact, Company, Deal) ---
|
||||
|
||||
export const ownersApi = {
|
||||
addContactOwner: (contactId: string, data: AddOwnerPayload) =>
|
||||
api
|
||||
.post<SingleResponse<EntityOwner>>(`/crm/contacts/${contactId}/owners`, data)
|
||||
.then((r) => r.data),
|
||||
|
||||
removeContactOwner: (contactId: string, userId: string) =>
|
||||
api
|
||||
.delete<SingleResponse<EntityOwner>>(`/crm/contacts/${contactId}/owners/${userId}`)
|
||||
.then((r) => r.data),
|
||||
|
||||
addCompanyOwner: (companyId: string, data: AddOwnerPayload) =>
|
||||
api
|
||||
.post<SingleResponse<EntityOwner>>(`/crm/companies/${companyId}/owners`, data)
|
||||
.then((r) => r.data),
|
||||
|
||||
removeCompanyOwner: (companyId: string, userId: string) =>
|
||||
api
|
||||
.delete<SingleResponse<EntityOwner>>(`/crm/companies/${companyId}/owners/${userId}`)
|
||||
.then((r) => r.data),
|
||||
|
||||
addDealOwner: (dealId: string, data: AddOwnerPayload) =>
|
||||
api
|
||||
.post<SingleResponse<EntityOwner>>(`/crm/deals/${dealId}/owners`, data)
|
||||
.then((r) => r.data),
|
||||
|
||||
removeDealOwner: (dealId: string, userId: string) =>
|
||||
api
|
||||
.delete<SingleResponse<EntityOwner>>(`/crm/deals/${dealId}/owners/${userId}`)
|
||||
.then((r) => r.data),
|
||||
};
|
||||
|
||||
// --- Industries ---
|
||||
|
||||
export const industriesApi = {
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ const ACTIVITY_TYPE_LABELS: Record<ActivityType, string> = {
|
|||
EMAIL: 'E-Mail',
|
||||
MEETING: 'Meeting',
|
||||
TASK: 'Aufgabe',
|
||||
FOLLOWUP: 'Follow-Up',
|
||||
};
|
||||
|
||||
const iconSvgProps = {
|
||||
|
|
|
|||
|
|
@ -3,7 +3,8 @@ import { useNavigate } from 'react-router-dom';
|
|||
import { Modal } from '../../components/Modal';
|
||||
import { useCompanies, useDeleteCompany, useCompanyContacts } from '../hooks';
|
||||
import { CompanyFormModal } from './CompanyFormModal';
|
||||
import type { Company, Contact, CompaniesQueryParams, ContactType } from '../types';
|
||||
import type { Company, Contact, CompaniesQueryParams, ContactType, EntityStatus } from '../types';
|
||||
import { ENTITY_STATUS_LABELS } from '../types';
|
||||
import styles from './CompaniesPage.module.css';
|
||||
|
||||
const thStyle: React.CSSProperties = {
|
||||
|
|
@ -114,11 +115,13 @@ function ContactSubRows({ companyId }: { companyId: string }) {
|
|||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: '50%',
|
||||
background: contact.isActive
|
||||
background: ((contact as Contact & { status?: EntityStatus }).status ?? (contact.isActive ? 'ACTIVE' : 'INACTIVE')) === 'ACTIVE'
|
||||
? 'var(--color-success)'
|
||||
: 'var(--color-error)',
|
||||
: ((contact as Contact & { status?: EntityStatus }).status ?? '') === 'BLOCKED'
|
||||
? '#991b1b'
|
||||
: 'var(--color-error)',
|
||||
}}
|
||||
title={contact.isActive ? 'Aktiv' : 'Inaktiv'}
|
||||
title={ENTITY_STATUS_LABELS[((contact as Contact & { status?: EntityStatus }).status ?? (contact.isActive ? 'ACTIVE' : 'INACTIVE')) as EntityStatus] ?? 'Unbekannt'}
|
||||
/>
|
||||
</td>
|
||||
{/* Leere Aktionen-Spalte */}
|
||||
|
|
@ -355,11 +358,13 @@ export function CompaniesPage() {
|
|||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: '50%',
|
||||
background: company.isActive
|
||||
background: (company.status ?? (company.isActive ? 'ACTIVE' : 'INACTIVE')) === 'ACTIVE'
|
||||
? 'var(--color-success)'
|
||||
: 'var(--color-error)',
|
||||
: (company.status ?? '') === 'BLOCKED'
|
||||
? '#991b1b'
|
||||
: 'var(--color-error)',
|
||||
}}
|
||||
title={company.isActive ? 'Aktiv' : 'Inaktiv'}
|
||||
title={ENTITY_STATUS_LABELS[(company.status ?? (company.isActive ? 'ACTIVE' : 'INACTIVE')) as EntityStatus] ?? 'Unbekannt'}
|
||||
/>
|
||||
</td>
|
||||
{/* Aktionen */}
|
||||
|
|
|
|||
|
|
@ -298,6 +298,58 @@ export function CompanyDetailPage() {
|
|||
</span>
|
||||
</>
|
||||
)}
|
||||
{(company.deliveryStreet || company.deliveryZip || company.deliveryCity) && (
|
||||
<>
|
||||
<span className={styles.infoLabel}>Lieferadresse</span>
|
||||
<span className={styles.infoValue}>
|
||||
{company.deliveryStreet && <>{company.deliveryStreet}<br /></>}
|
||||
{company.deliveryZip} {company.deliveryCity}
|
||||
{company.deliveryCountry && company.deliveryCountry !== 'DE' && (
|
||||
<>, {company.deliveryCountry}</>
|
||||
)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{company.vatId && (
|
||||
<>
|
||||
<span className={styles.infoLabel}>USt-IdNr.</span>
|
||||
<span className={styles.infoValue}>{company.vatId}</span>
|
||||
</>
|
||||
)}
|
||||
{company.taxId && (
|
||||
<>
|
||||
<span className={styles.infoLabel}>Steuernummer</span>
|
||||
<span className={styles.infoValue}>{company.taxId}</span>
|
||||
</>
|
||||
)}
|
||||
{company.tradeRegisterNumber && (
|
||||
<>
|
||||
<span className={styles.infoLabel}>Handelsregister</span>
|
||||
<span className={styles.infoValue}>
|
||||
{company.tradeRegisterNumber}
|
||||
{company.registerCourt && (
|
||||
<span style={{ color: 'var(--color-text-muted)' }}> ({company.registerCourt})</span>
|
||||
)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{company.companySize && (
|
||||
<>
|
||||
<span className={styles.infoLabel}>Groesse</span>
|
||||
<span className={styles.infoValue}>
|
||||
{{'SIZE_1_10': '1–10', 'SIZE_11_50': '11–50', 'SIZE_51_200': '51–200', 'SIZE_201_500': '201–500', 'SIZE_500_PLUS': '500+'}[company.companySize] ?? company.companySize} Mitarbeiter
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{company.dataEnrichedAt && (
|
||||
<>
|
||||
<span className={styles.infoLabel}>Datenanreicherung</span>
|
||||
<span className={styles.infoValue} style={{ fontSize: '0.8125rem', color: 'var(--color-text-muted)' }}>
|
||||
{new Date(company.dataEnrichedAt).toLocaleDateString('de-DE')}
|
||||
{company.dataEnrichedSource && ` (${company.dataEnrichedSource})`}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
<span className={styles.infoLabel}>Erstellt</span>
|
||||
<span className={styles.infoValue}>
|
||||
{formatDate(company.createdAt)}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { ActivityFormModal } from '../activities/ActivityFormModal';
|
|||
import { Modal } from '../../components/Modal';
|
||||
import { LexwareSection } from '../lexware/LexwareSection';
|
||||
import type { Contact, Activity, ActivityType, ContactType } from '../types';
|
||||
import { CONTACT_SOURCE_LABELS, ENTITY_STATUS_LABELS } from '../types';
|
||||
import styles from './ContactDetailPage.module.css';
|
||||
|
||||
const TYPE_COLORS: Record<ContactType, { bg: string; color: string }> = {
|
||||
|
|
@ -24,6 +25,7 @@ const ACTIVITY_TYPE_LABELS: Record<ActivityType, string> = {
|
|||
EMAIL: 'E-Mail',
|
||||
MEETING: 'Meeting',
|
||||
TASK: 'Aufgabe',
|
||||
FOLLOWUP: 'Follow-Up',
|
||||
};
|
||||
|
||||
function activityIcon(type: ActivityType): React.ReactNode {
|
||||
|
|
@ -287,6 +289,53 @@ export function ContactDetailPage() {
|
|||
</span>
|
||||
</>
|
||||
)}
|
||||
{contact.linkedinUrl && (
|
||||
<>
|
||||
<span className={styles.infoLabel}>LinkedIn</span>
|
||||
<span className={styles.infoValue}>
|
||||
<a
|
||||
href={contact.linkedinUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ color: 'var(--color-primary)' }}
|
||||
>
|
||||
{contact.linkedinUrl.replace(/^https?:\/\/(www\.)?linkedin\.com\/in\//, '')}
|
||||
</a>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{contact.department && (
|
||||
<>
|
||||
<span className={styles.infoLabel}>Abteilung</span>
|
||||
<span className={styles.infoValue}>{contact.department}</span>
|
||||
</>
|
||||
)}
|
||||
{contact.birthday && (
|
||||
<>
|
||||
<span className={styles.infoLabel}>Geburtstag</span>
|
||||
<span className={styles.infoValue}>
|
||||
{new Date(contact.birthday).toLocaleDateString('de-DE')}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{contact.source && (
|
||||
<>
|
||||
<span className={styles.infoLabel}>Quelle</span>
|
||||
<span className={styles.infoValue}>
|
||||
{CONTACT_SOURCE_LABELS[contact.source] ?? contact.source}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{contact.status && contact.status !== 'ACTIVE' && (
|
||||
<>
|
||||
<span className={styles.infoLabel}>Status</span>
|
||||
<span className={styles.infoValue} style={{
|
||||
color: contact.status === 'BLOCKED' ? '#991b1b' : 'var(--color-text-muted)',
|
||||
}}>
|
||||
{ENTITY_STATUS_LABELS[contact.status] ?? contact.status}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{(contact.street || contact.zip || contact.city) && (
|
||||
<>
|
||||
<span className={styles.infoLabel}>Adresse</span>
|
||||
|
|
|
|||
|
|
@ -2,7 +2,8 @@ import { useState, useEffect, useRef } from 'react';
|
|||
import { Modal } from '../../components/Modal';
|
||||
import { useCreateContact, useUpdateContact } from '../hooks';
|
||||
import { companiesApi } from '../api';
|
||||
import type { Contact, ContactType, Company } from '../types';
|
||||
import type { Contact, ContactType, ContactSource, EntityStatus, Company } from '../types';
|
||||
import { CONTACT_SOURCE_LABELS, ENTITY_STATUS_LABELS } from '../types';
|
||||
|
||||
interface ContactFormModalProps {
|
||||
isOpen: boolean;
|
||||
|
|
@ -64,6 +65,11 @@ export function ContactFormModal({
|
|||
const [notes, setNotes] = useState('');
|
||||
const [tagsInput, setTagsInput] = useState('');
|
||||
const [position, setPosition] = useState('');
|
||||
const [linkedinUrl, setLinkedinUrl] = useState('');
|
||||
const [birthday, setBirthday] = useState('');
|
||||
const [source, setSource] = useState<ContactSource | ''>('');
|
||||
const [department, setDepartment] = useState('');
|
||||
const [status, setStatus] = useState<EntityStatus>('ACTIVE');
|
||||
|
||||
// Unternehmen-Suche
|
||||
const [companySearch, setCompanySearch] = useState('');
|
||||
|
|
@ -133,6 +139,11 @@ export function ContactFormModal({
|
|||
setNotes(contact.notes ?? '');
|
||||
setTagsInput((contact.tags ?? []).join(', '));
|
||||
setPosition(contact.position ?? '');
|
||||
setLinkedinUrl(contact.linkedinUrl ?? '');
|
||||
setBirthday(contact.birthday ? contact.birthday.split('T')[0] : '');
|
||||
setSource((contact.source as ContactSource) ?? '');
|
||||
setDepartment(contact.department ?? '');
|
||||
setStatus(contact.status ?? 'ACTIVE');
|
||||
if (contact.company) {
|
||||
setSelectedCompany({ id: contact.company.id, name: contact.company.name });
|
||||
setCompanySearch(contact.company.name);
|
||||
|
|
@ -156,6 +167,11 @@ export function ContactFormModal({
|
|||
setNotes('');
|
||||
setTagsInput('');
|
||||
setPosition('');
|
||||
setLinkedinUrl('');
|
||||
setBirthday('');
|
||||
setSource('');
|
||||
setDepartment('');
|
||||
setStatus('ACTIVE');
|
||||
setSelectedCompany(null);
|
||||
setCompanySearch('');
|
||||
}
|
||||
|
|
@ -180,6 +196,11 @@ export function ContactFormModal({
|
|||
...(phone ? { phone } : {}),
|
||||
...(mobile ? { mobile } : {}),
|
||||
...(website ? { website } : {}),
|
||||
...(linkedinUrl ? { linkedinUrl } : {}),
|
||||
...(birthday ? { birthday: new Date(birthday).toISOString() } : {}),
|
||||
...(source ? { source } : {}),
|
||||
...(department ? { department } : {}),
|
||||
status,
|
||||
...(street ? { street } : {}),
|
||||
...(zip ? { zip } : {}),
|
||||
...(city ? { city } : {}),
|
||||
|
|
@ -346,16 +367,27 @@ export function ContactFormModal({
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* Position (nur bei Person) */}
|
||||
{/* Position + Abteilung (nur bei Person) */}
|
||||
{type === 'PERSON' && (
|
||||
<div style={{ marginBottom: '1rem' }}>
|
||||
<label style={labelStyle}>Position</label>
|
||||
<input
|
||||
style={inputStyle}
|
||||
value={position}
|
||||
onChange={(e) => setPosition(e.target.value)}
|
||||
placeholder="z.B. Geschäftsführer, Einkäufer..."
|
||||
/>
|
||||
<div style={{ ...rowStyle, marginBottom: '1rem' }}>
|
||||
<div>
|
||||
<label style={labelStyle}>Position</label>
|
||||
<input
|
||||
style={inputStyle}
|
||||
value={position}
|
||||
onChange={(e) => setPosition(e.target.value)}
|
||||
placeholder="z.B. Geschaeftsfuehrer"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={labelStyle}>Abteilung</label>
|
||||
<input
|
||||
style={inputStyle}
|
||||
value={department}
|
||||
onChange={(e) => setDepartment(e.target.value)}
|
||||
placeholder="z.B. Einkauf, Vertrieb"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -427,15 +459,66 @@ export function ContactFormModal({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Website */}
|
||||
<div style={{ marginBottom: '1rem' }}>
|
||||
<label style={labelStyle}>Website</label>
|
||||
<input
|
||||
style={inputStyle}
|
||||
value={website}
|
||||
onChange={(e) => setWebsite(e.target.value)}
|
||||
placeholder="https://..."
|
||||
/>
|
||||
{/* Website + LinkedIn */}
|
||||
<div style={{ ...rowStyle, marginBottom: '1rem' }}>
|
||||
<div>
|
||||
<label style={labelStyle}>Website</label>
|
||||
<input
|
||||
style={inputStyle}
|
||||
value={website}
|
||||
onChange={(e) => setWebsite(e.target.value)}
|
||||
placeholder="https://..."
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={labelStyle}>LinkedIn</label>
|
||||
<input
|
||||
style={inputStyle}
|
||||
value={linkedinUrl}
|
||||
onChange={(e) => setLinkedinUrl(e.target.value)}
|
||||
placeholder="https://linkedin.com/in/..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Geburtsdatum + Quelle + Status */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '0.75rem', marginBottom: '1rem' }}>
|
||||
{type === 'PERSON' && (
|
||||
<div>
|
||||
<label style={labelStyle}>Geburtsdatum</label>
|
||||
<input
|
||||
type="date"
|
||||
style={inputStyle}
|
||||
value={birthday}
|
||||
onChange={(e) => setBirthday(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label style={labelStyle}>Quelle</label>
|
||||
<select
|
||||
style={{ ...inputStyle, cursor: 'pointer' }}
|
||||
value={source}
|
||||
onChange={(e) => setSource(e.target.value as ContactSource)}
|
||||
>
|
||||
<option value="">-- Keine --</option>
|
||||
{Object.entries(CONTACT_SOURCE_LABELS).map(([val, label]) => (
|
||||
<option key={val} value={val}>{label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label style={labelStyle}>Status</label>
|
||||
<select
|
||||
style={{ ...inputStyle, cursor: 'pointer' }}
|
||||
value={status}
|
||||
onChange={(e) => setStatus(e.target.value as EntityStatus)}
|
||||
>
|
||||
{Object.entries(ENTITY_STATUS_LABELS).map(([val, label]) => (
|
||||
<option key={val} value={val}>{label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Adresse */}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { DealFormModal } from './DealFormModal';
|
|||
import { Modal } from '../../components/Modal';
|
||||
import { DealVouchersSection } from '../lexware/DealVouchersSection';
|
||||
import type { DealStatus } from '../types';
|
||||
import { LOST_REASON_LABELS } from '../types';
|
||||
import styles from './DealDetailPage.module.css';
|
||||
|
||||
const STATUS_COLORS: Record<DealStatus, { bg: string; color: string }> = {
|
||||
|
|
@ -237,6 +238,20 @@ export function DealDetailPage() {
|
|||
</>
|
||||
)}
|
||||
|
||||
{deal.status === 'LOST' && deal.lostReason && (
|
||||
<>
|
||||
<span className={styles.infoLabel}>Verlustgrund</span>
|
||||
<span className={styles.infoValue} style={{ color: '#991b1b' }}>
|
||||
{LOST_REASON_LABELS[deal.lostReason] ?? deal.lostReason}
|
||||
{deal.lostReasonText && (
|
||||
<span style={{ color: 'var(--color-text-muted)', fontWeight: 400, marginLeft: '0.5rem' }}>
|
||||
— {deal.lostReasonText}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
<span className={styles.infoLabel}>Erstellt am</span>
|
||||
<span className={styles.infoValue}>
|
||||
{formatDate(deal.createdAt)}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,8 @@ import { useState, useEffect, useRef } from 'react';
|
|||
import { Modal } from '../../components/Modal';
|
||||
import { useCreateDeal, useUpdateDeal, usePipelines } from '../hooks';
|
||||
import { contactsApi, companiesApi } from '../api';
|
||||
import type { Deal, DealStatus, Contact, Company } from '../types';
|
||||
import type { Deal, DealStatus, LostReason, Contact, Company } from '../types';
|
||||
import { LOST_REASON_LABELS } from '../types';
|
||||
|
||||
interface DealFormModalProps {
|
||||
isOpen: boolean;
|
||||
|
|
@ -66,6 +67,8 @@ export function DealFormModal({
|
|||
const [currency, setCurrency] = useState('EUR');
|
||||
const [expectedCloseDate, setExpectedCloseDate] = useState('');
|
||||
const [notes, setNotes] = useState('');
|
||||
const [lostReason, setLostReason] = useState<LostReason | ''>('');
|
||||
const [lostReasonText, setLostReasonText] = useState('');
|
||||
|
||||
// Kontakt-Suche
|
||||
const [contactSearch, setContactSearch] = useState('');
|
||||
|
|
@ -179,6 +182,8 @@ export function DealFormModal({
|
|||
: '',
|
||||
);
|
||||
setNotes(deal.notes ?? '');
|
||||
setLostReason((deal.lostReason as LostReason) ?? '');
|
||||
setLostReasonText(deal.lostReasonText ?? '');
|
||||
if (deal.contact) {
|
||||
const { id, firstName, lastName, companyName } = deal.contact;
|
||||
const name =
|
||||
|
|
@ -207,6 +212,8 @@ export function DealFormModal({
|
|||
setCurrency('EUR');
|
||||
setExpectedCloseDate('');
|
||||
setNotes('');
|
||||
setLostReason('');
|
||||
setLostReasonText('');
|
||||
setSelectedContact(null);
|
||||
setContactSearch('');
|
||||
setSelectedCompany(null);
|
||||
|
|
@ -242,6 +249,10 @@ export function DealFormModal({
|
|||
setError('Stage auswählen');
|
||||
return;
|
||||
}
|
||||
if (status === 'LOST' && !lostReason) {
|
||||
setError('Bitte einen Grund fuer den Verlust angeben');
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
title: title.trim(),
|
||||
|
|
@ -254,6 +265,8 @@ export function DealFormModal({
|
|||
currency,
|
||||
...(expectedCloseDate ? { expectedCloseDate: new Date(expectedCloseDate).toISOString() } : {}),
|
||||
...(notes ? { notes } : {}),
|
||||
...(status === 'LOST' && lostReason ? { lostReason } : {}),
|
||||
...(status === 'LOST' && lostReasonText ? { lostReasonText } : {}),
|
||||
};
|
||||
|
||||
if (isEditMode && deal) {
|
||||
|
|
@ -601,6 +614,34 @@ export function DealFormModal({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Lost-Reason (nur bei Status LOST) */}
|
||||
{status === 'LOST' && (
|
||||
<>
|
||||
<div style={{ marginBottom: '1rem' }}>
|
||||
<label style={labelStyle}>Verlustgrund *</label>
|
||||
<select
|
||||
value={lostReason}
|
||||
onChange={(e) => setLostReason(e.target.value as LostReason)}
|
||||
style={{ ...inputStyle, cursor: 'pointer', borderColor: !lostReason ? 'var(--color-error)' : undefined }}
|
||||
>
|
||||
<option value="">-- Bitte waehlen --</option>
|
||||
{Object.entries(LOST_REASON_LABELS).map(([val, label]) => (
|
||||
<option key={val} value={val}>{label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div style={{ marginBottom: '1rem' }}>
|
||||
<label style={labelStyle}>Bemerkung zum Verlust</label>
|
||||
<textarea
|
||||
style={{ ...inputStyle, minHeight: 50, resize: 'vertical' }}
|
||||
value={lostReasonText}
|
||||
onChange={(e) => setLostReasonText(e.target.value)}
|
||||
placeholder="Optionale Details..."
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Notizen */}
|
||||
<div style={{ marginBottom: '1.5rem' }}>
|
||||
<label style={labelStyle}>Notizen</label>
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import {
|
|||
lexwareContactsApi,
|
||||
lexwareVouchersApi,
|
||||
tradeEventsApi,
|
||||
ownersApi,
|
||||
} from './api';
|
||||
import type {
|
||||
ContactsQueryParams,
|
||||
|
|
@ -46,6 +47,7 @@ import type {
|
|||
LexwareVouchersQueryParams,
|
||||
CreateTradeEventPayload,
|
||||
UpdateTradeEventPayload,
|
||||
AddOwnerPayload,
|
||||
} from './types';
|
||||
|
||||
// --- Query Key Factory ---
|
||||
|
|
@ -937,3 +939,73 @@ export function useDeleteTradeEvent() {
|
|||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Owners (Contact, Company, Deal)
|
||||
// ============================================================
|
||||
|
||||
export function useAddContactOwner() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ contactId, data }: { contactId: string; data: AddOwnerPayload }) =>
|
||||
ownersApi.addContactOwner(contactId, data),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: crmKeys.contacts.all });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useRemoveContactOwner() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ contactId, userId }: { contactId: string; userId: string }) =>
|
||||
ownersApi.removeContactOwner(contactId, userId),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: crmKeys.contacts.all });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useAddCompanyOwner() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ companyId, data }: { companyId: string; data: AddOwnerPayload }) =>
|
||||
ownersApi.addCompanyOwner(companyId, data),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: crmKeys.companies.all });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useRemoveCompanyOwner() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ companyId, userId }: { companyId: string; userId: string }) =>
|
||||
ownersApi.removeCompanyOwner(companyId, userId),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: crmKeys.companies.all });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useAddDealOwner() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ dealId, data }: { dealId: string; data: AddOwnerPayload }) =>
|
||||
ownersApi.addDealOwner(dealId, data),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: crmKeys.deals.all });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useRemoveDealOwner() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ dealId, userId }: { dealId: string; userId: string }) =>
|
||||
ownersApi.removeDealOwner(dealId, userId),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: crmKeys.deals.all });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,9 +6,114 @@
|
|||
|
||||
export type ContactType = 'PERSON' | 'ORGANIZATION';
|
||||
export type DealStatus = 'OPEN' | 'WON' | 'LOST';
|
||||
export type ActivityType = 'NOTE' | 'CALL' | 'EMAIL' | 'MEETING' | 'TASK';
|
||||
export type ActivityType = 'NOTE' | 'CALL' | 'EMAIL' | 'MEETING' | 'TASK' | 'FOLLOWUP';
|
||||
export type ContractStatus = 'DRAFT' | 'ACTIVE' | 'EXPIRED' | 'CANCELLED';
|
||||
|
||||
// Phase 1 Enums
|
||||
export type ContactSource = 'TRADE_FAIR' | 'REFERRAL' | 'WEBSITE' | 'COLD_CALL' | 'IMPORT' | 'BUSINESS_CARD' | 'OTHER';
|
||||
export type EntityStatus = 'ACTIVE' | 'INACTIVE' | 'BLOCKED';
|
||||
export type CompanySize = 'SIZE_1_10' | 'SIZE_11_50' | 'SIZE_51_200' | 'SIZE_201_500' | 'SIZE_500_PLUS';
|
||||
export type OwnerRole = 'OWNER' | 'MEMBER' | 'WATCHER';
|
||||
export type LostReason = 'PRICE' | 'TIMING' | 'COMPETITOR' | 'NO_NEED' | 'OTHER';
|
||||
export type EmailType = 'WORK' | 'PERSONAL' | 'OTHER';
|
||||
export type PhoneType = 'OFFICE' | 'MOBILE' | 'FAX';
|
||||
|
||||
// Label-Maps fuer UI
|
||||
export const CONTACT_SOURCE_LABELS: Record<ContactSource, string> = {
|
||||
TRADE_FAIR: 'Messe',
|
||||
REFERRAL: 'Empfehlung',
|
||||
WEBSITE: 'Website',
|
||||
COLD_CALL: 'Kaltakquise',
|
||||
IMPORT: 'Import',
|
||||
BUSINESS_CARD: 'Visitenkarte',
|
||||
OTHER: 'Sonstige',
|
||||
};
|
||||
|
||||
export const ENTITY_STATUS_LABELS: Record<EntityStatus, string> = {
|
||||
ACTIVE: 'Aktiv',
|
||||
INACTIVE: 'Inaktiv',
|
||||
BLOCKED: 'Gesperrt',
|
||||
};
|
||||
|
||||
export const COMPANY_SIZE_LABELS: Record<CompanySize, string> = {
|
||||
SIZE_1_10: '1–10',
|
||||
SIZE_11_50: '11–50',
|
||||
SIZE_51_200: '51–200',
|
||||
SIZE_201_500: '201–500',
|
||||
SIZE_500_PLUS: '500+',
|
||||
};
|
||||
|
||||
export const OWNER_ROLE_LABELS: Record<OwnerRole, string> = {
|
||||
OWNER: 'Verantwortlich',
|
||||
MEMBER: 'Mitarbeiter',
|
||||
WATCHER: 'Beobachter',
|
||||
};
|
||||
|
||||
export const LOST_REASON_LABELS: Record<LostReason, string> = {
|
||||
PRICE: 'Preis',
|
||||
TIMING: 'Timing',
|
||||
COMPETITOR: 'Wettbewerber',
|
||||
NO_NEED: 'Kein Bedarf',
|
||||
OTHER: 'Sonstiges',
|
||||
};
|
||||
|
||||
export const EMAIL_TYPE_LABELS: Record<EmailType, string> = {
|
||||
WORK: 'Arbeit',
|
||||
PERSONAL: 'Privat',
|
||||
OTHER: 'Sonstige',
|
||||
};
|
||||
|
||||
export const PHONE_TYPE_LABELS: Record<PhoneType, string> = {
|
||||
OFFICE: 'Buero',
|
||||
MOBILE: 'Mobil',
|
||||
FAX: 'Fax',
|
||||
};
|
||||
|
||||
// --- Multi-Value Contact Info ---
|
||||
|
||||
export interface ContactEmail {
|
||||
id: string;
|
||||
email: string;
|
||||
type: EmailType;
|
||||
isPrimary: boolean;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface ContactPhone {
|
||||
id: string;
|
||||
phone: string;
|
||||
type: PhoneType;
|
||||
isPrimary: boolean;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface CreateEmailPayload {
|
||||
email: string;
|
||||
type?: EmailType;
|
||||
isPrimary?: boolean;
|
||||
}
|
||||
|
||||
export interface CreatePhonePayload {
|
||||
phone: string;
|
||||
type?: PhoneType;
|
||||
isPrimary?: boolean;
|
||||
}
|
||||
|
||||
// --- Owner (m:n) ---
|
||||
|
||||
export interface EntityOwner {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
userId: string;
|
||||
role: OwnerRole;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface AddOwnerPayload {
|
||||
userId: string;
|
||||
role?: OwnerRole;
|
||||
}
|
||||
|
||||
// --- Admin-konfigurierbare Entitaeten ---
|
||||
|
||||
export interface Industry {
|
||||
|
|
@ -130,6 +235,10 @@ export interface Contact {
|
|||
phone: string | null;
|
||||
mobile: string | null;
|
||||
website: string | null;
|
||||
linkedinUrl: string | null;
|
||||
birthday: string | null;
|
||||
source: ContactSource | null;
|
||||
department: string | null;
|
||||
street: string | null;
|
||||
zip: string | null;
|
||||
city: string | null;
|
||||
|
|
@ -137,7 +246,8 @@ export interface Contact {
|
|||
country: string;
|
||||
notes: string | null;
|
||||
tags: string[];
|
||||
isActive: boolean;
|
||||
status: EntityStatus;
|
||||
isActive: boolean; // deprecated — use status
|
||||
createdBy: string;
|
||||
updatedBy: string | null;
|
||||
createdAt: string;
|
||||
|
|
@ -145,6 +255,9 @@ export interface Contact {
|
|||
lexwareContactId: string | null;
|
||||
lexwareContactVersion: number | null;
|
||||
lexwareSyncedAt: string | null;
|
||||
emails?: ContactEmail[];
|
||||
phones?: ContactPhone[];
|
||||
owners?: EntityOwner[];
|
||||
activities?: Activity[];
|
||||
company?: {
|
||||
id: string;
|
||||
|
|
@ -166,6 +279,10 @@ export interface CreateContactPayload {
|
|||
phone?: string;
|
||||
mobile?: string;
|
||||
website?: string;
|
||||
linkedinUrl?: string;
|
||||
birthday?: string;
|
||||
source?: ContactSource;
|
||||
department?: string;
|
||||
street?: string;
|
||||
zip?: string;
|
||||
city?: string;
|
||||
|
|
@ -173,7 +290,10 @@ export interface CreateContactPayload {
|
|||
country?: string;
|
||||
notes?: string;
|
||||
tags?: string[];
|
||||
isActive?: boolean;
|
||||
status?: EntityStatus;
|
||||
isActive?: boolean; // deprecated
|
||||
emails?: CreateEmailPayload[];
|
||||
phones?: CreatePhonePayload[];
|
||||
}
|
||||
|
||||
export type UpdateContactPayload = Partial<CreateContactPayload>;
|
||||
|
|
@ -282,11 +402,14 @@ export interface Deal {
|
|||
status: DealStatus;
|
||||
expectedCloseDate: string | null;
|
||||
closedAt: string | null;
|
||||
lostReason: LostReason | null;
|
||||
lostReasonText: string | null;
|
||||
notes: string | null;
|
||||
createdBy: string;
|
||||
updatedBy: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
owners?: EntityOwner[];
|
||||
pipeline?: { id: string; name: string; stages?: PipelineStage[] };
|
||||
stage?: { id: string; name: string; color: string };
|
||||
contact?: {
|
||||
|
|
@ -310,6 +433,8 @@ export interface CreateDealPayload {
|
|||
status?: DealStatus;
|
||||
expectedCloseDate?: string;
|
||||
notes?: string;
|
||||
lostReason?: LostReason;
|
||||
lostReasonText?: string;
|
||||
}
|
||||
|
||||
export type UpdateDealPayload = Partial<CreateDealPayload>;
|
||||
|
|
@ -325,6 +450,11 @@ export interface Company {
|
|||
accountTypeId: string | null;
|
||||
ownerId: string | null;
|
||||
ownerName: string | null;
|
||||
vatId: string | null;
|
||||
taxId: string | null;
|
||||
tradeRegisterNumber: string | null;
|
||||
registerCourt: string | null;
|
||||
companySize: CompanySize | null;
|
||||
email: string | null;
|
||||
phone: string | null;
|
||||
website: string | null;
|
||||
|
|
@ -333,9 +463,16 @@ export interface Company {
|
|||
city: string | null;
|
||||
state: string | null;
|
||||
country: string;
|
||||
deliveryStreet: string | null;
|
||||
deliveryZip: string | null;
|
||||
deliveryCity: string | null;
|
||||
deliveryCountry: string | null;
|
||||
dataEnrichedAt: string | null;
|
||||
dataEnrichedSource: string | null;
|
||||
notes: string | null;
|
||||
tags: string[];
|
||||
isActive: boolean;
|
||||
status: EntityStatus;
|
||||
isActive: boolean; // deprecated — use status
|
||||
createdBy: string;
|
||||
updatedBy: string | null;
|
||||
createdAt: string;
|
||||
|
|
@ -343,6 +480,9 @@ export interface Company {
|
|||
lexwareContactId: string | null;
|
||||
lexwareContactVersion: number | null;
|
||||
lexwareSyncedAt: string | null;
|
||||
emails?: ContactEmail[];
|
||||
phones?: ContactPhone[];
|
||||
owners?: EntityOwner[];
|
||||
industryRef?: Industry | null;
|
||||
accountType?: AccountType | null;
|
||||
_count?: { contacts: number; deals: number; lexwareVouchers?: number; contracts?: number };
|
||||
|
|
@ -353,6 +493,7 @@ export interface Company {
|
|||
email: string | null;
|
||||
phone: string | null;
|
||||
position: string | null;
|
||||
status: EntityStatus;
|
||||
isActive: boolean;
|
||||
}[];
|
||||
deals?: Deal[];
|
||||
|
|
@ -368,6 +509,11 @@ export interface CreateCompanyPayload {
|
|||
accountTypeId?: string;
|
||||
ownerId?: string;
|
||||
ownerName?: string;
|
||||
vatId?: string;
|
||||
taxId?: string;
|
||||
tradeRegisterNumber?: string;
|
||||
registerCourt?: string;
|
||||
companySize?: CompanySize;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
website?: string;
|
||||
|
|
@ -376,9 +522,16 @@ export interface CreateCompanyPayload {
|
|||
city?: string;
|
||||
state?: string;
|
||||
country?: string;
|
||||
deliveryStreet?: string;
|
||||
deliveryZip?: string;
|
||||
deliveryCity?: string;
|
||||
deliveryCountry?: string;
|
||||
notes?: string;
|
||||
tags?: string[];
|
||||
isActive?: boolean;
|
||||
status?: EntityStatus;
|
||||
isActive?: boolean; // deprecated
|
||||
emails?: CreateEmailPayload[];
|
||||
phones?: CreatePhonePayload[];
|
||||
}
|
||||
|
||||
export type UpdateCompanyPayload = Partial<CreateCompanyPayload>;
|
||||
|
|
@ -413,6 +566,7 @@ export interface ContactsQueryParams {
|
|||
search?: string;
|
||||
type?: ContactType;
|
||||
companyId?: string;
|
||||
status?: EntityStatus;
|
||||
sort?: string;
|
||||
order?: 'asc' | 'desc';
|
||||
}
|
||||
|
|
@ -446,6 +600,7 @@ export interface CompaniesQueryParams {
|
|||
pageSize?: number;
|
||||
search?: string;
|
||||
industry?: string;
|
||||
status?: EntityStatus;
|
||||
sort?: string;
|
||||
order?: 'asc' | 'desc';
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue