Referrals: 4 neue Beziehungen + Bearbeiten-Stift pro Eintrag

Beziehungs-Dropdown erweitert um Schwiegertochter/Schwiegersohn,
Schwiegermutter/Schwiegervater, Oma/Opa, Uroma/Uropa (Whitelist
front- und backend synchron).

Bearbeiten-Stift pro Zeile (vor der Muelltonne): Beziehung und/oder
Gegen-Kunde aenderbar. Neuer PUT /:customerId/referrals/:referralId
mit Whitelist-Pruefung, Doppel-Werber-409 (Self-Ausschluss),
Portal-Block und UPDATE-Auditeintrag.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-30 10:57:04 +02:00
co-authored by Claude Opus 4.8
parent 2d8ee7f569
commit 81cd284cc4
7 changed files with 231 additions and 15 deletions
@@ -106,6 +106,61 @@ export async function createReferral(req: AuthRequest, res: Response): Promise<v
}
}
export async function updateReferral(req: AuthRequest, res: Response): Promise<void> {
try {
if (blockPortal(req, res)) return;
const customerId = parseInt(req.params.customerId);
const referralId = parseInt(req.params.referralId);
if (Number.isNaN(customerId) || Number.isNaN(referralId)) {
res.status(400).json({ success: false, error: 'Ungültige 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;
}
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 updated = await referralService.updateReferral({
id: referralId,
recruiterId,
recruitedId,
relationship: (relationship ?? '').toString(),
});
await logChange({
req,
action: 'UPDATE',
resourceType: 'CustomerReferral',
resourceId: updated.id.toString(),
label: `Werbe-Beziehung geändert: Kunde #${recruiterId} hat Kunde #${recruitedId} geworben (${updated.relationship})`,
customerId: recruitedId,
});
res.json({ success: true, data: updated } 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 Ändern der Werbe-Beziehung',
} as ApiResponse);
}
}
export async function deleteReferral(req: AuthRequest, res: Response): Promise<void> {
try {
if (blockPortal(req, res)) return;
+1
View File
@@ -60,6 +60,7 @@ router.get('/:customerId/representatives/search', authenticate, requirePermissio
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.put('/:customerId/referrals/:referralId', authenticate, requirePermission('customers:update'), customerReferralController.updateReferral);
router.delete('/:customerId/referrals/:referralId', authenticate, requirePermission('customers:update'), customerReferralController.deleteReferral);
export default router;
@@ -15,6 +15,10 @@ export const REFERRAL_RELATIONSHIPS = [
'Freund/Kumpel',
'Eltern',
'Bruder/Schwester',
'Schwiegertochter/Schwiegersohn',
'Schwiegermutter/Schwiegervater',
'Oma/Opa',
'Uroma/Uropa',
'Bekannte',
'sonstige',
] as const;
@@ -108,6 +112,60 @@ export async function createReferral(params: {
return created;
}
/**
* Ändert eine bestehende Werbe-Beziehung (Beziehungsart und/oder den
* beteiligten Gegen-Kunden). recruiter/recruited werden vom Controller aus
* der Richtung + aktueller Kundenakte bestimmt.
*/
export async function updateReferral(params: {
id: number;
recruiterId: number;
recruitedId: number;
relationship: string;
}) {
const { id, recruiterId, recruitedId, relationship } = params;
if (!Number.isInteger(id)) {
throw new ReferralError('Ungültige ID');
}
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');
}
const existing = await prisma.customerReferral.findUnique({ where: { id } });
if (!existing) {
throw new ReferralError('Eintrag nicht gefunden', 404);
}
// 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 → falls der Geworbene auf einen anderen Kunden
// geändert wird, der bereits einen Werber hat, klare Meldung statt DB-Fehler.
if (recruitedId !== existing.recruitedId) {
const clash = await prisma.customerReferral.findUnique({ where: { recruitedId } });
if (clash && clash.id !== id) {
throw new ReferralError('Dieser Kunde wurde bereits als angeworben markiert. Bitte zuerst den bestehenden Eintrag entfernen.', 409);
}
}
const updated = await prisma.customerReferral.update({
where: { id },
data: { recruiterId, recruitedId, relationship },
include: { recruiter: { select: CUSTOMER_SELECT }, recruited: { select: CUSTOMER_SELECT } },
});
return updated;
}
export async function deleteReferral(id: number): Promise<boolean> {
if (!Number.isInteger(id)) return false;
const existing = await prisma.customerReferral.findUnique({ where: { id } });