Stressfrei-Adressen: Duplikate beim Anlegen ablehnen

Bug: dieselbe E-Mail-Adresse konnte beim selben Kunden mehrfach
angelegt werden – im Screenshot zwei identische Einträge nach
einem Doppel-Submit.

- createEmail: findFirst auf (customerId, email) case-insensitive,
  bei Treffer ApiError(409). Eigene Meldung für inaktive
  Duplikate (Hinweis: alten Eintrag reaktivieren statt neu anlegen).
- updateEmail: gleicher Check beim Umbenennen, NOT id-Exclude.
- Controller: catch-Blöcke honorieren ApiError.statusCode (vorher
  pauschal 400) → 409 kommt sauber an die UI durch.
- Frontend: updateMutation bekam onError, damit der Fehler nicht
  schlucken bleibt.
This commit is contained in:
2026-06-18 14:01:35 +02:00
parent 246999be01
commit 8992bb7a5d
4 changed files with 70 additions and 2 deletions
@@ -84,7 +84,8 @@ export async function createEmail(req: Request, res: Response): Promise<void> {
}); });
res.status(201).json({ success: true, data: email } as ApiResponse); res.status(201).json({ success: true, data: email } as ApiResponse);
} catch (error) { } catch (error) {
res.status(400).json({ const status = error instanceof ApiError ? error.statusCode : 400;
res.status(status).json({
success: false, success: false,
error: error instanceof Error ? error.message : 'Fehler beim Erstellen der Stressfrei-Wechseln Adresse', error: error instanceof Error ? error.message : 'Fehler beim Erstellen der Stressfrei-Wechseln Adresse',
} as ApiResponse); } as ApiResponse);
@@ -104,7 +105,8 @@ export async function updateEmail(req: AuthRequest, res: Response): Promise<void
}); });
res.json({ success: true, data: email } as ApiResponse); res.json({ success: true, data: email } as ApiResponse);
} catch (error) { } catch (error) {
res.status(400).json({ const status = error instanceof ApiError ? error.statusCode : 400;
res.status(status).json({
success: false, success: false,
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren der Stressfrei-Wechseln Adresse', error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren der Stressfrei-Wechseln Adresse',
} as ApiResponse); } as ApiResponse);
@@ -152,6 +152,27 @@ export interface CreateEmailData {
export async function createEmail(data: CreateEmailData) { export async function createEmail(data: CreateEmailData) {
const { provisionAtProvider, createMailbox, ...emailData } = data; const { provisionAtProvider, createMailbox, ...emailData } = data;
// Duplikatscheck: pro Kunde darf eine Stressfrei-Adresse nur einmal
// existieren. Case-insensitive, weil RFC-localpart-Großschreibung in
// der Praxis nie semantischen Unterschied macht und der Provider eh
// einheitlich lowercased.
const normalized = data.email.trim().toLowerCase();
const conflict = await prisma.stressfreiEmail.findFirst({
where: {
customerId: data.customerId,
email: { equals: normalized },
},
select: { id: true, isActive: true },
});
if (conflict) {
const hint = conflict.isActive
? 'Diese E-Mail-Adresse ist bei diesem Kunden bereits angelegt.'
: 'Diese E-Mail-Adresse existiert bei diesem Kunden bereits (deaktiviert). Bitte reaktiviere den vorhandenen Eintrag, statt einen neuen anzulegen.';
throw new ApiError(409, hint);
}
// Wert in DB ist eh schon lowercase wir setzen es einheitlich.
emailData.email = normalized;
// Falls beim Provider anlegen gewünscht // Falls beim Provider anlegen gewünscht
if (provisionAtProvider) { if (provisionAtProvider) {
// Kunde laden für Weiterleitung // Kunde laden für Weiterleitung
@@ -222,6 +243,34 @@ export async function updateEmail(
isActive?: boolean; isActive?: boolean;
} }
) { ) {
// Beim Umbenennen der E-Mail-Adresse erneut Kollisionscheck. Sonst
// kann man eine zweite Adresse mit identischer Mail beim selben Kunden
// anlegen (Umweg um den Create-Check).
if (typeof data.email === 'string' && data.email.trim() !== '') {
const normalized = data.email.trim().toLowerCase();
const current = await prisma.stressfreiEmail.findUnique({
where: { id },
select: { customerId: true, email: true },
});
if (!current) {
throw new ApiError(404, 'StressfreiEmail nicht gefunden');
}
if (normalized !== current.email.toLowerCase()) {
const conflict = await prisma.stressfreiEmail.findFirst({
where: {
customerId: current.customerId,
email: { equals: normalized },
NOT: { id },
},
select: { id: true },
});
if (conflict) {
throw new ApiError(409, 'Diese E-Mail-Adresse ist bei diesem Kunden bereits angelegt.');
}
}
data.email = normalized;
}
return prisma.stressfreiEmail.update({ return prisma.stressfreiEmail.update({
where: { id }, where: { id },
data, data,
+14
View File
@@ -97,6 +97,20 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung
## ✅ Erledigt ## ✅ Erledigt
- [x] **🐞 Stressfrei-Adressen: doppelte E-Mails beim Anlegen erlaubt**
- Bug: User konnte dieselbe Adresse zweimal beim selben Kunden
anlegen (siehe Screenshot mit 2× `max.mustermann@...`). `createEmail`
hatte keinen Duplikatscheck, `updateEmail` ebenfalls nicht.
- Service: Vor `prisma.create` jetzt `findFirst` auf
`(customerId, email)` (case-insensitive). Bei Treffer → `ApiError(409)`.
Unterschiedliche Meldung für aktive vs. inaktive Duplikate
(Hinweis bei inaktiv: alten Eintrag reaktivieren statt neu anlegen).
- `updateEmail`: gleicher Check beim Umbenennen, mit `NOT id`-Exclude.
- Controller: `catch`-Blöcke honorieren jetzt den `ApiError.statusCode`
(vorher pauschal 400) → 409 kommt sauber durch.
- Frontend: `updateMutation` bekam ein `onError`, damit der 409 nicht
nur ins Leere lief.
- [x] **🔒 Pentest 71.171.4: Härtung der Zusatz-Weiterleitungen** - [x] **🔒 Pentest 71.171.4: Härtung der Zusatz-Weiterleitungen**
- **71.1 MEDIUM:** Reservierte/private TLDs (`local`, `internal`, - **71.1 MEDIUM:** Reservierte/private TLDs (`local`, `internal`,
`corp`, `lan`, `home`, `private`, `invalid`, `test`, `localhost`, `corp`, `lan`, `home`, `private`, `invalid`, `test`, `localhost`,
@@ -3960,6 +3960,9 @@ function StressfreiEmailModal({
queryClient.invalidateQueries({ queryKey: ['customer', customerId] }); queryClient.invalidateQueries({ queryKey: ['customer', customerId] });
onClose(); onClose();
}, },
onError: (err) => {
setProvisionError(err instanceof Error ? err.message : 'Fehler beim Speichern');
},
}); });
const handleSubmit = (e: React.FormEvent) => { const handleSubmit = (e: React.FormEvent) => {