opencrm/backend/src/services/birthday.service.ts

338 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;
lastName: string;
salutation: string | null;
useInformalAddress: boolean;
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,
lastName: true,
salutation: true,
useInformalAddress: true,
birthDate: true,
lastBirthdayGreetingYear: true,
},
});
if (!customer?.birthDate) return null;
const baseInfo = {
firstName: customer.firstName,
lastName: customer.lastName,
salutation: customer.salutation,
useInformalAddress: customer.useInformalAddress,
};
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, ...baseInfo, 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, ...baseInfo, age: 0 };
}
const age = calculateAge(customer.birthDate, today);
return {
show: true,
isToday: diff === 0,
daysAgo: diff,
...baseInfo,
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 },
});
}
/**
* Setzt den Gruß-Marker zurück, damit das Modal beim nächsten Login wieder erscheint.
* (Für Mitarbeiter nützlich zum Debuggen und als Fallback wenn etwas schief ging.)
*/
export async function resetBirthdayGreeting(customerId: number): Promise<void> {
await prisma.customer.update({
where: { id: customerId },
data: { lastBirthdayGreetingYear: null },
});
}
/**
* Generiert den persönlichen Geburtstagsgruß-Text (Du/Sie-abhängig).
*/
export function buildBirthdayGreetingText(
customer: {
firstName: string;
lastName: string;
salutation: string | null;
useInformalAddress: boolean;
},
age: number,
): { subject: string; plain: string; html: string } {
const name = customer.useInformalAddress
? customer.firstName
: [customer.salutation, customer.lastName].filter(Boolean).join(' ') || customer.firstName;
const du = customer.useInformalAddress;
const pronoun = du ? 'dir' : 'Ihnen';
const possessive = du ? 'deinem' : 'Ihrem';
const yourLower = du ? 'dein' : 'Ihr';
const subject = du
? `Alles Gute zum Geburtstag, ${customer.firstName}! 🎉`
: 'Herzlichen Glückwunsch zum Geburtstag 🎉';
// Plain-Text ohne Emojis, damit WhatsApp/Telegram/Signal-URL-Handler nicht stolpern
const plain = [
`Herzlichen Glückwunsch, ${name}!`,
'',
age > 0
? `Alles Gute zu ${possessive} ${age}. Geburtstag!`
: `Alles Gute zu ${possessive} Geburtstag!`,
'',
`Wir wünschen ${pronoun} einen wunderschönen Tag und alles Gute für ${yourLower} neues Lebensjahr.`,
'',
'Herzliche Grüße',
'Hacker-Net Telekommunikation',
].join('\n');
const html = `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<div style="background: linear-gradient(135deg, #ec4899 0%, #8b5cf6 50%, #6366f1 100%); padding: 40px 20px; text-align: center; border-radius: 12px 12px 0 0;">
<div style="font-size: 64px; margin-bottom: 8px;">🎉🎂🎈</div>
</div>
<div style="padding: 32px; background: #ffffff; border: 1px solid #e5e7eb; border-top: none; border-radius: 0 0 12px 12px;">
<h2 style="color: #1f2937; margin-top: 0;">Herzlichen Glückwunsch, ${name}!</h2>
<p style="color: #4b5563; font-size: 16px; line-height: 1.6;">
${age > 0 ? `Alles Gute zu ${possessive} <strong>${age}. Geburtstag</strong>!` : `Alles Gute zu ${possessive} Geburtstag!`}
</p>
<p style="color: #6b7280; font-size: 14px; line-height: 1.6;">
Wir wünschen ${pronoun} einen wunderschönen Tag und alles Gute für ${yourLower} neues Lebensjahr. 🌟
</p>
<hr style="border: none; border-top: 1px solid #e5e7eb; margin: 24px 0;">
<p style="color: #9ca3af; font-size: 12px; margin: 0;">
Herzliche Grüße<br>
<strong>Hacker-Net Telekommunikation</strong><br>
Am Wunderburgpark 5b, 26135 Oldenburg<br>
info@hacker-net.de
</p>
</div>
</div>
`;
return { subject, plain, html };
}
/**
* Lädt die für den Gruß benötigten Kundendaten inkl. aktuellem Alter heute.
*/
export async function getBirthdayGreetingData(customerId: number): Promise<{
firstName: string;
lastName: string;
salutation: string | null;
useInformalAddress: boolean;
email: string | null;
phone: string | null;
mobile: string | null;
age: number;
} | null> {
const c = await prisma.customer.findUnique({
where: { id: customerId },
select: {
firstName: true,
lastName: true,
salutation: true,
useInformalAddress: true,
email: true,
phone: true,
mobile: true,
birthDate: true,
},
});
if (!c?.birthDate) return null;
const today = new Date();
today.setHours(0, 0, 0, 0);
const age = calculateAge(c.birthDate, today);
return {
firstName: c.firstName,
lastName: c.lastName,
salutation: c.salutation,
useInformalAddress: c.useInformalAddress,
email: c.email,
phone: c.phone,
mobile: c.mobile,
age,
};
}