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:
Thomas Reitz 2026-03-12 15:56:41 +01:00
parent 7d8847fafa
commit 48df3c3144
41 changed files with 2818 additions and 240 deletions

View file

@ -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`*

View file

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

View file

@ -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)
// --------------------------------------------------------

View file

@ -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");

View file

@ -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: [
{

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

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

View file

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

View file

@ -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],

View file

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

View file

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

View file

@ -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()

View file

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

View file

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

View file

@ -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],

View file

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

View file

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

View file

@ -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()

View file

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

View file

@ -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],

View file

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

View file

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

View file

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

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

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

View file

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

View file

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

View file

@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { OwnersService } from './owners.service';
@Module({
providers: [OwnersService],
exports: [OwnersService],
})
export class OwnersModule {}

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

View file

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

View file

@ -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[] = [

View file

@ -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 = {

View file

@ -13,6 +13,7 @@ const ACTIVITY_TYPE_LABELS: Record<ActivityType, string> = {
EMAIL: 'E-Mail',
MEETING: 'Meeting',
TASK: 'Aufgabe',
FOLLOWUP: 'Follow-Up',
};
const iconSvgProps = {

View file

@ -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 */}

View file

@ -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': '110', 'SIZE_11_50': '1150', 'SIZE_51_200': '51200', 'SIZE_201_500': '201500', '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)}

View file

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

View file

@ -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 */}

View file

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

View file

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

View file

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

View file

@ -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: '110',
SIZE_11_50: '1150',
SIZE_51_200: '51200',
SIZE_201_500: '201500',
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';
}