Kundenakte: Tab "Geworben / angeworben" (Kundenempfehlungen)

Neuer Tab vor "Datenschutz", nur Mitarbeiter/Admin (nicht Portal),
ohne Consent-Pflicht. Zwei Abschnitte:
1. "<Kunde> wurde an Board geholt durch:" – max. 1 Werber
   (DB-Unique auf recruitedId).
2. "<Kunde> 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 <noreply@anthropic.com>
This commit is contained in:
2026-07-27 17:50:15 +02:00
co-authored by Claude Opus 4.7
parent 6b08762ff9
commit cda6d2814e
10 changed files with 744 additions and 1 deletions
@@ -5,6 +5,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import toast from 'react-hot-toast';
import { customerApi, addressApi, bankCardApi, documentApi, meterApi, uploadApi, contractApi, stressfreiEmailApi, emailProviderApi, gdprApi, StressfreiEmail, ContractTreeNode } from '../../services/api';
import { EmailClientTab } from '../../components/email';
import ReferralsTab from '../../components/customers/ReferralsTab';
import { useAuth } from '../../context/AuthContext';
import Card from '../../components/ui/Card';
import Button from '../../components/ui/Button';
@@ -193,6 +194,19 @@ export default function CustomerDetail({ portalCustomerId }: { portalCustomerId?
/>
),
}] : []),
...(hasPermission('customers:read') && !isCustomerPortal ? [{
id: 'referrals',
label: 'Geworben / angeworben',
// Bewusst ohne Consent-Gate (hasConsentApproval): funktioniert auch
// ohne Datenschutz-Einwilligung des Kunden. Nur Staff, nie Portal.
content: (
<ReferralsTab
customerId={customerId}
customerName={c.type === 'BUSINESS' && c.companyName ? c.companyName : `${c.firstName} ${c.lastName}`}
canEdit={hasPermission('customers:update')}
/>
),
}] : []),
...(hasPermission('customers:read') && !isCustomerPortal ? [{
id: 'consents',
label: 'Einwilligungen / Datenschutz',