diff --git a/backend/src/controllers/customerReferral.controller.ts b/backend/src/controllers/customerReferral.controller.ts index 677eb00c..149a30db 100644 --- a/backend/src/controllers/customerReferral.controller.ts +++ b/backend/src/controllers/customerReferral.controller.ts @@ -106,6 +106,61 @@ export async function createReferral(req: AuthRequest, res: Response): Promise { + 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 { try { if (blockPortal(req, res)) return; diff --git a/backend/src/routes/customer.routes.ts b/backend/src/routes/customer.routes.ts index e151056e..8f013445 100644 --- a/backend/src/routes/customer.routes.ts +++ b/backend/src/routes/customer.routes.ts @@ -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; diff --git a/backend/src/services/customerReferral.service.ts b/backend/src/services/customerReferral.service.ts index a10fe02a..97f8245d 100644 --- a/backend/src/services/customerReferral.service.ts +++ b/backend/src/services/customerReferral.service.ts @@ -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 { if (!Number.isInteger(id)) return false; const existing = await prisma.customerReferral.findUnique({ where: { id } }); diff --git a/docs/todo.md b/docs/todo.md index 9a11e6f9..c8259565 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -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 diff --git a/frontend/src/components/customers/ReferralsTab.tsx b/frontend/src/components/customers/ReferralsTab.tsx index a19583db..6a508215 100644 --- a/frontend/src/components/customers/ReferralsTab.tsx +++ b/frontend/src/components/customers/ReferralsTab.tsx @@ -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(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 ( +
+
+ {customerLabel(selected)} + +
+
+