From 57959cd7820d229267c19583a33f8f521c000d95 Mon Sep 17 00:00:00 2001 From: duffyduck Date: Mon, 6 Jul 2026 00:34:51 +0200 Subject: [PATCH] =?UTF-8?q?Kunde:=20pers=C3=B6nliche=20Du/Sie-Pr=C3=A4fere?= =?UTF-8?q?nz=20pro=20Mitarbeiter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../migration.sql | 18 ++++ backend/prisma/schema.prisma | 28 ++++++ .../src/controllers/customer.controller.ts | 82 +++++++++++++++++ backend/src/routes/customer.routes.ts | 7 ++ backend/src/services/customer.service.ts | 64 +++++++++++++ .../src/pages/customers/CustomerDetail.tsx | 92 ++++++++++++++++++- frontend/src/services/api.ts | 21 +++++ 7 files changed, 311 insertions(+), 1 deletion(-) create mode 100644 backend/prisma/migrations/20260704200000_user_customer_salutation/migration.sql diff --git a/backend/prisma/migrations/20260704200000_user_customer_salutation/migration.sql b/backend/prisma/migrations/20260704200000_user_customer_salutation/migration.sql new file mode 100644 index 00000000..659d52f7 --- /dev/null +++ b/backend/prisma/migrations/20260704200000_user_customer_salutation/migration.sql @@ -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; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index efc651ee..dd38756c 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -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 diff --git a/backend/src/controllers/customer.controller.ts b/backend/src/controllers/customer.controller.ts index acb71381..2ff5d47b 100644 --- a/backend/src/controllers/customer.controller.ts +++ b/backend/src/controllers/customer.controller.ts @@ -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 { + 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 { + 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 { + 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 { try { // KRITISCH (Pentest Runde 6): ohne canAccessCustomer kann ein Portal-User diff --git a/backend/src/routes/customer.routes.ts b/backend/src/routes/customer.routes.ts index 41c50de5..ad35a74c 100644 --- a/backend/src/routes/customer.routes.ts +++ b/backend/src/routes/customer.routes.ts @@ -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); diff --git a/backend/src/services/customer.service.ts b/backend/src/services/customer.service.ts index 07ebf89c..ef5b741a 100644 --- a/backend/src/services/customer.service.ts +++ b/backend/src/services/customer.service.ts @@ -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 }, + }); +} diff --git a/frontend/src/pages/customers/CustomerDetail.tsx b/frontend/src/pages/customers/CustomerDetail.tsx index f2bbb90b..788c3d28 100644 --- a/frontend/src/pages/customers/CustomerDetail.tsx +++ b/frontend/src/pages/customers/CustomerDetail.tsx @@ -277,12 +277,19 @@ export default function CustomerDetail({ portalCustomerId }: { portalCustomerId? )}
Anrede per
-
+
{c.useInformalAddress ? 'Du (informell)' : 'Sie (formell)'} + Standard für alle Mitarbeiter
+ {!isCustomerPortal && ( +
+
Anrede für mich
+
+
+ )}
Vorname
@@ -5058,3 +5065,86 @@ function AuthorizationsSection({ customerId, customerEmail }: { customerId: numb
); } + +// ============================================================================ +// SalutationPreferenceToggle – persönliche Du/Sie-Präferenz des Mitarbeiters +// ============================================================================ +// User-Wunsch 2026-07-04: Stefan darf per Du sein mit einem Kunden, ohne dass +// das für andere Mitarbeiter gilt. Wenn kein Eintrag existiert, greift der +// Kunden-Default (`useInformalAddress`) – der Toggle zeigt das als "Standard". +function SalutationPreferenceToggle({ + customerId, + customerDefaultInformal, +}: { + customerId: number; + customerDefaultInformal: boolean; +}) { + const queryClient = useQueryClient(); + const { data, isLoading } = useQuery({ + queryKey: ['salutation-preference', customerId], + queryFn: () => customerApi.getSalutationPreference(customerId), + }); + const setMutation = useMutation({ + mutationFn: (preference: 'DU' | 'SIE') => + customerApi.setSalutationPreference(customerId, preference), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['salutation-preference', customerId] }); + }, + }); + const clearMutation = useMutation({ + mutationFn: () => customerApi.clearSalutationPreference(customerId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['salutation-preference', customerId] }); + }, + }); + + const isMutating = setMutation.isPending || clearMutation.isPending; + const preference = data?.data?.preference; + const source = data?.data?.source; + const defaultLabel = customerDefaultInformal ? 'Du' : 'Sie'; + + const btnClass = (active: boolean) => + `px-3 py-1 text-sm rounded-md transition-colors ${ + active + ? 'bg-blue-100 text-blue-800 border border-blue-300' + : 'bg-gray-100 text-gray-600 border border-gray-200 hover:bg-gray-200' + } ${isMutating ? 'opacity-60 cursor-not-allowed' : ''}`; + + if (isLoading) { + return lädt…; + } + + return ( +
+ + + {source === 'user' ? ( + + ) : ( + Standard: {defaultLabel} + )} +
+ ); +} diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 379a825c..6aa98268 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -205,6 +205,27 @@ export const customerApi = { const res = await api.delete>(`/customers/${customerId}/representatives/${representativeId}`); return res.data; }, + // Persönliche Anrede-Präferenz (Du/Sie) des eingeloggten Mitarbeiters + // für einen bestimmten Kunden. Fehlender Override → Kunden-Default. + getSalutationPreference: async (customerId: number) => { + const res = await api.get>( + `/customers/${customerId}/salutation-preference`, + ); + return res.data; + }, + setSalutationPreference: async (customerId: number, preference: 'DU' | 'SIE') => { + const res = await api.put>( + `/customers/${customerId}/salutation-preference`, + { preference }, + ); + return res.data; + }, + clearSalutationPreference: async (customerId: number) => { + const res = await api.delete>( + `/customers/${customerId}/salutation-preference`, + ); + return res.data; + }, searchForRepresentative: async (customerId: number, search: string) => { const res = await api.get>(`/customers/${customerId}/representatives/search`, { params: { search } }); return res.data;