From 48df3c3144e7de8c6c7c437d2523e638f601f876 Mon Sep 17 00:00:00 2001 From: Thomas Reitz Date: Thu, 12 Mar 2026 15:56:41 +0100 Subject: [PATCH] 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 --- docs/INSIGHT-CRM.md | 123 ++++++++ packages/crm-service/Summarize.md | 65 +++-- packages/crm-service/prisma/crm.schema.prisma | 222 +++++++++++++- .../migration.sql | 270 ++++++++++++++++++ packages/crm-service/src/app.module.ts | 2 + .../src/common/dto/contact-info.dto.ts | 91 ++++++ .../crm-service/src/common/dto/owner.dto.ts | 23 ++ .../src/companies/companies.controller.ts | 45 ++- .../src/companies/companies.module.ts | 3 +- .../src/companies/companies.service.ts | 168 ++++++++++- .../src/companies/dto/create-company.dto.ts | 78 ++++- .../src/companies/dto/query-companies.dto.ts | 8 +- .../src/companies/dto/update-company.dto.ts | 112 +------- .../src/contacts/contacts.controller.ts | 45 ++- .../src/contacts/contacts.module.ts | 3 +- .../src/contacts/contacts.service.ts | 183 +++++++++++- .../src/contacts/dto/create-contact.dto.ts | 52 +++- .../src/contacts/dto/query-contacts.dto.ts | 6 + .../crm-service/src/deals/deals.controller.ts | 45 ++- .../crm-service/src/deals/deals.module.ts | 2 + .../crm-service/src/deals/deals.service.ts | 92 +++++- .../src/deals/dto/create-deal.dto.ts | 18 ++ .../src/events/activity-due-soon.scheduler.ts | 85 ++++++ .../src/events/crm-event-publisher.service.ts | 184 ++++++++++++ .../src/events/crm-events.module.ts | 10 + .../src/lexware/lexware-contacts.service.ts | 150 ++++++++-- .../src/lexware/utils/lexware-mapper.ts | 161 ++++++++++- .../crm-service/src/owners/owners.module.ts | 8 + .../crm-service/src/owners/owners.service.ts | 166 +++++++++++ .../crm-service/src/redis/redis.service.ts | 64 ++++- .../src/crm/activities/ActivityFormModal.tsx | 1 + packages/frontend/src/crm/api.ts | 36 +++ .../src/crm/companies/ActivityFeed.tsx | 1 + .../src/crm/companies/CompaniesPage.tsx | 19 +- .../src/crm/companies/CompanyDetailPage.tsx | 52 ++++ .../src/crm/contacts/ContactDetailPage.tsx | 49 ++++ .../src/crm/contacts/ContactFormModal.tsx | 121 ++++++-- .../frontend/src/crm/deals/DealDetailPage.tsx | 15 + .../frontend/src/crm/deals/DealFormModal.tsx | 43 ++- packages/frontend/src/crm/hooks.ts | 72 +++++ packages/frontend/src/crm/types.ts | 165 ++++++++++- 41 files changed, 2818 insertions(+), 240 deletions(-) create mode 100644 packages/crm-service/prisma/migrations/20260312_phase1_schema_expansion/migration.sql create mode 100644 packages/crm-service/src/common/dto/contact-info.dto.ts create mode 100644 packages/crm-service/src/common/dto/owner.dto.ts create mode 100644 packages/crm-service/src/events/activity-due-soon.scheduler.ts create mode 100644 packages/crm-service/src/events/crm-event-publisher.service.ts create mode 100644 packages/crm-service/src/events/crm-events.module.ts create mode 100644 packages/crm-service/src/owners/owners.module.ts create mode 100644 packages/crm-service/src/owners/owners.service.ts diff --git a/docs/INSIGHT-CRM.md b/docs/INSIGHT-CRM.md index e69d8d6..cf5a44b 100644 --- a/docs/INSIGHT-CRM.md +++ b/docs/INSIGHT-CRM.md @@ -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`* diff --git a/packages/crm-service/Summarize.md b/packages/crm-service/Summarize.md index c8bf15f..bf8d565 100644 --- a/packages/crm-service/Summarize.md +++ b/packages/crm-service/Summarize.md @@ -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 diff --git a/packages/crm-service/prisma/crm.schema.prisma b/packages/crm-service/prisma/crm.schema.prisma index f0feb01..8265882 100644 --- a/packages/crm-service/prisma/crm.schema.prisma +++ b/packages/crm-service/prisma/crm.schema.prisma @@ -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) // -------------------------------------------------------- diff --git a/packages/crm-service/prisma/migrations/20260312_phase1_schema_expansion/migration.sql b/packages/crm-service/prisma/migrations/20260312_phase1_schema_expansion/migration.sql new file mode 100644 index 0000000..66c9322 --- /dev/null +++ b/packages/crm-service/prisma/migrations/20260312_phase1_schema_expansion/migration.sql @@ -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"); diff --git a/packages/crm-service/src/app.module.ts b/packages/crm-service/src/app.module.ts index 3b912c1..b04f426 100644 --- a/packages/crm-service/src/app.module.ts +++ b/packages/crm-service/src/app.module.ts @@ -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: [ { diff --git a/packages/crm-service/src/common/dto/contact-info.dto.ts b/packages/crm-service/src/common/dto/contact-info.dto.ts new file mode 100644 index 0000000..71d5b2c --- /dev/null +++ b/packages/crm-service/src/common/dto/contact-info.dto.ts @@ -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; +} diff --git a/packages/crm-service/src/common/dto/owner.dto.ts b/packages/crm-service/src/common/dto/owner.dto.ts new file mode 100644 index 0000000..5b35b04 --- /dev/null +++ b/packages/crm-service/src/common/dto/owner.dto.ts @@ -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; +} diff --git a/packages/crm-service/src/companies/companies.controller.ts b/packages/crm-service/src/companies/companies.controller.ts index 6d92de0..bb2ae36 100644 --- a/packages/crm-service/src/companies/companies.controller.ts +++ b/packages/crm-service/src/companies/companies.controller.ts @@ -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); + } } diff --git a/packages/crm-service/src/companies/companies.module.ts b/packages/crm-service/src/companies/companies.module.ts index ac8ace3..dab6c27 100644 --- a/packages/crm-service/src/companies/companies.module.ts +++ b/packages/crm-service/src/companies/companies.module.ts @@ -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], diff --git a/packages/crm-service/src/companies/companies.service.ts b/packages/crm-service/src/companies/companies.service.ts index a33610b..b117356 100644 --- a/packages/crm-service/src/companies/companies.service.ts +++ b/packages/crm-service/src/companies/companies.service.ts @@ -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 diff --git a/packages/crm-service/src/companies/dto/create-company.dto.ts b/packages/crm-service/src/companies/dto/create-company.dto.ts index 69ba62a..72c74a1 100644 --- a/packages/crm-service/src/companies/dto/create-company.dto.ts +++ b/packages/crm-service/src/companies/dto/create-company.dto.ts @@ -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[]; } diff --git a/packages/crm-service/src/companies/dto/query-companies.dto.ts b/packages/crm-service/src/companies/dto/query-companies.dto.ts index 8c6e153..6a1f90c 100644 --- a/packages/crm-service/src/companies/dto/query-companies.dto.ts +++ b/packages/crm-service/src/companies/dto/query-companies.dto.ts @@ -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() diff --git a/packages/crm-service/src/companies/dto/update-company.dto.ts b/packages/crm-service/src/companies/dto/update-company.dto.ts index 232b99a..0f7ddc6 100644 --- a/packages/crm-service/src/companies/dto/update-company.dto.ts +++ b/packages/crm-service/src/companies/dto/update-company.dto.ts @@ -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) {} diff --git a/packages/crm-service/src/contacts/contacts.controller.ts b/packages/crm-service/src/contacts/contacts.controller.ts index 0e7a958..d16f4aa 100644 --- a/packages/crm-service/src/contacts/contacts.controller.ts +++ b/packages/crm-service/src/contacts/contacts.controller.ts @@ -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); + } } diff --git a/packages/crm-service/src/contacts/contacts.module.ts b/packages/crm-service/src/contacts/contacts.module.ts index f8ae6fc..16941e7 100644 --- a/packages/crm-service/src/contacts/contacts.module.ts +++ b/packages/crm-service/src/contacts/contacts.module.ts @@ -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], diff --git a/packages/crm-service/src/contacts/contacts.service.ts b/packages/crm-service/src/contacts/contacts.service.ts index a947c7d..3133ee7 100644 --- a/packages/crm-service/src/contacts/contacts.service.ts +++ b/packages/crm-service/src/contacts/contacts.service.ts @@ -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 diff --git a/packages/crm-service/src/contacts/dto/create-contact.dto.ts b/packages/crm-service/src/contacts/dto/create-contact.dto.ts index 9e42108..31ea651 100644 --- a/packages/crm-service/src/contacts/dto/create-contact.dto.ts +++ b/packages/crm-service/src/contacts/dto/create-contact.dto.ts @@ -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[]; } diff --git a/packages/crm-service/src/contacts/dto/query-contacts.dto.ts b/packages/crm-service/src/contacts/dto/query-contacts.dto.ts index b5c494c..ea9c325 100644 --- a/packages/crm-service/src/contacts/dto/query-contacts.dto.ts +++ b/packages/crm-service/src/contacts/dto/query-contacts.dto.ts @@ -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() diff --git a/packages/crm-service/src/deals/deals.controller.ts b/packages/crm-service/src/deals/deals.controller.ts index 91954aa..51a8404 100644 --- a/packages/crm-service/src/deals/deals.controller.ts +++ b/packages/crm-service/src/deals/deals.controller.ts @@ -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); + } } diff --git a/packages/crm-service/src/deals/deals.module.ts b/packages/crm-service/src/deals/deals.module.ts index 2dc9dda..6e4f255 100644 --- a/packages/crm-service/src/deals/deals.module.ts +++ b/packages/crm-service/src/deals/deals.module.ts @@ -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], diff --git a/packages/crm-service/src/deals/deals.service.ts b/packages/crm-service/src/deals/deals.service.ts index 6b3dd8b..3516378 100644 --- a/packages/crm-service/src/deals/deals.service.ts +++ b/packages/crm-service/src/deals/deals.service.ts @@ -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) { diff --git a/packages/crm-service/src/deals/dto/create-deal.dto.ts b/packages/crm-service/src/deals/dto/create-deal.dto.ts index ccfe1c1..819da52 100644 --- a/packages/crm-service/src/deals/dto/create-deal.dto.ts +++ b/packages/crm-service/src/deals/dto/create-deal.dto.ts @@ -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; } diff --git a/packages/crm-service/src/events/activity-due-soon.scheduler.ts b/packages/crm-service/src/events/activity-due-soon.scheduler.ts new file mode 100644 index 0000000..2677ed7 --- /dev/null +++ b/packages/crm-service/src/events/activity-due-soon.scheduler.ts @@ -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 { + // 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)}`); + } + } +} diff --git a/packages/crm-service/src/events/crm-event-publisher.service.ts b/packages/crm-service/src/events/crm-event-publisher.service.ts new file mode 100644 index 0000000..e702480 --- /dev/null +++ b/packages/crm-service/src/events/crm-event-publisher.service.ts @@ -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; +} + +@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 { + const channel = `app:crm:events:${event.type}`; + try { + const receivers = await this.redis.publish(channel, event as unknown as Record); + 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 { + 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 = {}, + ): Promise { + 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 { + 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 = {}, + ): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + await this.publish({ + type: 'crm.activity.due_soon', + tenantId, + entityId: activityId, + userId: 'system', + timestamp: new Date().toISOString(), + payload, + }); + } +} diff --git a/packages/crm-service/src/events/crm-events.module.ts b/packages/crm-service/src/events/crm-events.module.ts new file mode 100644 index 0000000..e54526a --- /dev/null +++ b/packages/crm-service/src/events/crm-events.module.ts @@ -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 {} diff --git a/packages/crm-service/src/lexware/lexware-contacts.service.ts b/packages/crm-service/src/lexware/lexware-contacts.service.ts index 3414296..4c7e6f7 100644 --- a/packages/crm-service/src/lexware/lexware-contacts.service.ts +++ b/packages/crm-service/src/lexware/lexware-contacts.service.ts @@ -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, + }, + }); }); } } diff --git a/packages/crm-service/src/lexware/utils/lexware-mapper.ts b/packages/crm-service/src/lexware/utils/lexware-mapper.ts index dc625d4..32893dc 100644 --- a/packages/crm-service/src/lexware/utils/lexware-mapper.ts +++ b/packages/crm-service/src/lexware/utils/lexware-mapper.ts @@ -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 { const body: Record = { 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 = {}; + 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 = {}; + 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 { const body: Record = { 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 = {}; + 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 = {}; - 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 = {}; + 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 = {}; + 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 // -------------------------------------------------------- diff --git a/packages/crm-service/src/owners/owners.module.ts b/packages/crm-service/src/owners/owners.module.ts new file mode 100644 index 0000000..072a376 --- /dev/null +++ b/packages/crm-service/src/owners/owners.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { OwnersService } from './owners.service'; + +@Module({ + providers: [OwnersService], + exports: [OwnersService], +}) +export class OwnersModule {} diff --git a/packages/crm-service/src/owners/owners.service.ts b/packages/crm-service/src/owners/owners.service.ts new file mode 100644 index 0000000..bedbc76 --- /dev/null +++ b/packages/crm-service/src/owners/owners.service.ts @@ -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 }, + }); + } +} diff --git a/packages/crm-service/src/redis/redis.service.ts b/packages/crm-service/src/redis/redis.service.ts index 0a6120b..4a8440b 100644 --- a/packages/crm-service/src/redis/redis.service.ts +++ b/packages/crm-service/src/redis/redis.service.ts @@ -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('REDIS_PORT', 6379); const password = this.config.get('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 { - 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 { @@ -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): Promise { + 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) => void, + ): Promise { + await this.subscriber.subscribe(channel); + this.subscriber.on('message', (ch: string, message: string) => { + if (ch === channel) { + try { + const parsed = JSON.parse(message) as Record; + 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}"`); + } } diff --git a/packages/frontend/src/crm/activities/ActivityFormModal.tsx b/packages/frontend/src/crm/activities/ActivityFormModal.tsx index 7898bed..0c23158 100644 --- a/packages/frontend/src/crm/activities/ActivityFormModal.tsx +++ b/packages/frontend/src/crm/activities/ActivityFormModal.tsx @@ -18,6 +18,7 @@ const ACTIVITY_TYPE_LABELS: Record = { EMAIL: 'E-Mail', MEETING: 'Meeting', TASK: 'Aufgabe', + FOLLOWUP: 'Follow-Up', }; const ACTIVITY_TYPES: ActivityType[] = [ diff --git a/packages/frontend/src/crm/api.ts b/packages/frontend/src/crm/api.ts index 574b4bb..d052970 100644 --- a/packages/frontend/src/crm/api.ts +++ b/packages/frontend/src/crm/api.ts @@ -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>(`/crm/contacts/${contactId}/owners`, data) + .then((r) => r.data), + + removeContactOwner: (contactId: string, userId: string) => + api + .delete>(`/crm/contacts/${contactId}/owners/${userId}`) + .then((r) => r.data), + + addCompanyOwner: (companyId: string, data: AddOwnerPayload) => + api + .post>(`/crm/companies/${companyId}/owners`, data) + .then((r) => r.data), + + removeCompanyOwner: (companyId: string, userId: string) => + api + .delete>(`/crm/companies/${companyId}/owners/${userId}`) + .then((r) => r.data), + + addDealOwner: (dealId: string, data: AddOwnerPayload) => + api + .post>(`/crm/deals/${dealId}/owners`, data) + .then((r) => r.data), + + removeDealOwner: (dealId: string, userId: string) => + api + .delete>(`/crm/deals/${dealId}/owners/${userId}`) + .then((r) => r.data), +}; + // --- Industries --- export const industriesApi = { diff --git a/packages/frontend/src/crm/companies/ActivityFeed.tsx b/packages/frontend/src/crm/companies/ActivityFeed.tsx index 677c91d..7efe0b7 100644 --- a/packages/frontend/src/crm/companies/ActivityFeed.tsx +++ b/packages/frontend/src/crm/companies/ActivityFeed.tsx @@ -13,6 +13,7 @@ const ACTIVITY_TYPE_LABELS: Record = { EMAIL: 'E-Mail', MEETING: 'Meeting', TASK: 'Aufgabe', + FOLLOWUP: 'Follow-Up', }; const iconSvgProps = { diff --git a/packages/frontend/src/crm/companies/CompaniesPage.tsx b/packages/frontend/src/crm/companies/CompaniesPage.tsx index 912ba68..a20c97e 100644 --- a/packages/frontend/src/crm/companies/CompaniesPage.tsx +++ b/packages/frontend/src/crm/companies/CompaniesPage.tsx @@ -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'} /> {/* 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'} /> {/* Aktionen */} diff --git a/packages/frontend/src/crm/companies/CompanyDetailPage.tsx b/packages/frontend/src/crm/companies/CompanyDetailPage.tsx index 692c03d..d83533d 100644 --- a/packages/frontend/src/crm/companies/CompanyDetailPage.tsx +++ b/packages/frontend/src/crm/companies/CompanyDetailPage.tsx @@ -298,6 +298,58 @@ export function CompanyDetailPage() { )} + {(company.deliveryStreet || company.deliveryZip || company.deliveryCity) && ( + <> + Lieferadresse + + {company.deliveryStreet && <>{company.deliveryStreet}
} + {company.deliveryZip} {company.deliveryCity} + {company.deliveryCountry && company.deliveryCountry !== 'DE' && ( + <>, {company.deliveryCountry} + )} +
+ + )} + {company.vatId && ( + <> + USt-IdNr. + {company.vatId} + + )} + {company.taxId && ( + <> + Steuernummer + {company.taxId} + + )} + {company.tradeRegisterNumber && ( + <> + Handelsregister + + {company.tradeRegisterNumber} + {company.registerCourt && ( + ({company.registerCourt}) + )} + + + )} + {company.companySize && ( + <> + Groesse + + {{'SIZE_1_10': '1–10', 'SIZE_11_50': '11–50', 'SIZE_51_200': '51–200', 'SIZE_201_500': '201–500', 'SIZE_500_PLUS': '500+'}[company.companySize] ?? company.companySize} Mitarbeiter + + + )} + {company.dataEnrichedAt && ( + <> + Datenanreicherung + + {new Date(company.dataEnrichedAt).toLocaleDateString('de-DE')} + {company.dataEnrichedSource && ` (${company.dataEnrichedSource})`} + + + )} Erstellt {formatDate(company.createdAt)} diff --git a/packages/frontend/src/crm/contacts/ContactDetailPage.tsx b/packages/frontend/src/crm/contacts/ContactDetailPage.tsx index 30c679f..a84328e 100644 --- a/packages/frontend/src/crm/contacts/ContactDetailPage.tsx +++ b/packages/frontend/src/crm/contacts/ContactDetailPage.tsx @@ -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 = { @@ -24,6 +25,7 @@ const ACTIVITY_TYPE_LABELS: Record = { EMAIL: 'E-Mail', MEETING: 'Meeting', TASK: 'Aufgabe', + FOLLOWUP: 'Follow-Up', }; function activityIcon(type: ActivityType): React.ReactNode { @@ -287,6 +289,53 @@ export function ContactDetailPage() { )} + {contact.linkedinUrl && ( + <> + LinkedIn + + + {contact.linkedinUrl.replace(/^https?:\/\/(www\.)?linkedin\.com\/in\//, '')} + + + + )} + {contact.department && ( + <> + Abteilung + {contact.department} + + )} + {contact.birthday && ( + <> + Geburtstag + + {new Date(contact.birthday).toLocaleDateString('de-DE')} + + + )} + {contact.source && ( + <> + Quelle + + {CONTACT_SOURCE_LABELS[contact.source] ?? contact.source} + + + )} + {contact.status && contact.status !== 'ACTIVE' && ( + <> + Status + + {ENTITY_STATUS_LABELS[contact.status] ?? contact.status} + + + )} {(contact.street || contact.zip || contact.city) && ( <> Adresse diff --git a/packages/frontend/src/crm/contacts/ContactFormModal.tsx b/packages/frontend/src/crm/contacts/ContactFormModal.tsx index 3dae78d..2b9aa5d 100644 --- a/packages/frontend/src/crm/contacts/ContactFormModal.tsx +++ b/packages/frontend/src/crm/contacts/ContactFormModal.tsx @@ -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(''); + const [department, setDepartment] = useState(''); + const [status, setStatus] = useState('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({ )} - {/* Position (nur bei Person) */} + {/* Position + Abteilung (nur bei Person) */} {type === 'PERSON' && ( -
- - setPosition(e.target.value)} - placeholder="z.B. Geschäftsführer, Einkäufer..." - /> +
+
+ + setPosition(e.target.value)} + placeholder="z.B. Geschaeftsfuehrer" + /> +
+
+ + setDepartment(e.target.value)} + placeholder="z.B. Einkauf, Vertrieb" + /> +
)} @@ -427,15 +459,66 @@ export function ContactFormModal({
- {/* Website */} -
- - setWebsite(e.target.value)} - placeholder="https://..." - /> + {/* Website + LinkedIn */} +
+
+ + setWebsite(e.target.value)} + placeholder="https://..." + /> +
+
+ + setLinkedinUrl(e.target.value)} + placeholder="https://linkedin.com/in/..." + /> +
+
+ + {/* Geburtsdatum + Quelle + Status */} +
+ {type === 'PERSON' && ( +
+ + setBirthday(e.target.value)} + /> +
+ )} +
+ + +
+
+ + +
{/* Adresse */} diff --git a/packages/frontend/src/crm/deals/DealDetailPage.tsx b/packages/frontend/src/crm/deals/DealDetailPage.tsx index 6f1264c..4fffdf6 100644 --- a/packages/frontend/src/crm/deals/DealDetailPage.tsx +++ b/packages/frontend/src/crm/deals/DealDetailPage.tsx @@ -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 = { @@ -237,6 +238,20 @@ export function DealDetailPage() { )} + {deal.status === 'LOST' && deal.lostReason && ( + <> + Verlustgrund + + {LOST_REASON_LABELS[deal.lostReason] ?? deal.lostReason} + {deal.lostReasonText && ( + + — {deal.lostReasonText} + + )} + + + )} + Erstellt am {formatDate(deal.createdAt)} diff --git a/packages/frontend/src/crm/deals/DealFormModal.tsx b/packages/frontend/src/crm/deals/DealFormModal.tsx index 6adcc7f..54c5f32 100644 --- a/packages/frontend/src/crm/deals/DealFormModal.tsx +++ b/packages/frontend/src/crm/deals/DealFormModal.tsx @@ -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(''); + 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({
+ {/* Lost-Reason (nur bei Status LOST) */} + {status === 'LOST' && ( + <> +
+ + +
+
+ +