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:
@@ -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,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user