From cda6d2814e1caf9e29b8d43b8d1fd794580615bb Mon Sep 17 00:00:00 2001 From: duffyduck Date: Mon, 27 Jul 2026 17:50:15 +0200 Subject: [PATCH] Kundenakte: Tab "Geworben / angeworben" (Kundenempfehlungen) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Neuer Tab vor "Datenschutz", nur Mitarbeiter/Admin (nicht Portal), ohne Consent-Pflicht. Zwei Abschnitte: 1. " wurde an Board geholt durch:" – max. 1 Werber (DB-Unique auf recruitedId). 2. " hat folgende Kunden an Board geholt:" – beliebig viele. Jede Zeile: Kunde per Lupe-Such-Modal (breite Suche über Name/ Kundennr./Firma/E-Mail/Telefon) + Beziehungs-Dropdown. Löschen + Externtab-Link zur Kundenakte pro Zeile. Bidirektional aus EINEM Datensatz: "A geworben durch B" erscheint automatisch bei B unter "hat geworben"; von beiden Akten hinzufügbar/löschbar. Backend: neues Model CustomerReferral (recruiter/recruited FKs, recruitedId @unique, relationship) + Migration. Beziehungs-Whitelist serverseitig; Self-Werbung + Doppel-Werber (409) abgefangen. Portal-Token wird explizit geblockt (Defense-in-Depth, nicht nur UI-Ausblendung). CREATE/DELETE auditiert. Co-Authored-By: Claude Opus 4.7 --- .../migration.sql | 20 ++ backend/prisma/schema.prisma | 24 ++ .../customerReferral.controller.ts | 133 +++++++ backend/src/routes/customer.routes.ts | 9 + .../src/services/customerReferral.service.ts | 140 ++++++++ docs/todo.md | 19 + .../src/components/customers/ReferralsTab.tsx | 333 ++++++++++++++++++ .../src/pages/customers/CustomerDetail.tsx | 14 + frontend/src/services/api.ts | 25 +- frontend/src/types/index.ts | 28 ++ 10 files changed, 744 insertions(+), 1 deletion(-) create mode 100644 backend/prisma/migrations/20260728100000_customer_referral/migration.sql create mode 100644 backend/src/controllers/customerReferral.controller.ts create mode 100644 backend/src/services/customerReferral.service.ts create mode 100644 frontend/src/components/customers/ReferralsTab.tsx diff --git a/backend/prisma/migrations/20260728100000_customer_referral/migration.sql b/backend/prisma/migrations/20260728100000_customer_referral/migration.sql new file mode 100644 index 00000000..2886508a --- /dev/null +++ b/backend/prisma/migrations/20260728100000_customer_referral/migration.sql @@ -0,0 +1,20 @@ +-- Werbe-Beziehung zwischen Kunden ("Geworben / angeworben"). +-- recruitedId ist UNIQUE → ein Kunde kann nur von EINER Person geworben sein. +-- Beide FKs mit ON DELETE CASCADE: wird ein Kunde gelöscht, verschwinden +-- seine Werbe-Verknüpfungen mit. + +CREATE TABLE IF NOT EXISTS `CustomerReferral` ( + `id` INT NOT NULL AUTO_INCREMENT, + `recruiterId` INT NOT NULL, + `recruitedId` INT NOT NULL, + `relationship` VARCHAR(191) NOT NULL, + `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `createdBy` VARCHAR(191) NULL, + PRIMARY KEY (`id`), + UNIQUE INDEX `CustomerReferral_recruitedId_key` (`recruitedId`), + INDEX `CustomerReferral_recruiterId_idx` (`recruiterId`), + CONSTRAINT `CustomerReferral_recruiterId_fkey` FOREIGN KEY (`recruiterId`) + REFERENCES `Customer` (`id`) ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT `CustomerReferral_recruitedId_fkey` FOREIGN KEY (`recruitedId`) + REFERENCES `Customer` (`id`) ON DELETE CASCADE ON UPDATE CASCADE +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 778703fd..007244dd 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -213,10 +213,34 @@ model Customer { // Persönliche Anrede-Präferenzen aller Mitarbeiter zu diesem Kunden. userSalutations UserCustomerSalutation[] + // Werbung: wer hat wen an Board geholt. + // referralAsRecruited = dieser Kunde WURDE geworben (max. 1 Werber). + // referralsAsRecruiter = Kunden, die DIESER Kunde geworben hat (beliebig viele). + referralAsRecruited CustomerReferral? @relation("ReferralRecruited") + referralsAsRecruiter CustomerReferral[] @relation("ReferralRecruiter") + createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } +// Werbe-Beziehung zwischen zwei Kunden ("Geworben / angeworben"). +// Ein Datensatz = ein Werber (recruiter) hat einen Kunden (recruited) an +// Board geholt, plus die Beziehungsart. `recruitedId` ist @unique, damit ein +// Kunde nur von EINER Person geworben sein kann (Abschnitt 1 in der UI). +// Ein Werber kann beliebig viele Kunden werben (Abschnitt 2). +model CustomerReferral { + id Int @id @default(autoincrement()) + recruiterId Int + recruiter Customer @relation("ReferralRecruiter", fields: [recruiterId], references: [id], onDelete: Cascade) + recruitedId Int @unique + recruited Customer @relation("ReferralRecruited", fields: [recruitedId], references: [id], onDelete: Cascade) + relationship String // Ehepartner/in, Kind, Freund/Kumpel, … + createdAt DateTime @default(now()) + createdBy String? + + @@index([recruiterId]) +} + // ==================== USER-CUSTOMER SALUTATION ==================== // Per-User-Präferenz, ob der eingeloggte Mitarbeiter mit dem jeweiligen // Kunden per Du oder Sie ist. Fehlender Eintrag → fällt auf den diff --git a/backend/src/controllers/customerReferral.controller.ts b/backend/src/controllers/customerReferral.controller.ts new file mode 100644 index 00000000..677eb00c --- /dev/null +++ b/backend/src/controllers/customerReferral.controller.ts @@ -0,0 +1,133 @@ +import { Response } from 'express'; +import { ApiResponse, AuthRequest } from '../types/index.js'; +import { logChange } from '../services/audit.service.js'; +import * as referralService from '../services/customerReferral.service.js'; + +// Der ganze Bereich ist rein für Mitarbeiter/Admins – Portal-Kunden dürfen +// Werbe-Beziehungen weder sehen noch ändern. Defense-in-Depth: nicht nur die +// UI blendet den Tab aus, der Endpoint blockt Portal-Token explizit. +function blockPortal(req: AuthRequest, res: Response): boolean { + if (req.user?.isCustomerPortal) { + res.status(403).json({ success: false, error: 'Kein Zugriff' } as ApiResponse); + return true; + } + return false; +} + +export async function getReferrals(req: AuthRequest, res: Response): Promise { + try { + if (blockPortal(req, res)) return; + const customerId = parseInt(req.params.customerId); + if (Number.isNaN(customerId)) { + res.status(400).json({ success: false, error: 'Ungültige Kunden-ID' } as ApiResponse); + return; + } + const data = await referralService.getReferralsForCustomer(customerId); + res.json({ success: true, data } as ApiResponse); + } catch (error) { + res.status(500).json({ success: false, error: 'Fehler beim Laden der Werbe-Beziehungen' } as ApiResponse); + } +} + +export async function searchCustomers(req: AuthRequest, res: Response): Promise { + try { + if (blockPortal(req, res)) return; + const customerId = parseInt(req.params.customerId); + if (Number.isNaN(customerId)) { + res.status(400).json({ success: false, error: 'Ungültige Kunden-ID' } as ApiResponse); + return; + } + const search = req.query.search; + if (!search || typeof search !== 'string' || search.trim().length < 2) { + res.json({ success: true, data: [] } as ApiResponse); + return; + } + const customers = await referralService.searchCustomersForReferral(search.trim(), customerId); + res.json({ success: true, data: customers } as ApiResponse); + } catch (error) { + res.status(500).json({ success: false, error: 'Fehler bei der Suche' } as ApiResponse); + } +} + +export async function createReferral(req: AuthRequest, res: Response): Promise { + try { + if (blockPortal(req, res)) return; + const customerId = parseInt(req.params.customerId); + if (Number.isNaN(customerId)) { + res.status(400).json({ success: false, error: 'Ungültige Kunden-ID' } as ApiResponse); + return; + } + const { direction, otherCustomerId, relationship } = req.body ?? {}; + const otherId = parseInt(otherCustomerId); + if (Number.isNaN(otherId)) { + res.status(400).json({ success: false, error: 'Kein Kunde ausgewählt' } as ApiResponse); + return; + } + + // direction bestimmt, wer Werber und wer Geworbener ist: + // 'recruitedBy' = dieser Kunde wurde vom anderen geworben (Abschnitt 1) + // 'recruited' = dieser Kunde hat den anderen geworben (Abschnitt 2) + let recruiterId: number; + let recruitedId: number; + if (direction === 'recruitedBy') { + recruiterId = otherId; + recruitedId = customerId; + } else if (direction === 'recruited') { + recruiterId = customerId; + recruitedId = otherId; + } else { + res.status(400).json({ success: false, error: 'Ungültige Richtung' } as ApiResponse); + return; + } + + const created = await referralService.createReferral({ + recruiterId, + recruitedId, + relationship: (relationship ?? '').toString(), + createdBy: req.user?.email, + }); + + await logChange({ + req, + action: 'CREATE', + resourceType: 'CustomerReferral', + resourceId: created.id.toString(), + label: `Werbe-Beziehung angelegt: Kunde #${recruiterId} hat Kunde #${recruitedId} geworben (${created.relationship})`, + customerId: recruitedId, + }); + + res.status(201).json({ success: true, data: created } as ApiResponse); + } catch (error) { + const status = (error as referralService.ReferralError)?.status ?? 500; + res.status(status).json({ + success: false, + error: error instanceof Error ? error.message : 'Fehler beim Anlegen der Werbe-Beziehung', + } as ApiResponse); + } +} + +export async function deleteReferral(req: AuthRequest, res: Response): Promise { + try { + if (blockPortal(req, res)) return; + const referralId = parseInt(req.params.referralId); + if (Number.isNaN(referralId)) { + res.status(400).json({ success: false, error: 'Ungültige ID' } as ApiResponse); + return; + } + const ok = await referralService.deleteReferral(referralId); + if (!ok) { + res.status(404).json({ success: false, error: 'Eintrag nicht gefunden' } as ApiResponse); + return; + } + await logChange({ + req, + action: 'DELETE', + resourceType: 'CustomerReferral', + resourceId: referralId.toString(), + label: `Werbe-Beziehung #${referralId} entfernt`, + }); + res.json({ success: true } as ApiResponse); + } catch (error) { + res.status(500).json({ success: false, error: 'Fehler beim Entfernen der Werbe-Beziehung' } as ApiResponse); + } +} diff --git a/backend/src/routes/customer.routes.ts b/backend/src/routes/customer.routes.ts index ad35a74c..e151056e 100644 --- a/backend/src/routes/customer.routes.ts +++ b/backend/src/routes/customer.routes.ts @@ -1,5 +1,6 @@ import { Router } from 'express'; import * as customerController from '../controllers/customer.controller.js'; +import * as customerReferralController from '../controllers/customerReferral.controller.js'; import * as stressfreiEmailController from '../controllers/stressfreiEmail.controller.js'; import { authenticate, requirePermission, requireCustomerAccess } from '../middleware/auth.js'; @@ -53,4 +54,12 @@ router.post('/:customerId/representatives', authenticate, requirePermission('cus router.delete('/:customerId/representatives/:representativeId', authenticate, requirePermission('customers:update'), customerController.removeRepresentative); router.get('/:customerId/representatives/search', authenticate, requirePermission('customers:read'), customerController.searchForRepresentative); +// Werbung ("Geworben / angeworben") – nur Mitarbeiter/Admins (Controller +// blockt Portal zusätzlich explizit). /search VOR der Liste ist unkritisch, +// da unterschiedliche Pfade, aber der Klarheit halber gruppiert. +router.get('/:customerId/referrals/search', authenticate, requirePermission('customers:read'), customerReferralController.searchCustomers); +router.get('/:customerId/referrals', authenticate, requirePermission('customers:read'), customerReferralController.getReferrals); +router.post('/:customerId/referrals', authenticate, requirePermission('customers:update'), customerReferralController.createReferral); +router.delete('/:customerId/referrals/:referralId', authenticate, requirePermission('customers:update'), customerReferralController.deleteReferral); + export default router; diff --git a/backend/src/services/customerReferral.service.ts b/backend/src/services/customerReferral.service.ts new file mode 100644 index 00000000..a10fe02a --- /dev/null +++ b/backend/src/services/customerReferral.service.ts @@ -0,0 +1,140 @@ +// ==================== CUSTOMER REFERRAL SERVICE ==================== +// "Geworben / angeworben": wer hat wen an Board geholt. + +import prisma from '../lib/prisma.js'; + +// Erlaubte Beziehungsarten (Whitelist). Server vertraut dem Frontend-Dropdown +// nicht – ein handgebauter Request mit beliebigem String wird abgewiesen. +export const REFERRAL_RELATIONSHIPS = [ + 'Ehepartner/in', + 'Lebenspartner/in', + 'Kind', + 'Enkelkind', + 'Onkel/Tante', + 'Neffe', + 'Freund/Kumpel', + 'Eltern', + 'Bruder/Schwester', + 'Bekannte', + 'sonstige', +] as const; + +const CUSTOMER_SELECT = { + id: true, + customerNumber: true, + firstName: true, + lastName: true, + companyName: true, + type: true, +} as const; + +/** + * Liefert für einen Kunden beide Richtungen: + * - recruitedBy: von wem dieser Kunde geworben wurde (max. 1) + * - recruited: welche Kunden dieser Kunde geworben hat (beliebig viele) + */ +export async function getReferralsForCustomer(customerId: number) { + const [recruitedBy, recruited] = await Promise.all([ + prisma.customerReferral.findUnique({ + where: { recruitedId: customerId }, + include: { recruiter: { select: CUSTOMER_SELECT } }, + }), + prisma.customerReferral.findMany({ + where: { recruiterId: customerId }, + include: { recruited: { select: CUSTOMER_SELECT } }, + orderBy: { createdAt: 'asc' }, + }), + ]); + + return { + // Abschnitt 1: " wurde an Board geholt durch:" + recruitedBy: recruitedBy + ? { id: recruitedBy.id, relationship: recruitedBy.relationship, customer: recruitedBy.recruiter } + : null, + // Abschnitt 2: " hat folgende Kunden an Board geholt:" + recruited: recruited.map((r) => ({ + id: r.id, + relationship: r.relationship, + customer: r.recruited, + })), + }; +} + +export class ReferralError extends Error { + status: number; + constructor(message: string, status = 400) { + super(message); + this.status = status; + } +} + +/** + * Legt eine Werbe-Beziehung an: `recruiterId` hat `recruitedId` an Board geholt. + */ +export async function createReferral(params: { + recruiterId: number; + recruitedId: number; + relationship: string; + createdBy?: string; +}) { + const { recruiterId, recruitedId, relationship, createdBy } = params; + + if (!Number.isInteger(recruiterId) || !Number.isInteger(recruitedId)) { + throw new ReferralError('Ungültige Kunden-ID'); + } + if (recruiterId === recruitedId) { + throw new ReferralError('Ein Kunde kann sich nicht selbst werben'); + } + if (!REFERRAL_RELATIONSHIPS.includes(relationship as typeof REFERRAL_RELATIONSHIPS[number])) { + throw new ReferralError('Ungültige Beziehungsart'); + } + + // Beide Kunden müssen existieren + const count = await prisma.customer.count({ where: { id: { in: [recruiterId, recruitedId] } } }); + if (count !== 2) { + throw new ReferralError('Kunde nicht gefunden', 404); + } + + // recruitedId ist @unique → höchstens ein Werber. Vorab prüfen für klare Meldung. + const existing = await prisma.customerReferral.findUnique({ where: { recruitedId } }); + if (existing) { + throw new ReferralError('Dieser Kunde wurde bereits als angeworben markiert. Bitte zuerst den bestehenden Eintrag entfernen.', 409); + } + + const created = await prisma.customerReferral.create({ + data: { recruiterId, recruitedId, relationship, createdBy }, + include: { recruiter: { select: CUSTOMER_SELECT }, recruited: { select: CUSTOMER_SELECT } }, + }); + return created; +} + +export async function deleteReferral(id: number): Promise { + if (!Number.isInteger(id)) return false; + const existing = await prisma.customerReferral.findUnique({ where: { id } }); + if (!existing) return false; + await prisma.customerReferral.delete({ where: { id } }); + return true; +} + +/** + * Kundensuche für die Werber-Auswahl. Breit über die wichtigsten + * Kundenakten-Felder, schließt den eigenen Kunden aus. + */ +export async function searchCustomersForReferral(search: string, excludeCustomerId: number) { + return prisma.customer.findMany({ + where: { + id: { not: excludeCustomerId }, + OR: [ + { firstName: { contains: search } }, + { lastName: { contains: search } }, + { companyName: { contains: search } }, + { customerNumber: { contains: search } }, + { email: { contains: search } }, + { phone: { contains: search } }, + { mobile: { contains: search } }, + ], + }, + select: CUSTOMER_SELECT, + take: 10, + }); +} diff --git a/docs/todo.md b/docs/todo.md index 2f2a2f46..9a11e6f9 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -97,6 +97,25 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung ## ✅ Erledigt +- [x] **🤝 Kundenakte: Tab „Geworben / angeworben" (Kundenempfehlungen)** + - Neuer Tab vor „Datenschutz", **nur Mitarbeiter/Admin** (nicht Portal), + **ohne** Consent-Pflicht nutzbar. Zwei Abschnitte: + 1. „ wurde an Board geholt durch:" – **max. 1** Werber + (DB-Unique auf `recruitedId`). + 2. „ hat folgende Kunden an Board geholt:" – beliebig viele. + - Jede Zeile: Kunde (per Lupe-Modal gesucht, breite Suche über + Name/Nr./Firma/E-Mail/Telefon) + Beziehungs-Dropdown (Ehepartner/in, + Kind, Freund/Kumpel, … „Bitte auswählen!"). Löschen + Externtab-Link + zur Kundenakte pro Zeile. + - **Bidirektional aus einem Datensatz:** trägt man in Kunde A „geworben + durch B" ein, erscheint A automatisch bei B unter „hat geworben". + Von beiden Akten hinzufügbar/löschbar. + - Neues Model `CustomerReferral` (recruiter/recruited FKs, `recruitedId` + @unique, `relationship`) + Migration. Beziehungs-Whitelist server- + seitig; Self-Werbung + Doppel-Werber (409) abgefangen; Portal-Token + explizit geblockt (Defense-in-Depth, nicht nur UI). CREATE/DELETE + auditiert. + - [x] **🔄 „Neue Version verfügbar"-Banner (offener-Tab-Problem)** - Cache-Header waren schon optimal (index.html `no-store`, Assets `immutable`) – aber eine laufende SPA holt sich nach einem Deploy diff --git a/frontend/src/components/customers/ReferralsTab.tsx b/frontend/src/components/customers/ReferralsTab.tsx new file mode 100644 index 00000000..a19583db --- /dev/null +++ b/frontend/src/components/customers/ReferralsTab.tsx @@ -0,0 +1,333 @@ +import { useState } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { Link } from 'react-router-dom'; +import { Plus, Search, Trash2, ExternalLink, X, Check } from 'lucide-react'; +import toast from 'react-hot-toast'; +import { referralApi } from '../../services/api'; +import { REFERRAL_RELATIONSHIPS } from '../../types'; +import type { CustomerSummary, ReferralEntry } from '../../types'; +import Modal from '../ui/Modal'; +import Input from '../ui/Input'; +import Select from '../ui/Select'; + +interface ReferralsTabProps { + customerId: number; + customerName: string; + canEdit: boolean; +} + +function customerLabel(c: CustomerSummary): string { + const name = c.type === 'BUSINESS' && c.companyName ? c.companyName : `${c.firstName} ${c.lastName}`; + return `${name} (${c.customerNumber})`; +} + +// Modal zum Suchen und Auswählen eines Kunden. +function CustomerSearchModal({ + customerId, + isOpen, + onClose, + onSelect, +}: { + customerId: number; + isOpen: boolean; + onClose: () => void; + onSelect: (c: CustomerSummary) => void; +}) { + const [term, setTerm] = useState(''); + const [results, setResults] = useState([]); + const [searching, setSearching] = useState(false); + + const runSearch = async (value: string) => { + setTerm(value); + if (value.trim().length < 2) { + setResults([]); + return; + } + setSearching(true); + try { + const res = await referralApi.search(customerId, value.trim()); + setResults(res.data || []); + } catch { + setResults([]); + } finally { + setSearching(false); + } + }; + + const handleClose = () => { + setTerm(''); + setResults([]); + onClose(); + }; + + return ( + +
+ runSearch(e.target.value)} + /> +

Mindestens 2 Zeichen eingeben.

+
+ {searching ? ( +
Suche …
+ ) : results.length > 0 ? ( + results.map((c) => ( + + )) + ) : term.trim().length >= 2 ? ( +
Keine Kunden gefunden.
+ ) : ( +
Suchbegriff eingeben …
+ )} +
+
+
+ ); +} + +// Eine Draft-Zeile: Kunde per Lupe wählen + Beziehung, dann speichern. +function DraftRow({ + customerId, + direction, + onCancel, + onSaved, +}: { + customerId: number; + direction: 'recruitedBy' | 'recruited'; + onCancel: () => void; + onSaved: () => void; +}) { + const [selected, setSelected] = useState(null); + const [relationship, setRelationship] = useState(''); + const [showSearch, setShowSearch] = useState(false); + + const saveMutation = useMutation({ + mutationFn: () => + referralApi.create(customerId, { + direction, + otherCustomerId: selected!.id, + relationship, + }), + onSuccess: () => { + onSaved(); + }, + onError: (err: Error) => { + toast.error(err.message || 'Speichern fehlgeschlagen'); + }, + }); + + return ( +
+
+ + {selected ? customerLabel(selected) : 'Kunde wählen …'} + + +
+
+