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,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,
});
}