338 lines
10 KiB
TypeScript
338 lines
10 KiB
TypeScript
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: 0–7)
|
||
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,
|
||
};
|
||
}
|