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