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>
134 lines
5.0 KiB
TypeScript
134 lines
5.0 KiB
TypeScript
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);
|
||
}
|
||
}
|