Kunde: persönliche Du/Sie-Präferenz pro Mitarbeiter

Neue Tabelle UserCustomerSalutation (PK userId+customerId,
preference 'DU'|'SIE'). Fehlender Eintrag → Fallback auf
Customer.useInformalAddress (Kunden-Default).

Backend:
- customerService: get/set/clearSalutationPreference mit Fallback-
  Logik. Response enthält immer effektive Präferenz + `source`
  ('user' | 'customer-default'), damit die UI den Standard-Text
  anzeigen kann.
- customerController: 3 Endpunkte GET/PUT/DELETE
  /:customerId/salutation-preference. userId aus dem JWT, canAccessCustomer
  greift.

Frontend:
- customerApi: 3 neue Methoden.
- CustomerDetail: neues Feld "Anrede für mich" mit Du/Sie-Toggle
  und Zurücksetzen-Link. Wird nur Mitarbeitern angezeigt, nicht
  Portal-Usern.
- Bestehendes Feld "Anrede per" bekommt Hinweis "Standard für alle
  Mitarbeiter", damit die Semantik der beiden Felder klar ist.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-07-06 00:34:51 +02:00
parent d14db3de2f
commit 57959cd782
7 changed files with 311 additions and 1 deletions
@@ -0,0 +1,18 @@
-- Per-User-Präferenz "Du"/"Sie" pro Kunde. Fehlender Eintrag fällt auf
-- den Kunden-Default `Customer.useInformalAddress` zurück. Composite-PK
-- (userId, customerId) verhindert doppelte Präferenzen pro Paar; Index
-- auf customerId hilft dem Cleanup, wenn der Kunde gelöscht wird.
CREATE TABLE IF NOT EXISTS `UserCustomerSalutation` (
`userId` INT NOT NULL,
`customerId` INT NOT NULL,
`preference` VARCHAR(191) NOT NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
PRIMARY KEY (`userId`, `customerId`),
INDEX `UserCustomerSalutation_customerId_idx` (`customerId`),
CONSTRAINT `UserCustomerSalutation_userId_fkey`
FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT `UserCustomerSalutation_customerId_fkey`
FOREIGN KEY (`customerId`) REFERENCES `Customer`(`id`) ON DELETE CASCADE ON UPDATE CASCADE
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
+28
View File
@@ -90,6 +90,9 @@ model User {
customerId Int? @unique
customer Customer? @relation(fields: [customerId], references: [id])
roles UserRole[]
// Persönliche Anrede-Präferenz pro Kunde ("Du"/"Sie") User-Wunsch
// 2026-07-04. Fehlender Eintrag = Kunden-Default (Customer.useInformalAddress).
customerSalutations UserCustomerSalutation[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
@@ -207,10 +210,35 @@ model Customer {
// DSGVO: Einwilligungen
consents CustomerConsent[]
// Persönliche Anrede-Präferenzen aller Mitarbeiter zu diesem Kunden.
userSalutations UserCustomerSalutation[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
// ==================== USER-CUSTOMER SALUTATION ====================
// Per-User-Präferenz, ob der eingeloggte Mitarbeiter mit dem jeweiligen
// Kunden per Du oder Sie ist. Fehlender Eintrag → fällt auf den
// Kunden-Default (Customer.useInformalAddress) zurück.
// User-Wunsch 2026-07-04: Stefan kann per Du mit einem Kunden sein,
// während andere Mitarbeiter denselben Kunden weiter siezen.
model UserCustomerSalutation {
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
customerId Int
customer Customer @relation(fields: [customerId], references: [id], onDelete: Cascade)
// 'DU' oder 'SIE'. String statt Enum, damit man ohne Migration einen
// dritten Wert nachschieben könnte (z.B. 'MIXED' o.ä.).
preference String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@id([userId, customerId])
@@index([customerId])
}
// ==================== CUSTOMER REPRESENTATIVES ====================
// Vertretungsbeziehung: Ein Kunde kann die Verträge eines anderen Kunden einsehen
// z.B. Sohn (representativeId) kann Verträge der Mutter (customerId) sehen
@@ -1274,6 +1274,88 @@ export async function removeRepresentative(req: AuthRequest, res: Response): Pro
}
}
// ==================== SALUTATION PREFERENCE ====================
// Per-User "Du"/"Sie" pro Kunde User-Wunsch 2026-07-04.
export async function getSalutationPreference(req: AuthRequest, res: Response): Promise<void> {
try {
const customerId = parseInt(req.params.customerId);
if (!Number.isFinite(customerId) || customerId < 1) {
res.status(400).json({ success: false, error: 'Ungültige Kunden-ID' } as ApiResponse);
return;
}
if (!(await canAccessCustomer(req, res, customerId))) return;
const userId = req.user?.userId;
if (!userId) {
res.status(401).json({ success: false, error: 'Nicht authentifiziert' } as ApiResponse);
return;
}
const result = await customerService.getSalutationPreference(userId, customerId);
res.json({ success: true, data: result } as ApiResponse);
} catch (error) {
res.status(500).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Laden der Anrede-Präferenz',
} as ApiResponse);
}
}
export async function setSalutationPreference(req: AuthRequest, res: Response): Promise<void> {
try {
const customerId = parseInt(req.params.customerId);
if (!Number.isFinite(customerId) || customerId < 1) {
res.status(400).json({ success: false, error: 'Ungültige Kunden-ID' } as ApiResponse);
return;
}
if (!(await canAccessCustomer(req, res, customerId))) return;
const userId = req.user?.userId;
if (!userId) {
res.status(401).json({ success: false, error: 'Nicht authentifiziert' } as ApiResponse);
return;
}
const preference = req.body?.preference;
if (preference !== 'DU' && preference !== 'SIE') {
res.status(400).json({
success: false,
error: 'preference muss "DU" oder "SIE" sein',
} as ApiResponse);
return;
}
await customerService.setSalutationPreference(userId, customerId, preference);
const result = await customerService.getSalutationPreference(userId, customerId);
res.json({ success: true, data: result } as ApiResponse);
} catch (error) {
res.status(500).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Speichern der Anrede-Präferenz',
} as ApiResponse);
}
}
export async function clearSalutationPreference(req: AuthRequest, res: Response): Promise<void> {
try {
const customerId = parseInt(req.params.customerId);
if (!Number.isFinite(customerId) || customerId < 1) {
res.status(400).json({ success: false, error: 'Ungültige Kunden-ID' } as ApiResponse);
return;
}
if (!(await canAccessCustomer(req, res, customerId))) return;
const userId = req.user?.userId;
if (!userId) {
res.status(401).json({ success: false, error: 'Nicht authentifiziert' } as ApiResponse);
return;
}
await customerService.clearSalutationPreference(userId, customerId);
const result = await customerService.getSalutationPreference(userId, customerId);
res.json({ success: true, data: result } as ApiResponse);
} catch (error) {
res.status(500).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Zurücksetzen der Anrede-Präferenz',
} as ApiResponse);
}
}
export async function searchForRepresentative(req: AuthRequest, res: Response): Promise<void> {
try {
// KRITISCH (Pentest Runde 6): ohne canAccessCustomer kann ein Portal-User
+7
View File
@@ -40,6 +40,13 @@ router.get('/:customerId/portal/password', authenticate, requirePermission('cust
router.post('/:customerId/portal/password/generate', authenticate, requirePermission('customers:update'), customerController.generatePortalPassword);
router.post('/:customerId/portal/send-credentials', authenticate, requirePermission('customers:update'), customerController.sendPortalCredentials);
// Salutation-Präferenz pro User (Du/Sie) 2026-07-04. Nutzt
// requireCustomerAccess damit auch Portal-User (falls je nötig) nicht
// automatisch scheitern; der Endpoint speichert userId aus dem Token.
router.get('/:customerId/salutation-preference', authenticate, requireCustomerAccess, customerController.getSalutationPreference);
router.put('/:customerId/salutation-preference', authenticate, requireCustomerAccess, customerController.setSalutationPreference);
router.delete('/:customerId/salutation-preference', authenticate, requireCustomerAccess, customerController.clearSalutationPreference);
// Representatives (Vertreter)
router.get('/:customerId/representatives', authenticate, requirePermission('customers:read'), customerController.getRepresentatives);
router.post('/:customerId/representatives', authenticate, requirePermission('customers:update'), customerController.addRepresentative);
+64
View File
@@ -1017,3 +1017,67 @@ export async function searchCustomersForRepresentative(search: string, excludeCu
take: 10,
});
}
// ==================== SALUTATION PREFERENCE ====================
// Per-User "Du"/"Sie" pro Kunde. Fehlender Eintrag ⇒ Kunden-Default
// (Customer.useInformalAddress). Nur Mitarbeiter-User, keine Portal-User.
export type SalutationPreference = 'DU' | 'SIE';
function isValidPreference(v: unknown): v is SalutationPreference {
return v === 'DU' || v === 'SIE';
}
/**
* Liefert die effektive Anrede für einen User/Kunde: eigene Präferenz
* bevorzugt, sonst Kunden-Default. `source` sagt der UI, ob's ein eigener
* Override ist oder der Fallback greift.
*/
export async function getSalutationPreference(
userId: number,
customerId: number,
): Promise<{
preference: SalutationPreference;
source: 'user' | 'customer-default';
}> {
const own = await prisma.userCustomerSalutation.findUnique({
where: { userId_customerId: { userId, customerId } },
});
if (own && isValidPreference(own.preference)) {
return { preference: own.preference, source: 'user' };
}
const customer = await prisma.customer.findUnique({
where: { id: customerId },
select: { useInformalAddress: true },
});
if (!customer) {
throw new Error('Kunde nicht gefunden');
}
return {
preference: customer.useInformalAddress ? 'DU' : 'SIE',
source: 'customer-default',
};
}
export async function setSalutationPreference(
userId: number,
customerId: number,
preference: SalutationPreference,
) {
return prisma.userCustomerSalutation.upsert({
where: { userId_customerId: { userId, customerId } },
create: { userId, customerId, preference },
update: { preference },
});
}
/**
* Löscht den eigenen Override, sodass die UI wieder auf den Kunden-
* Default zurückfällt. Kein 404, wenn kein Eintrag da war das ist
* idempotent gedacht ("stelle sicher, dass kein Override existiert").
*/
export async function clearSalutationPreference(userId: number, customerId: number) {
await prisma.userCustomerSalutation.deleteMany({
where: { userId, customerId },
});
}