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:
@@ -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> {
|
export async function deleteReferral(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
if (blockPortal(req, res)) return;
|
if (blockPortal(req, res)) return;
|
||||||
|
|||||||
@@ -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/search', authenticate, requirePermission('customers:read'), customerReferralController.searchCustomers);
|
||||||
router.get('/:customerId/referrals', authenticate, requirePermission('customers:read'), customerReferralController.getReferrals);
|
router.get('/:customerId/referrals', authenticate, requirePermission('customers:read'), customerReferralController.getReferrals);
|
||||||
router.post('/:customerId/referrals', authenticate, requirePermission('customers:update'), customerReferralController.createReferral);
|
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);
|
router.delete('/:customerId/referrals/:referralId', authenticate, requirePermission('customers:update'), customerReferralController.deleteReferral);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ export const REFERRAL_RELATIONSHIPS = [
|
|||||||
'Freund/Kumpel',
|
'Freund/Kumpel',
|
||||||
'Eltern',
|
'Eltern',
|
||||||
'Bruder/Schwester',
|
'Bruder/Schwester',
|
||||||
|
'Schwiegertochter/Schwiegersohn',
|
||||||
|
'Schwiegermutter/Schwiegervater',
|
||||||
|
'Oma/Opa',
|
||||||
|
'Uroma/Uropa',
|
||||||
'Bekannte',
|
'Bekannte',
|
||||||
'sonstige',
|
'sonstige',
|
||||||
] as const;
|
] as const;
|
||||||
@@ -108,6 +112,60 @@ export async function createReferral(params: {
|
|||||||
return created;
|
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> {
|
export async function deleteReferral(id: number): Promise<boolean> {
|
||||||
if (!Number.isInteger(id)) return false;
|
if (!Number.isInteger(id)) return false;
|
||||||
const existing = await prisma.customerReferral.findUnique({ where: { id } });
|
const existing = await prisma.customerReferral.findUnique({ where: { id } });
|
||||||
|
|||||||
@@ -115,6 +115,12 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung
|
|||||||
seitig; Self-Werbung + Doppel-Werber (409) abgefangen; Portal-Token
|
seitig; Self-Werbung + Doppel-Werber (409) abgefangen; Portal-Token
|
||||||
explizit geblockt (Defense-in-Depth, nicht nur UI). CREATE/DELETE
|
explizit geblockt (Defense-in-Depth, nicht nur UI). CREATE/DELETE
|
||||||
auditiert.
|
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)**
|
- [x] **🔄 „Neue Version verfügbar"-Banner (offener-Tab-Problem)**
|
||||||
- Cache-Header waren schon optimal (index.html `no-store`, Assets
|
- Cache-Header waren schon optimal (index.html `no-store`, Assets
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { Link } from 'react-router-dom';
|
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 toast from 'react-hot-toast';
|
||||||
import { referralApi } from '../../services/api';
|
import { referralApi } from '../../services/api';
|
||||||
import { REFERRAL_RELATIONSHIPS } from '../../types';
|
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({
|
function SavedRow({
|
||||||
customerId,
|
customerId,
|
||||||
entry,
|
entry,
|
||||||
|
direction,
|
||||||
canEdit,
|
canEdit,
|
||||||
onDeleted,
|
onChanged,
|
||||||
}: {
|
}: {
|
||||||
customerId: number;
|
customerId: number;
|
||||||
entry: ReferralEntry;
|
entry: ReferralEntry;
|
||||||
|
direction: 'recruitedBy' | 'recruited';
|
||||||
canEdit: boolean;
|
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({
|
const deleteMutation = useMutation({
|
||||||
mutationFn: () => referralApi.remove(customerId, entry.id),
|
mutationFn: () => referralApi.remove(customerId, entry.id),
|
||||||
onSuccess: () => onDeleted(),
|
onSuccess: () => onChanged(),
|
||||||
onError: (err: Error) => toast.error(err.message || 'Löschen fehlgeschlagen'),
|
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 (
|
return (
|
||||||
<div className="flex items-center gap-2 py-2 border-b px-2">
|
<div className="flex items-center gap-2 py-2 border-b px-2">
|
||||||
<div className="flex-1 flex items-center gap-2">
|
<div className="flex-1 flex items-center gap-2">
|
||||||
@@ -218,14 +293,23 @@ function SavedRow({
|
|||||||
</div>
|
</div>
|
||||||
<div className="w-56 text-sm">{entry.relationship}</div>
|
<div className="w-56 text-sm">{entry.relationship}</div>
|
||||||
{canEdit ? (
|
{canEdit ? (
|
||||||
<button
|
<>
|
||||||
onClick={() => deleteMutation.mutate()}
|
<button
|
||||||
disabled={deleteMutation.isPending}
|
onClick={startEdit}
|
||||||
className="text-gray-400 hover:text-red-600 p-1 rounded"
|
className="text-gray-400 hover:text-blue-600 p-1 rounded"
|
||||||
title="Eintrag entfernen"
|
title="Eintrag bearbeiten"
|
||||||
>
|
>
|
||||||
<Trash2 className="w-4 h-4" />
|
<Pencil className="w-4 h-4" />
|
||||||
</button>
|
</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" />
|
<span className="w-6" />
|
||||||
)}
|
)}
|
||||||
@@ -271,7 +355,7 @@ export default function ReferralsTab({ customerId, customerName, canEdit }: Refe
|
|||||||
</h3>
|
</h3>
|
||||||
{recruitedBy || addingRecruitedBy ? columnHeader : null}
|
{recruitedBy || addingRecruitedBy ? columnHeader : null}
|
||||||
{recruitedBy ? (
|
{recruitedBy ? (
|
||||||
<SavedRow customerId={customerId} entry={recruitedBy} canEdit={canEdit} onDeleted={refresh} />
|
<SavedRow customerId={customerId} entry={recruitedBy} direction="recruitedBy" canEdit={canEdit} onChanged={refresh} />
|
||||||
) : addingRecruitedBy ? (
|
) : addingRecruitedBy ? (
|
||||||
<DraftRow
|
<DraftRow
|
||||||
customerId={customerId}
|
customerId={customerId}
|
||||||
@@ -303,7 +387,7 @@ export default function ReferralsTab({ customerId, customerName, canEdit }: Refe
|
|||||||
</h3>
|
</h3>
|
||||||
{recruited.length > 0 || addingRecruited ? columnHeader : null}
|
{recruited.length > 0 || addingRecruited ? columnHeader : null}
|
||||||
{recruited.map((entry) => (
|
{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 && (
|
{addingRecruited && (
|
||||||
<DraftRow
|
<DraftRow
|
||||||
|
|||||||
@@ -249,6 +249,14 @@ export const referralApi = {
|
|||||||
const res = await api.post<ApiResponse<unknown>>(`/customers/${customerId}/referrals`, payload);
|
const res = await api.post<ApiResponse<unknown>>(`/customers/${customerId}/referrals`, payload);
|
||||||
return res.data;
|
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) => {
|
remove: async (customerId: number, referralId: number) => {
|
||||||
const res = await api.delete<ApiResponse<void>>(`/customers/${customerId}/referrals/${referralId}`);
|
const res = await api.delete<ApiResponse<void>>(`/customers/${customerId}/referrals/${referralId}`);
|
||||||
return res.data;
|
return res.data;
|
||||||
|
|||||||
@@ -36,6 +36,10 @@ export const REFERRAL_RELATIONSHIPS = [
|
|||||||
'Freund/Kumpel',
|
'Freund/Kumpel',
|
||||||
'Eltern',
|
'Eltern',
|
||||||
'Bruder/Schwester',
|
'Bruder/Schwester',
|
||||||
|
'Schwiegertochter/Schwiegersohn',
|
||||||
|
'Schwiegermutter/Schwiegervater',
|
||||||
|
'Oma/Opa',
|
||||||
|
'Uroma/Uropa',
|
||||||
'Bekannte',
|
'Bekannte',
|
||||||
'sonstige',
|
'sonstige',
|
||||||
] as const;
|
] as const;
|
||||||
|
|||||||
Reference in New Issue
Block a user