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
@@ -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;
+24
View File
@@ -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