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 } });
+6
View File
@@ -115,6 +115,12 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung
seitig; Self-Werbung + Doppel-Werber (409) abgefangen; Portal-Token
explizit geblockt (Defense-in-Depth, nicht nur UI). CREATE/DELETE
auditiert.
- **Nachtrag 2026-07-30:** Beziehungs-Dropdown um Schwiegertochter/
Schwiegersohn, Schwiegermutter/Schwiegervater, Oma/Opa, Uroma/Uropa
erweitert (Whitelist front- & backend synchron). Bearbeiten-Stift pro
Zeile (vor der Mülltonne): ändert Beziehung und/oder Gegen-Kunden;
neuer `PUT /:customerId/referrals/:referralId` (Whitelist, Doppel-
Werber-409 mit Self-Ausschluss, Portal-Block, UPDATE auditiert).
- [x] **🔄 „Neue Version verfügbar"-Banner (offener-Tab-Problem)**
- Cache-Header waren schon optimal (index.html `no-store`, Assets
@@ -1,7 +1,7 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Link } from 'react-router-dom';
import { Plus, Search, Trash2, ExternalLink, X, Check } from 'lucide-react';
import { Plus, Search, Trash2, ExternalLink, X, Check, Pencil } from 'lucide-react';
import toast from 'react-hot-toast';
import { referralApi } from '../../services/api';
import { REFERRAL_RELATIONSHIPS } from '../../types';
@@ -177,24 +177,99 @@ function DraftRow({
);
}
// Eine gespeicherte Zeile: Kunde (Link) + Beziehung + löschen + Externtab.
// Eine gespeicherte Zeile: Kunde (Link) + Beziehung + bearbeiten/löschen + Externtab.
function SavedRow({
customerId,
entry,
direction,
canEdit,
onDeleted,
onChanged,
}: {
customerId: number;
entry: ReferralEntry;
direction: 'recruitedBy' | 'recruited';
canEdit: boolean;
onDeleted: () => void;
onChanged: () => void;
}) {
const [editing, setEditing] = useState(false);
// Im Bearbeiten-Modus veränderbar: Gegen-Kunde und Beziehung.
const [selected, setSelected] = useState<CustomerSummary>(entry.customer);
const [relationship, setRelationship] = useState(entry.relationship);
const [showSearch, setShowSearch] = useState(false);
const startEdit = () => {
setSelected(entry.customer);
setRelationship(entry.relationship);
setEditing(true);
};
const deleteMutation = useMutation({
mutationFn: () => referralApi.remove(customerId, entry.id),
onSuccess: () => onDeleted(),
onSuccess: () => onChanged(),
onError: (err: Error) => toast.error(err.message || 'Löschen fehlgeschlagen'),
});
const saveMutation = useMutation({
mutationFn: () =>
referralApi.update(customerId, entry.id, {
direction,
otherCustomerId: selected.id,
relationship,
}),
onSuccess: () => {
setEditing(false);
onChanged();
},
onError: (err: Error) => toast.error(err.message || 'Speichern fehlgeschlagen'),
});
if (editing) {
return (
<div className="flex items-center gap-2 py-2 border-b bg-amber-50/50 px-2 rounded">
<div className="flex-1 flex items-center gap-2">
<span className="text-sm font-medium">{customerLabel(selected)}</span>
<button
onClick={() => setShowSearch(true)}
className="text-gray-400 hover:text-blue-600 p-1 rounded"
title="Anderen Kunden wählen"
>
<Search className="w-4 h-4" />
</button>
</div>
<div className="w-56">
<Select
value={relationship}
onChange={(e) => setRelationship(e.target.value)}
options={REFERRAL_RELATIONSHIPS.map((r) => ({ value: r, label: r }))}
placeholder="Bitte auswählen!"
/>
</div>
<button
onClick={() => saveMutation.mutate()}
disabled={!relationship || saveMutation.isPending}
className="text-green-600 hover:text-green-700 disabled:text-gray-300 p-1 rounded"
title="Speichern"
>
<Check className="w-5 h-5" />
</button>
<button
onClick={() => setEditing(false)}
className="text-gray-400 hover:text-red-600 p-1 rounded"
title="Abbrechen"
>
<X className="w-5 h-5" />
</button>
<CustomerSearchModal
customerId={customerId}
isOpen={showSearch}
onClose={() => setShowSearch(false)}
onSelect={(c) => setSelected(c)}
/>
</div>
);
}
return (
<div className="flex items-center gap-2 py-2 border-b px-2">
<div className="flex-1 flex items-center gap-2">
@@ -218,14 +293,23 @@ function SavedRow({
</div>
<div className="w-56 text-sm">{entry.relationship}</div>
{canEdit ? (
<button
onClick={() => deleteMutation.mutate()}
disabled={deleteMutation.isPending}
className="text-gray-400 hover:text-red-600 p-1 rounded"
title="Eintrag entfernen"
>
<Trash2 className="w-4 h-4" />
</button>
<>
<button
onClick={startEdit}
className="text-gray-400 hover:text-blue-600 p-1 rounded"
title="Eintrag bearbeiten"
>
<Pencil className="w-4 h-4" />
</button>
<button
onClick={() => deleteMutation.mutate()}
disabled={deleteMutation.isPending}
className="text-gray-400 hover:text-red-600 p-1 rounded"
title="Eintrag entfernen"
>
<Trash2 className="w-4 h-4" />
</button>
</>
) : (
<span className="w-6" />
)}
@@ -271,7 +355,7 @@ export default function ReferralsTab({ customerId, customerName, canEdit }: Refe
</h3>
{recruitedBy || addingRecruitedBy ? columnHeader : null}
{recruitedBy ? (
<SavedRow customerId={customerId} entry={recruitedBy} canEdit={canEdit} onDeleted={refresh} />
<SavedRow customerId={customerId} entry={recruitedBy} direction="recruitedBy" canEdit={canEdit} onChanged={refresh} />
) : addingRecruitedBy ? (
<DraftRow
customerId={customerId}
@@ -303,7 +387,7 @@ export default function ReferralsTab({ customerId, customerName, canEdit }: Refe
</h3>
{recruited.length > 0 || addingRecruited ? columnHeader : null}
{recruited.map((entry) => (
<SavedRow key={entry.id} customerId={customerId} entry={entry} canEdit={canEdit} onDeleted={refresh} />
<SavedRow key={entry.id} customerId={customerId} entry={entry} direction="recruited" canEdit={canEdit} onChanged={refresh} />
))}
{addingRecruited && (
<DraftRow
+8
View File
@@ -249,6 +249,14 @@ export const referralApi = {
const res = await api.post<ApiResponse<unknown>>(`/customers/${customerId}/referrals`, payload);
return res.data;
},
update: async (
customerId: number,
referralId: number,
payload: { direction: 'recruitedBy' | 'recruited'; otherCustomerId: number; relationship: string },
) => {
const res = await api.put<ApiResponse<unknown>>(`/customers/${customerId}/referrals/${referralId}`, payload);
return res.data;
},
remove: async (customerId: number, referralId: number) => {
const res = await api.delete<ApiResponse<void>>(`/customers/${customerId}/referrals/${referralId}`);
return res.data;
+4
View File
@@ -36,6 +36,10 @@ export const REFERRAL_RELATIONSHIPS = [
'Freund/Kumpel',
'Eltern',
'Bruder/Schwester',
'Schwiegertochter/Schwiegersohn',
'Schwiegermutter/Schwiegervater',
'Oma/Opa',
'Uroma/Uropa',
'Bekannte',
'sonstige',
] as const;