Geburtstagskalender + Geburtstagsgruß-Modal im Kundenportal

Admin (Vertrags-Cockpit):
- Neue Section "Geburtstage" zeigt Kunden mit Geburtstag
- Fenster: -7 bis +30 Tage um heute
- Farbcodierung: heute (pink), vergangen (amber), bevorstehend (grau)
- Anzeige: Name, Kundennummer, Geburtsdatum, Alter, "Heute!" / "In X Tagen" / "Vor X Tagen"

Portal (Kundenportal):
- Modal mit Geburtstagsgruß wenn Geburtstag heute oder in den letzten 7 Tagen war
- Unterscheidet zwischen aktuellem Geburtstag und nachträglichen Glückwünschen
- Schönes Gradient-Design mit Konfetti-Emojis
- Wird pro Jahr nur einmal angezeigt (Customer.lastBirthdayGreetingYear)
- Bestätigung speichert das aktuelle Jahr

Backend:
- Neues Feld Customer.lastBirthdayGreetingYear (Int?)
- Service birthday.service.ts mit Fenster-Logik + Alter-Berechnung
- Endpoints /api/birthdays/upcoming (Admin),
  /api/birthdays/my-birthday (Portal GET + POST /acknowledge)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-23 11:51:20 +02:00
parent a47dfcd841
commit 9e55e25dc8
9 changed files with 467 additions and 1 deletions
+3
View File
@@ -163,6 +163,9 @@ model Customer {
portalPasswordEncrypted String? // Verschlüsseltes Passwort (für Anzeige)
portalLastLogin DateTime? // Letzte Anmeldung
// Geburtstagsmodal: Jahr in dem dem Kunden der Geburtstagsgruß gezeigt wurde (vermeidet mehrfaches Anzeigen)
lastBirthdayGreetingYear Int?
user User?
addresses Address[]
bankCards BankCard[]
@@ -0,0 +1,56 @@
import { Response } from 'express';
import { AuthRequest } from '../types/index.js';
import * as birthdayService from '../services/birthday.service.js';
/**
* Admin/Mitarbeiter: Kommende und vergangene Geburtstage
* Query: ?past=7&future=30 (Default)
*/
export async function getUpcomingBirthdays(req: AuthRequest, res: Response) {
try {
const past = req.query.past ? parseInt(String(req.query.past)) : 7;
const future = req.query.future ? parseInt(String(req.query.future)) : 30;
const entries = await birthdayService.getUpcomingBirthdays(past, future);
res.json({ success: true, data: entries });
} catch (error) {
console.error('Fehler beim Abrufen der Geburtstage:', error);
res.status(500).json({ success: false, error: 'Fehler beim Abrufen der Geburtstage' });
}
}
/**
* Portal: Eigenen Geburtstags-Check soll das Modal angezeigt werden?
*/
export async function getMyBirthday(req: AuthRequest, res: Response) {
try {
const user = req.user as any;
if (!user?.isCustomerPortal || !user?.customerId) {
return res.status(403).json({ success: false, error: 'Nur für Kundenportal-Benutzer' });
}
const data = await birthdayService.checkMyBirthday(user.customerId);
res.json({ success: true, data });
} catch (error) {
console.error('Fehler beim Geburtstags-Check:', error);
res.status(500).json({ success: false, error: 'Fehler beim Abruf' });
}
}
/**
* Portal: Modal als gesehen markieren
*/
export async function acknowledgeMyBirthday(req: AuthRequest, res: Response) {
try {
const user = req.user as any;
if (!user?.isCustomerPortal || !user?.customerId) {
return res.status(403).json({ success: false, error: 'Nur für Kundenportal-Benutzer' });
}
await birthdayService.acknowledgeBirthdayGreeting(user.customerId);
res.json({ success: true });
} catch (error) {
console.error('Fehler beim Bestätigen:', error);
res.status(500).json({ success: false, error: 'Fehler beim Speichern' });
}
}
+2
View File
@@ -31,6 +31,7 @@ import gdprRoutes from './routes/gdpr.routes.js';
import consentPublicRoutes from './routes/consent-public.routes.js';
import emailLogRoutes from './routes/emailLog.routes.js';
import pdfTemplateRoutes from './routes/pdfTemplate.routes.js';
import birthdayRoutes from './routes/birthday.routes.js';
import { auditContextMiddleware } from './middleware/auditContext.js';
import { auditMiddleware } from './middleware/audit.js';
@@ -81,6 +82,7 @@ app.use('/api/audit-logs', auditLogRoutes);
app.use('/api/gdpr', gdprRoutes);
app.use('/api/email-logs', emailLogRoutes);
app.use('/api/pdf-templates', pdfTemplateRoutes);
app.use('/api/birthdays', birthdayRoutes);
// Health check
app.get('/api/health', (req, res) => {
+14
View File
@@ -0,0 +1,14 @@
import { Router } from 'express';
import * as birthdayController from '../controllers/birthday.controller.js';
import { authenticate, requirePermission } from '../middleware/auth.js';
const router = Router();
// Admin: Kommende und vergangene Geburtstage
router.get('/upcoming', authenticate, requirePermission('customers:read'), birthdayController.getUpcomingBirthdays);
// Portal: eigener Geburtstag-Check
router.get('/my-birthday', authenticate, birthdayController.getMyBirthday);
router.post('/my-birthday/acknowledge', authenticate, birthdayController.acknowledgeMyBirthday);
export default router;
+203
View File
@@ -0,0 +1,203 @@
import prisma from '../lib/prisma.js';
export interface BirthdayEntry {
customerId: number;
customerNumber: string;
name: string;
birthDate: Date;
age: number; // Alter das der Kunde wird bzw. geworden ist
daysUntil: number; // 0 = heute, -3 = vor 3 Tagen, +5 = in 5 Tagen
isToday: boolean;
isPast: boolean; // Geburtstag war in den letzten Tagen
portalEnabled: boolean;
email?: string | null;
phone?: string | null;
}
/**
* Berechnet die Anzahl Tage von "heute" bis zum nächsten Vorkommen eines Geburtstags.
* Negative Werte = Geburtstag war kürzlich, positive = Geburtstag steht bevor.
*
* Für den Zeitraum `[-pastDays, +futureDays]` suchen wir den relevantesten Wert:
* - ist der Geburtstag dieses Jahr innerhalb des Fensters → dieser
* - sonst: nichts (nicht relevant)
*/
function daysDifferenceInWindow(
birthDate: Date,
today: Date,
pastDays: number,
futureDays: number,
): number | null {
const month = birthDate.getMonth();
const day = birthDate.getDate();
// Geburtstag dieses Jahr
const thisYear = new Date(today.getFullYear(), month, day);
thisYear.setHours(0, 0, 0, 0);
const todayNormalized = new Date(today);
todayNormalized.setHours(0, 0, 0, 0);
const msPerDay = 1000 * 60 * 60 * 24;
const diffThisYear = Math.round((thisYear.getTime() - todayNormalized.getTime()) / msPerDay);
// Falls im Fenster → nutzen
if (diffThisYear >= -pastDays && diffThisYear <= futureDays) {
return diffThisYear;
}
// Falls Geburtstag schon lange vorbei (>pastDays her), ist nächstes Jahr relevant
if (diffThisYear < -pastDays) {
const nextYear = new Date(today.getFullYear() + 1, month, day);
nextYear.setHours(0, 0, 0, 0);
const diffNextYear = Math.round((nextYear.getTime() - todayNormalized.getTime()) / msPerDay);
if (diffNextYear <= futureDays) {
return diffNextYear;
}
}
return null;
}
function calculateAge(birthDate: Date, refDate: Date): number {
let age = refDate.getFullYear() - birthDate.getFullYear();
const m = refDate.getMonth() - birthDate.getMonth();
if (m < 0 || (m === 0 && refDate.getDate() < birthDate.getDate())) {
age--;
}
return age;
}
/**
* Kommende und vergangene Geburtstage in einem Fenster von [pastDays, futureDays].
* Default: 7 Tage zurück, 30 Tage voraus.
*/
export async function getUpcomingBirthdays(
pastDays: number = 7,
futureDays: number = 30,
): Promise<BirthdayEntry[]> {
const customers = await prisma.customer.findMany({
where: {
birthDate: { not: null },
},
select: {
id: true,
customerNumber: true,
firstName: true,
lastName: true,
companyName: true,
birthDate: true,
portalEnabled: true,
email: true,
phone: true,
},
});
const today = new Date();
today.setHours(0, 0, 0, 0);
const entries: BirthdayEntry[] = [];
for (const c of customers) {
if (!c.birthDate) continue;
const daysUntil = daysDifferenceInWindow(c.birthDate, today, pastDays, futureDays);
if (daysUntil === null) continue;
// Alter am nächsten Geburtstag (bei daysUntil >= 0) bzw. aktuelles Alter (daysUntil < 0)
const refDate = new Date(today);
refDate.setDate(refDate.getDate() + daysUntil);
const age = calculateAge(c.birthDate, refDate);
entries.push({
customerId: c.id,
customerNumber: c.customerNumber,
name: c.companyName || `${c.firstName} ${c.lastName}`.trim(),
birthDate: c.birthDate,
age,
daysUntil,
isToday: daysUntil === 0,
isPast: daysUntil < 0,
portalEnabled: c.portalEnabled,
email: c.email,
phone: c.phone,
});
}
// Sortiert: zuerst heute, dann kommende aufsteigend, dann vergangene absteigend
entries.sort((a, b) => {
if (a.daysUntil === 0 && b.daysUntil !== 0) return -1;
if (b.daysUntil === 0 && a.daysUntil !== 0) return 1;
if (a.daysUntil >= 0 && b.daysUntil >= 0) return a.daysUntil - b.daysUntil;
if (a.daysUntil < 0 && b.daysUntil < 0) return b.daysUntil - a.daysUntil;
// kommende vor vergangenen
return a.daysUntil >= 0 ? -1 : 1;
});
return entries;
}
export interface MyBirthdayCheck {
show: boolean; // Modal anzeigen?
isToday: boolean;
daysAgo: number; // 0 = heute, >0 = x Tage her
firstName: string;
age: number;
}
/**
* Portal-Check: Soll dem Kunden ein Geburtstagsmodal angezeigt werden?
* - Heute oder in den letzten 7 Tagen
* - Modal dieses Jahr noch nicht gezeigt (lastBirthdayGreetingYear != aktuelles Jahr)
*/
export async function checkMyBirthday(customerId: number): Promise<MyBirthdayCheck | null> {
const customer = await prisma.customer.findUnique({
where: { id: customerId },
select: {
firstName: true,
birthDate: true,
lastBirthdayGreetingYear: true,
},
});
if (!customer?.birthDate) return null;
const today = new Date();
today.setHours(0, 0, 0, 0);
const thisYear = today.getFullYear();
// Schon dieses Jahr angezeigt?
if (customer.lastBirthdayGreetingYear === thisYear) {
return { show: false, isToday: false, daysAgo: 0, firstName: customer.firstName, age: 0 };
}
const birthday = new Date(thisYear, customer.birthDate.getMonth(), customer.birthDate.getDate());
birthday.setHours(0, 0, 0, 0);
const msPerDay = 1000 * 60 * 60 * 24;
const diff = Math.round((today.getTime() - birthday.getTime()) / msPerDay);
// Nur wenn heute oder in den letzten 7 Tagen (diff: 07)
if (diff < 0 || diff > 7) {
return { show: false, isToday: false, daysAgo: 0, firstName: customer.firstName, age: 0 };
}
const age = calculateAge(customer.birthDate, today);
return {
show: true,
isToday: diff === 0,
daysAgo: diff,
firstName: customer.firstName,
age,
};
}
/**
* Markiert, dass dem Kunden das Geburtstagsmodal dieses Jahr bereits gezeigt wurde.
*/
export async function acknowledgeBirthdayGreeting(customerId: number): Promise<void> {
const thisYear = new Date().getFullYear();
await prisma.customer.update({
where: { id: customerId },
data: { lastBirthdayGreetingYear: thisYear },
});
}