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
@@ -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<void> {
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<void> {
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<void> {
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<void> {
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);
}
}
+9
View File
@@ -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;
@@ -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: "<Kunde> wurde an Board geholt durch:"
recruitedBy: recruitedBy
? { id: recruitedBy.id, relationship: recruitedBy.relationship, customer: recruitedBy.recruiter }
: null,
// Abschnitt 2: "<Kunde> 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<boolean> {
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,
});
}