feat: CalDAV-Integration für Admin-Kalender
- Neue CalDAV-Route mit PROPFIND und GET-Endpoints - ICS-Format-Generator für Buchungsdaten - Token-basierte Authentifizierung für CalDAV-Zugriff - Admin-Interface mit CalDAV-Link-Generator - Schritt-für-Schritt-Anleitung für Kalender-Apps - 24h-Token-Ablaufzeit für Sicherheit - Unterstützung für Outlook, Google Calendar, Apple Calendar, Thunderbird Fixes: Admin kann jetzt Terminkalender in externen Apps abonnieren
This commit is contained in:
13
server-dist/lib/auth.js
Normal file
13
server-dist/lib/auth.js
Normal file
@@ -0,0 +1,13 @@
|
||||
import { createKV } from "./create-kv.js";
|
||||
export const sessionsKV = createKV("sessions");
|
||||
export const usersKV = createKV("users");
|
||||
export async function assertOwner(sessionId) {
|
||||
const session = await sessionsKV.getItem(sessionId);
|
||||
if (!session)
|
||||
throw new Error("Invalid session");
|
||||
if (new Date(session.expiresAt) < new Date())
|
||||
throw new Error("Session expired");
|
||||
const user = await usersKV.getItem(session.userId);
|
||||
if (!user || user.role !== "owner")
|
||||
throw new Error("Forbidden");
|
||||
}
|
33
server-dist/lib/create-kv.js
Normal file
33
server-dist/lib/create-kv.js
Normal file
@@ -0,0 +1,33 @@
|
||||
import { createStorage } from "unstorage";
|
||||
import fsDriver from "unstorage/drivers/fs";
|
||||
const STORAGE_PATH = "./.storage"; // It is .gitignored
|
||||
export function createKV(name) {
|
||||
const storage = createStorage({
|
||||
driver: fsDriver({ base: `${STORAGE_PATH}/${name}` }),
|
||||
});
|
||||
// Async generator to play work well with oRPC live queries
|
||||
async function* subscribe() {
|
||||
let resolve;
|
||||
let promise = new Promise((r) => (resolve = r));
|
||||
const unwatch = await storage.watch((event, key) => {
|
||||
resolve({ event, key });
|
||||
promise = new Promise((r) => (resolve = r));
|
||||
});
|
||||
try {
|
||||
while (true)
|
||||
yield await promise;
|
||||
}
|
||||
finally {
|
||||
await unwatch();
|
||||
}
|
||||
}
|
||||
return {
|
||||
...storage,
|
||||
getAllItems: async () => {
|
||||
const keys = await storage.getKeys();
|
||||
const values = await storage.getItems(keys);
|
||||
return values.map(({ value }) => value);
|
||||
},
|
||||
subscribe,
|
||||
};
|
||||
}
|
258
server-dist/lib/email-templates.js
Normal file
258
server-dist/lib/email-templates.js
Normal file
@@ -0,0 +1,258 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, resolve } from "node:path";
|
||||
// Helper function to convert date from yyyy-mm-dd to dd.mm.yyyy
|
||||
function formatDateGerman(dateString) {
|
||||
const [year, month, day] = dateString.split('-');
|
||||
return `${day}.${month}.${year}`;
|
||||
}
|
||||
let cachedLogoDataUrl = null;
|
||||
async function getLogoDataUrl() {
|
||||
if (cachedLogoDataUrl)
|
||||
return cachedLogoDataUrl;
|
||||
try {
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
const logoPath = resolve(__dirname, "../../../assets/stargilnails_logo_transparent.png");
|
||||
const buf = await readFile(logoPath);
|
||||
const base64 = buf.toString("base64");
|
||||
cachedLogoDataUrl = `data:image/png;base64,${base64}`;
|
||||
return cachedLogoDataUrl;
|
||||
}
|
||||
catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
async function renderBrandedEmail(title, bodyHtml) {
|
||||
const logo = await getLogoDataUrl();
|
||||
const domain = process.env.DOMAIN || 'localhost:5173';
|
||||
const protocol = domain.includes('localhost') ? 'http' : 'https';
|
||||
const homepageUrl = `${protocol}://${domain}`;
|
||||
return `
|
||||
<div style="font-family: Arial, sans-serif; color: #0f172a; background:#fdf2f8; padding:24px;">
|
||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="max-width:640px; margin:0 auto; background:#ffffff; border-radius:12px; overflow:hidden; box-shadow:0 1px 3px rgba(0,0,0,0.06)">
|
||||
<tr>
|
||||
<td style="padding:24px 24px 0 24px; text-align:center;">
|
||||
${logo ? `<img src="${logo}" alt="Stargirlnails" style="width:120px; height:auto; display:inline-block;" />` : `<div style=\"font-size:24px\">💅</div>`}
|
||||
<h1 style="margin:16px 0 0 0; font-size:22px; color:#db2777;">${title}</h1>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:16px 24px 24px 24px;">
|
||||
<div style="font-size:16px; line-height:1.6; color:#334155;">
|
||||
${bodyHtml}
|
||||
</div>
|
||||
<hr style="border:none; border-top:1px solid #f1f5f9; margin:24px 0" />
|
||||
<div style="text-align:center; margin-bottom:16px;">
|
||||
<a href="${homepageUrl}" style="display: inline-block; background-color: #db2777; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: 600; font-size: 14px;">Zur Website</a>
|
||||
</div>
|
||||
<div style="font-size:12px; color:#64748b; text-align:center;">
|
||||
© ${new Date().getFullYear()} Stargirlnails Kiel • Professional Nail Care
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>`;
|
||||
}
|
||||
export async function renderBookingPendingHTML(params) {
|
||||
const { name, date, time, statusUrl } = params;
|
||||
const formattedDate = formatDateGerman(date);
|
||||
const domain = process.env.DOMAIN || 'localhost:5173';
|
||||
const protocol = domain.includes('localhost') ? 'http' : 'https';
|
||||
const legalUrl = `${protocol}://${domain}/legal`;
|
||||
const inner = `
|
||||
<p>Hallo ${name},</p>
|
||||
<p>wir haben deine Anfrage für <strong>${formattedDate}</strong> um <strong>${time}</strong> erhalten.</p>
|
||||
<p>Wir bestätigen deinen Termin in Kürze. Du erhältst eine weitere E-Mail, sobald der Termin bestätigt ist.</p>
|
||||
${statusUrl ? `
|
||||
<div style="background-color: #fef9f5; border-left: 4px solid #f59e0b; padding: 16px; margin: 20px 0; border-radius: 4px;">
|
||||
<p style="margin: 0; font-weight: 600; color: #f59e0b;">⏳ Termin-Status ansehen:</p>
|
||||
<p style="margin: 8px 0 12px 0; color: #475569;">Du kannst den aktuellen Status deiner Buchung jederzeit einsehen:</p>
|
||||
<a href="${statusUrl}" style="display: inline-block; background-color: #f59e0b; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: 600;">Status ansehen</a>
|
||||
</div>
|
||||
` : ''}
|
||||
<div style="background-color: #f8fafc; border-left: 4px solid #3b82f6; padding: 16px; margin: 20px 0; border-radius: 4px;">
|
||||
<p style="margin: 0; font-weight: 600; color: #3b82f6;">📋 Rechtliche Informationen:</p>
|
||||
<p style="margin: 8px 0 12px 0; color: #475569;">Weitere Informationen findest du in unserem <a href="${legalUrl}" style="color: #3b82f6; text-decoration: underline;">Impressum und Datenschutz</a>.</p>
|
||||
</div>
|
||||
<p>Liebe Grüße,<br/>Stargirlnails Kiel</p>
|
||||
`;
|
||||
return renderBrandedEmail("Deine Terminanfrage ist eingegangen", inner);
|
||||
}
|
||||
export async function renderBookingConfirmedHTML(params) {
|
||||
const { name, date, time, cancellationUrl, reviewUrl } = params;
|
||||
const formattedDate = formatDateGerman(date);
|
||||
const domain = process.env.DOMAIN || 'localhost:5173';
|
||||
const protocol = domain.includes('localhost') ? 'http' : 'https';
|
||||
const legalUrl = `${protocol}://${domain}/legal`;
|
||||
const inner = `
|
||||
<p>Hallo ${name},</p>
|
||||
<p>wir haben deinen Termin am <strong>${formattedDate}</strong> um <strong>${time}</strong> bestätigt.</p>
|
||||
<p>Wir freuen uns auf dich!</p>
|
||||
<div style="background-color: #f8fafc; border-left: 4px solid #db2777; padding: 16px; margin: 20px 0; border-radius: 4px;">
|
||||
<p style="margin: 0; font-weight: 600; color: #db2777;">📋 Wichtiger Hinweis:</p>
|
||||
<p style="margin: 8px 0 0 0; color: #475569;">Die Allgemeinen Geschäftsbedingungen (AGB) findest du im Anhang dieser E-Mail. Bitte lies sie vor deinem Termin durch.</p>
|
||||
</div>
|
||||
${cancellationUrl ? `
|
||||
<div style="background-color: #fef9f5; border-left: 4px solid #db2777; padding: 16px; margin: 20px 0; border-radius: 4px;">
|
||||
<p style="margin: 0; font-weight: 600; color: #db2777;">📅 Termin verwalten:</p>
|
||||
<p style="margin: 8px 0 12px 0; color: #475569;">Du kannst deinen Termin-Status einsehen und bei Bedarf stornieren:</p>
|
||||
<a href="${cancellationUrl}" style="display: inline-block; background-color: #db2777; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: 600;">Termin ansehen & verwalten</a>
|
||||
</div>
|
||||
` : ''}
|
||||
${reviewUrl ? `
|
||||
<div style="background-color: #eff6ff; border-left: 4px solid #3b82f6; padding: 16px; margin: 20px 0; border-radius: 4px;">
|
||||
<p style="margin: 0; font-weight: 600; color: #3b82f6;">⭐ Bewertung abgeben:</p>
|
||||
<p style="margin: 8px 0 12px 0; color: #475569;">Nach deinem Termin würden wir uns über deine Bewertung freuen!</p>
|
||||
<a href="${reviewUrl}" style="display: inline-block; background-color: #3b82f6; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: 600;">Bewertung schreiben</a>
|
||||
<p style="margin: 12px 0 0 0; color: #64748b; font-size: 13px;">Du kannst deine Bewertung nach dem Termin über diesen Link abgeben.</p>
|
||||
</div>
|
||||
` : ''}
|
||||
<div style="background-color: #f8fafc; border-left: 4px solid #3b82f6; padding: 16px; margin: 20px 0; border-radius: 4px;">
|
||||
<p style="margin: 0; font-weight: 600; color: #3b82f6;">📋 Rechtliche Informationen:</p>
|
||||
<p style="margin: 8px 0 12px 0; color: #475569;">Weitere Informationen findest du in unserem <a href="${legalUrl}" style="color: #3b82f6; text-decoration: underline;">Impressum und Datenschutz</a>.</p>
|
||||
</div>
|
||||
<p>Liebe Grüße,<br/>Stargirlnails Kiel</p>
|
||||
`;
|
||||
return renderBrandedEmail("Termin bestätigt", inner);
|
||||
}
|
||||
export async function renderBookingCancelledHTML(params) {
|
||||
const { name, date, time } = params;
|
||||
const formattedDate = formatDateGerman(date);
|
||||
const domain = process.env.DOMAIN || 'localhost:5173';
|
||||
const protocol = domain.includes('localhost') ? 'http' : 'https';
|
||||
const legalUrl = `${protocol}://${domain}/legal`;
|
||||
const inner = `
|
||||
<p>Hallo ${name},</p>
|
||||
<p>dein Termin am <strong>${formattedDate}</strong> um <strong>${time}</strong> wurde abgesagt.</p>
|
||||
<p>Bitte buche einen neuen Termin. Bei Fragen helfen wir dir gerne weiter.</p>
|
||||
<div style="background-color: #f8fafc; border-left: 4px solid #3b82f6; padding: 16px; margin: 20px 0; border-radius: 4px;">
|
||||
<p style="margin: 0; font-weight: 600; color: #3b82f6;">📋 Rechtliche Informationen:</p>
|
||||
<p style="margin: 8px 0 12px 0; color: #475569;">Weitere Informationen findest du in unserem <a href="${legalUrl}" style="color: #3b82f6; text-decoration: underline;">Impressum und Datenschutz</a>.</p>
|
||||
</div>
|
||||
<p>Liebe Grüße,<br/>Stargirlnails Kiel</p>
|
||||
`;
|
||||
return renderBrandedEmail("Termin abgesagt", inner);
|
||||
}
|
||||
export async function renderAdminBookingNotificationHTML(params) {
|
||||
const { name, date, time, treatment, phone, notes, hasInspirationPhoto } = params;
|
||||
const formattedDate = formatDateGerman(date);
|
||||
const inner = `
|
||||
<p>Hallo Admin,</p>
|
||||
<p>eine neue Buchungsanfrage ist eingegangen:</p>
|
||||
<div style="background-color: #f8fafc; border-left: 4px solid #db2777; padding: 16px; margin: 20px 0; border-radius: 4px;">
|
||||
<p style="margin: 0; font-weight: 600; color: #db2777;">📅 Buchungsdetails:</p>
|
||||
<ul style="margin: 8px 0 0 0; color: #475569; list-style: none; padding: 0;">
|
||||
<li><strong>Name:</strong> ${name}</li>
|
||||
<li><strong>Telefon:</strong> ${phone}</li>
|
||||
<li><strong>Behandlung:</strong> ${treatment}</li>
|
||||
<li><strong>Datum:</strong> ${formattedDate}</li>
|
||||
<li><strong>Uhrzeit:</strong> ${time}</li>
|
||||
${notes ? `<li><strong>Notizen:</strong> ${notes}</li>` : ''}
|
||||
<li><strong>Inspiration-Foto:</strong> ${hasInspirationPhoto ? '✅ Im Anhang verfügbar' : '❌ Kein Foto hochgeladen'}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<p>Bitte logge dich in das Admin-Panel ein, um die Buchung zu bestätigen oder abzulehnen.</p>
|
||||
<p>Liebe Grüße,<br/>Stargirlnails System</p>
|
||||
`;
|
||||
return renderBrandedEmail("Neue Buchungsanfrage - Admin-Benachrichtigung", inner);
|
||||
}
|
||||
export async function renderBookingRescheduleProposalHTML(params) {
|
||||
const formattedOriginalDate = formatDateGerman(params.originalDate);
|
||||
const formattedProposedDate = formatDateGerman(params.proposedDate);
|
||||
const expiryDate = new Date(params.expiresAt);
|
||||
const formattedExpiry = `${expiryDate.toLocaleDateString('de-DE')} ${expiryDate.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })}`;
|
||||
const inner = `
|
||||
<p>Hallo ${params.name},</p>
|
||||
<p>wir müssen deinen Termin leider verschieben. Hier ist unser Vorschlag:</p>
|
||||
<div style="background-color: #f8fafc; border-left: 4px solid #f59e0b; padding: 16px; margin: 20px 0; border-radius: 4px;">
|
||||
<p style="margin: 0; font-weight: 600; color: #92400e;">📅 Übersicht</p>
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" style="width:100%; margin-top:8px; font-size:14px; color:#475569;">
|
||||
<tr>
|
||||
<td style="padding:6px 0; width:45%"><strong>Alter Termin</strong></td>
|
||||
<td style="padding:6px 0;">${formattedOriginalDate} um ${params.originalTime} Uhr</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:6px 0; width:45%"><strong>Neuer Vorschlag</strong></td>
|
||||
<td style="padding:6px 0; color:#b45309;"><strong>${formattedProposedDate} um ${params.proposedTime} Uhr</strong></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:6px 0; width:45%"><strong>Behandlung</strong></td>
|
||||
<td style="padding:6px 0;">${params.treatmentName}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<div style="background-color: #fffbeb; border-left: 4px solid #f59e0b; padding: 12px; margin: 16px 0; border-radius: 4px; color:#92400e;">
|
||||
⏰ Bitte antworte bis ${formattedExpiry}.
|
||||
</div>
|
||||
<div style="text-align:center; margin: 20px 0;">
|
||||
<a href="${params.acceptUrl}" style="display:inline-block; background-color:#16a34a; color:#ffffff; padding:12px 18px; border-radius:8px; text-decoration:none; font-weight:700; margin-right:8px;">Neuen Termin akzeptieren</a>
|
||||
<a href="${params.declineUrl}" style="display:inline-block; background-color:#dc2626; color:#ffffff; padding:12px 18px; border-radius:8px; text-decoration:none; font-weight:700;">Termin ablehnen</a>
|
||||
</div>
|
||||
<div style="background-color: #f8fafc; border-left: 4px solid #10b981; padding: 12px; margin: 16px 0; border-radius: 4px; color:#065f46;">
|
||||
Wenn du den Vorschlag ablehnst, bleibt dein ursprünglicher Termin bestehen und wir kontaktieren dich für eine alternative Lösung.
|
||||
</div>
|
||||
<p>Falls du einen komplett neuen Termin buchen möchtest, kannst du deinen aktuellen Termin stornieren und einen neuen Termin auf unserer Website buchen.</p>
|
||||
<p>Liebe Grüße,<br/>Stargirlnails Kiel</p>
|
||||
`;
|
||||
return renderBrandedEmail("Terminänderung vorgeschlagen", inner);
|
||||
}
|
||||
export async function renderAdminRescheduleDeclinedHTML(params) {
|
||||
const inner = `
|
||||
<p>Hallo Admin,</p>
|
||||
<p>der Kunde <strong>${params.customerName}</strong> hat den Terminänderungsvorschlag abgelehnt.</p>
|
||||
<div style="background-color:#f8fafc; border-left:4px solid #ef4444; padding:16px; margin:16px 0; border-radius:4px;">
|
||||
<ul style="margin:0; padding:0; list-style:none; color:#475569; font-size:14px;">
|
||||
<li><strong>Kunde:</strong> ${params.customerName}</li>
|
||||
${params.customerEmail ? `<li><strong>E-Mail:</strong> ${params.customerEmail}</li>` : ''}
|
||||
${params.customerPhone ? `<li><strong>Telefon:</strong> ${params.customerPhone}</li>` : ''}
|
||||
<li><strong>Behandlung:</strong> ${params.treatmentName}</li>
|
||||
<li><strong>Ursprünglicher Termin:</strong> ${formatDateGerman(params.originalDate)} um ${params.originalTime} Uhr (bleibt bestehen)</li>
|
||||
<li><strong>Abgelehnter Vorschlag:</strong> ${formatDateGerman(params.proposedDate)} um ${params.proposedTime} Uhr</li>
|
||||
</ul>
|
||||
</div>
|
||||
<p>Bitte kontaktiere den Kunden, um eine alternative Lösung zu finden.</p>
|
||||
`;
|
||||
return renderBrandedEmail("Kunde hat Terminänderung abgelehnt", inner);
|
||||
}
|
||||
export async function renderAdminRescheduleAcceptedHTML(params) {
|
||||
const inner = `
|
||||
<p>Hallo Admin,</p>
|
||||
<p>der Kunde <strong>${params.customerName}</strong> hat den Terminänderungsvorschlag akzeptiert.</p>
|
||||
<div style="background-color:#ecfeff; border-left:4px solid #10b981; padding:16px; margin:16px 0; border-radius:4px;">
|
||||
<ul style="margin:0; padding:0; list-style:none; color:#475569; font-size:14px;">
|
||||
<li><strong>Kunde:</strong> ${params.customerName}</li>
|
||||
<li><strong>Behandlung:</strong> ${params.treatmentName}</li>
|
||||
<li><strong>Alter Termin:</strong> ${formatDateGerman(params.originalDate)} um ${params.originalTime} Uhr</li>
|
||||
<li><strong>Neuer Termin:</strong> ${formatDateGerman(params.newDate)} um ${params.newTime} Uhr ✅</li>
|
||||
</ul>
|
||||
</div>
|
||||
<p>Der Termin wurde automatisch aktualisiert.</p>
|
||||
`;
|
||||
return renderBrandedEmail("Kunde hat Terminänderung akzeptiert", inner);
|
||||
}
|
||||
export async function renderAdminRescheduleExpiredHTML(params) {
|
||||
const inner = `
|
||||
<p>Hallo Admin,</p>
|
||||
<p><strong>${params.expiredProposals.length} Terminänderungsvorschlag${params.expiredProposals.length > 1 ? 'e' : ''} ${params.expiredProposals.length > 1 ? 'sind' : 'ist'} abgelaufen</strong> und wurde${params.expiredProposals.length > 1 ? 'n' : ''} automatisch entfernt.</p>
|
||||
<div style="background-color:#fef2f2; border-left:4px solid #ef4444; padding:16px; margin:16px 0; border-radius:4px;">
|
||||
<p style="margin:0 0 12px 0; font-weight:600; color:#dc2626;">⚠️ Abgelaufene Vorschläge:</p>
|
||||
${params.expiredProposals.map(proposal => `
|
||||
<div style="background-color:#ffffff; border:1px solid #fecaca; border-radius:4px; padding:12px; margin:8px 0;">
|
||||
<ul style="margin:0; padding:0; list-style:none; color:#475569; font-size:13px;">
|
||||
<li><strong>Kunde:</strong> ${proposal.customerName}</li>
|
||||
${proposal.customerEmail ? `<li><strong>E-Mail:</strong> ${proposal.customerEmail}</li>` : ''}
|
||||
${proposal.customerPhone ? `<li><strong>Telefon:</strong> ${proposal.customerPhone}</li>` : ''}
|
||||
<li><strong>Behandlung:</strong> ${proposal.treatmentName}</li>
|
||||
<li><strong>Ursprünglicher Termin:</strong> ${formatDateGerman(proposal.originalDate)} um ${proposal.originalTime} Uhr</li>
|
||||
<li><strong>Vorgeschlagener Termin:</strong> ${formatDateGerman(proposal.proposedDate)} um ${proposal.proposedTime} Uhr</li>
|
||||
<li><strong>Abgelaufen am:</strong> ${new Date(proposal.expiredAt).toLocaleString('de-DE')}</li>
|
||||
</ul>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
<p style="color:#dc2626; font-weight:600;">Bitte kontaktiere die Kunden, um eine alternative Lösung zu finden.</p>
|
||||
<p>Die ursprünglichen Termine bleiben bestehen.</p>
|
||||
`;
|
||||
return renderBrandedEmail("Abgelaufene Terminänderungsvorschläge", inner);
|
||||
}
|
88
server-dist/lib/email-validator.js
Normal file
88
server-dist/lib/email-validator.js
Normal file
@@ -0,0 +1,88 @@
|
||||
// Email validation using Rapid Email Validator API
|
||||
// API: https://rapid-email-verifier.fly.dev/
|
||||
// Privacy-focused, no data storage, completely free
|
||||
/**
|
||||
* Validate email address using Rapid Email Validator API
|
||||
* Returns true if email is valid, false otherwise
|
||||
*/
|
||||
export async function validateEmail(email) {
|
||||
try {
|
||||
// Call Rapid Email Validator API
|
||||
const response = await fetch(`https://rapid-email-verifier.fly.dev/api/validate?email=${encodeURIComponent(email)}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
});
|
||||
if (!response.ok) {
|
||||
console.error(`Email validation API error: ${response.status}`);
|
||||
// If API is down, reject the email with error message
|
||||
return {
|
||||
valid: false,
|
||||
reason: 'E-Mail-Validierung ist derzeit nicht verfügbar. Bitte überprüfe deine E-Mail-Adresse und versuche es erneut.'
|
||||
};
|
||||
}
|
||||
const data = await response.json();
|
||||
// Check if email is disposable/temporary
|
||||
if (data.validations.is_disposable) {
|
||||
return {
|
||||
valid: false,
|
||||
reason: 'Temporäre oder Wegwerf-E-Mail-Adressen sind nicht erlaubt. Bitte verwende eine echte E-Mail-Adresse.',
|
||||
};
|
||||
}
|
||||
// Check if MX records exist (deliverable)
|
||||
if (!data.validations.mx_records) {
|
||||
return {
|
||||
valid: false,
|
||||
reason: 'Diese E-Mail-Adresse kann keine E-Mails empfangen. Bitte überprüfe deine E-Mail-Adresse.',
|
||||
};
|
||||
}
|
||||
// Check if domain exists
|
||||
if (!data.validations.domain_exists) {
|
||||
return {
|
||||
valid: false,
|
||||
reason: 'Die E-Mail-Domain existiert nicht. Bitte überprüfe deine E-Mail-Adresse.',
|
||||
};
|
||||
}
|
||||
// Check if email syntax is valid
|
||||
if (!data.validations.syntax) {
|
||||
return {
|
||||
valid: false,
|
||||
reason: 'Ungültige E-Mail-Adresse. Bitte überprüfe die Schreibweise.',
|
||||
};
|
||||
}
|
||||
// Email is valid
|
||||
return { valid: true };
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Email validation error:', error);
|
||||
// If validation fails, reject the email with error message
|
||||
return {
|
||||
valid: false,
|
||||
reason: 'E-Mail-Validierung ist derzeit nicht verfügbar. Bitte überprüfe deine E-Mail-Adresse und versuche es erneut.'
|
||||
};
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Batch validate multiple emails
|
||||
* @param emails Array of email addresses to validate
|
||||
* @returns Array of validation results
|
||||
*/
|
||||
export async function validateEmailBatch(emails) {
|
||||
const results = new Map();
|
||||
// Validate up to 100 emails at once (API limit)
|
||||
const batchSize = 100;
|
||||
for (let i = 0; i < emails.length; i += batchSize) {
|
||||
const batch = emails.slice(i, i + batchSize);
|
||||
// Call each validation in parallel for better performance
|
||||
const validations = await Promise.all(batch.map(async (email) => {
|
||||
const result = await validateEmail(email);
|
||||
return { email, result };
|
||||
}));
|
||||
// Store results
|
||||
validations.forEach(({ email, result }) => {
|
||||
results.set(email, result);
|
||||
});
|
||||
}
|
||||
return results;
|
||||
}
|
186
server-dist/lib/email.js
Normal file
186
server-dist/lib/email.js
Normal file
@@ -0,0 +1,186 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, resolve } from "node:path";
|
||||
const RESEND_API_KEY = process.env.RESEND_API_KEY;
|
||||
const DEFAULT_FROM = process.env.EMAIL_FROM || "Stargirlnails <no-reply@stargirlnails.de>";
|
||||
// Helper function to format dates for ICS files (YYYYMMDDTHHMMSS)
|
||||
function formatDateForICS(date, time) {
|
||||
// date is in YYYY-MM-DD format, time is in HH:MM format
|
||||
const [year, month, day] = date.split('-');
|
||||
const [hours, minutes] = time.split(':');
|
||||
return `${year}${month}${day}T${hours}${minutes}00`;
|
||||
}
|
||||
// Helper function to create ICS (iCalendar) file content
|
||||
function createICSFile(params) {
|
||||
const { date, time, durationMinutes, customerName, treatmentName } = params;
|
||||
// Calculate start and end times in Europe/Berlin timezone
|
||||
const dtStart = formatDateForICS(date, time);
|
||||
// Calculate end time
|
||||
const [hours, minutes] = time.split(':').map(Number);
|
||||
const startDate = new Date(`${date}T${time}:00`);
|
||||
const endDate = new Date(startDate.getTime() + durationMinutes * 60000);
|
||||
const endHours = String(endDate.getHours()).padStart(2, '0');
|
||||
const endMinutes = String(endDate.getMinutes()).padStart(2, '0');
|
||||
const dtEnd = formatDateForICS(date, `${endHours}:${endMinutes}`);
|
||||
// Create unique ID for this event
|
||||
const uid = `booking-${Date.now()}-${Math.random().toString(36).substr(2, 9)}@stargirlnails.de`;
|
||||
// Current timestamp for DTSTAMP
|
||||
const now = new Date();
|
||||
const dtstamp = now.toISOString().replace(/[-:]/g, '').split('.')[0] + 'Z';
|
||||
// ICS content
|
||||
const icsContent = [
|
||||
'BEGIN:VCALENDAR',
|
||||
'VERSION:2.0',
|
||||
'PRODID:-//Stargirlnails Kiel//Booking System//DE',
|
||||
'CALSCALE:GREGORIAN',
|
||||
'METHOD:REQUEST',
|
||||
'BEGIN:VEVENT',
|
||||
`UID:${uid}`,
|
||||
`DTSTAMP:${dtstamp}`,
|
||||
`DTSTART;TZID=Europe/Berlin:${dtStart}`,
|
||||
`DTEND;TZID=Europe/Berlin:${dtEnd}`,
|
||||
`SUMMARY:${treatmentName} - Stargirlnails Kiel`,
|
||||
`DESCRIPTION:Termin für ${treatmentName} bei Stargirlnails Kiel`,
|
||||
'LOCATION:Stargirlnails Kiel',
|
||||
`ORGANIZER;CN=Stargirlnails Kiel:mailto:${process.env.EMAIL_FROM?.match(/<(.+)>/)?.[1] || 'no-reply@stargirlnails.de'}`,
|
||||
`ATTENDEE;CN=${customerName};RSVP=TRUE:mailto:${customerName}`,
|
||||
'STATUS:CONFIRMED',
|
||||
'SEQUENCE:0',
|
||||
'BEGIN:VALARM',
|
||||
'TRIGGER:-PT24H',
|
||||
'ACTION:DISPLAY',
|
||||
'DESCRIPTION:Erinnerung: Termin morgen bei Stargirlnails Kiel',
|
||||
'END:VALARM',
|
||||
'END:VEVENT',
|
||||
'BEGIN:VTIMEZONE',
|
||||
'TZID:Europe/Berlin',
|
||||
'BEGIN:DAYLIGHT',
|
||||
'TZOFFSETFROM:+0100',
|
||||
'TZOFFSETTO:+0200',
|
||||
'TZNAME:CEST',
|
||||
'DTSTART:19700329T020000',
|
||||
'RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU',
|
||||
'END:DAYLIGHT',
|
||||
'BEGIN:STANDARD',
|
||||
'TZOFFSETFROM:+0200',
|
||||
'TZOFFSETTO:+0100',
|
||||
'TZNAME:CET',
|
||||
'DTSTART:19701025T030000',
|
||||
'RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU',
|
||||
'END:STANDARD',
|
||||
'END:VTIMEZONE',
|
||||
'END:VCALENDAR'
|
||||
].join('\r\n');
|
||||
return icsContent;
|
||||
}
|
||||
// Cache for AGB PDF to avoid reading it multiple times
|
||||
let cachedAGBPDF = null;
|
||||
async function getAGBPDFBase64() {
|
||||
if (cachedAGBPDF)
|
||||
return cachedAGBPDF;
|
||||
try {
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
const agbPath = resolve(__dirname, "../../../AGB.pdf");
|
||||
const buf = await readFile(agbPath);
|
||||
cachedAGBPDF = buf.toString('base64');
|
||||
return cachedAGBPDF;
|
||||
}
|
||||
catch (error) {
|
||||
console.warn("Could not read AGB.pdf:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
export async function sendEmail(params) {
|
||||
if (!RESEND_API_KEY) {
|
||||
// In development or if not configured, skip sending but don't fail the flow
|
||||
console.warn("Resend API key not configured. Skipping email send.");
|
||||
return { success: false };
|
||||
}
|
||||
const response = await fetch("https://api.resend.com/emails", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Authorization": `Bearer ${RESEND_API_KEY}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
from: params.from || DEFAULT_FROM,
|
||||
to: Array.isArray(params.to) ? params.to : [params.to],
|
||||
subject: params.subject,
|
||||
text: params.text,
|
||||
html: params.html,
|
||||
cc: params.cc ? (Array.isArray(params.cc) ? params.cc : [params.cc]) : undefined,
|
||||
bcc: params.bcc ? (Array.isArray(params.bcc) ? params.bcc : [params.bcc]) : undefined,
|
||||
attachments: params.attachments,
|
||||
}),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const body = await response.text().catch(() => "");
|
||||
console.error("Resend send error:", response.status, body);
|
||||
return { success: false };
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
export async function sendEmailWithAGB(params) {
|
||||
const agbBase64 = await getAGBPDFBase64();
|
||||
if (agbBase64) {
|
||||
params.attachments = [
|
||||
...(params.attachments || []),
|
||||
{
|
||||
filename: "AGB_Stargirlnails_Kiel.pdf",
|
||||
content: agbBase64,
|
||||
type: "application/pdf"
|
||||
}
|
||||
];
|
||||
}
|
||||
return sendEmail(params);
|
||||
}
|
||||
export async function sendEmailWithAGBAndCalendar(params, calendarParams) {
|
||||
const agbBase64 = await getAGBPDFBase64();
|
||||
// Create ICS file content
|
||||
const icsContent = createICSFile(calendarParams);
|
||||
const icsBase64 = Buffer.from(icsContent, 'utf-8').toString('base64');
|
||||
// Attach both AGB and ICS file
|
||||
params.attachments = [...(params.attachments || [])];
|
||||
if (agbBase64) {
|
||||
params.attachments.push({
|
||||
filename: "AGB_Stargirlnails_Kiel.pdf",
|
||||
content: agbBase64,
|
||||
type: "application/pdf"
|
||||
});
|
||||
}
|
||||
params.attachments.push({
|
||||
filename: "Termin_Stargirlnails.ics",
|
||||
content: icsBase64,
|
||||
type: "text/calendar"
|
||||
});
|
||||
return sendEmail(params);
|
||||
}
|
||||
export async function sendEmailWithInspirationPhoto(params, photoData, customerName) {
|
||||
if (!photoData) {
|
||||
return sendEmail(params);
|
||||
}
|
||||
// Extract file extension from base64 data URL
|
||||
const match = photoData.match(/data:image\/([^;]+);base64,(.+)/);
|
||||
if (!match) {
|
||||
console.warn("Invalid photo data format");
|
||||
return sendEmail(params);
|
||||
}
|
||||
const [, extension, base64Content] = match;
|
||||
const filename = `inspiration_${customerName.replace(/[^a-zA-Z0-9]/g, '_')}_${Date.now()}.${extension}`;
|
||||
// Check if attachment is too large (max 1MB base64 content)
|
||||
if (base64Content.length > 1024 * 1024) {
|
||||
console.warn(`Photo attachment too large: ${base64Content.length} chars, skipping attachment`);
|
||||
return sendEmail(params);
|
||||
}
|
||||
// console.log(`Sending email with photo attachment: ${filename}, size: ${base64Content.length} chars`);
|
||||
params.attachments = [
|
||||
...(params.attachments || []),
|
||||
{
|
||||
filename,
|
||||
content: base64Content,
|
||||
type: `image/${extension}`
|
||||
}
|
||||
];
|
||||
return sendEmail(params);
|
||||
}
|
39
server-dist/lib/legal-config.js
Normal file
39
server-dist/lib/legal-config.js
Normal file
@@ -0,0 +1,39 @@
|
||||
// Default configuration - should be overridden by environment variables
|
||||
export const defaultLegalConfig = {
|
||||
companyName: process.env.COMPANY_NAME || "Stargirlnails Kiel",
|
||||
ownerName: process.env.OWNER_NAME || "Inhaber Name",
|
||||
address: {
|
||||
street: process.env.ADDRESS_STREET || "Liebigstr. 15",
|
||||
city: process.env.ADDRESS_CITY || "Kiel",
|
||||
postalCode: process.env.ADDRESS_POSTAL_CODE || "24145",
|
||||
country: process.env.ADDRESS_COUNTRY || "Deutschland",
|
||||
latitude: process.env.ADDRESS_LATITUDE ? parseFloat(process.env.ADDRESS_LATITUDE) : 54.3233,
|
||||
longitude: process.env.ADDRESS_LONGITUDE ? parseFloat(process.env.ADDRESS_LONGITUDE) : 10.1228,
|
||||
},
|
||||
contact: {
|
||||
phone: process.env.CONTACT_PHONE || "+49 431 123456",
|
||||
email: process.env.CONTACT_EMAIL || "info@stargirlnails.de",
|
||||
website: process.env.DOMAIN || "stargirlnails.de",
|
||||
},
|
||||
businessDetails: {
|
||||
taxId: process.env.TAX_ID || "",
|
||||
vatId: process.env.VAT_ID || "",
|
||||
commercialRegister: process.env.COMMERCIAL_REGISTER || "",
|
||||
responsibleForContent: process.env.RESPONSIBLE_FOR_CONTENT || "Inhaber Name",
|
||||
},
|
||||
dataProtection: {
|
||||
responsiblePerson: process.env.DATA_PROTECTION_RESPONSIBLE || "Inhaber Name",
|
||||
email: process.env.DATA_PROTECTION_EMAIL || "datenschutz@stargirlnails.de",
|
||||
purpose: process.env.DATA_PROTECTION_PURPOSE || "Terminbuchung und Kundenbetreuung",
|
||||
legalBasis: process.env.DATA_PROTECTION_LEGAL_BASIS || "Art. 6 Abs. 1 lit. b DSGVO (Vertragserfüllung) und Art. 6 Abs. 1 lit. f DSGVO (berechtigtes Interesse)",
|
||||
dataRetention: process.env.DATA_PROTECTION_RETENTION || "Buchungsdaten werden 3 Jahre nach Vertragsende gespeichert",
|
||||
rights: process.env.DATA_PROTECTION_RIGHTS || "Auskunft, Berichtigung, Löschung, Einschränkung, Widerspruch, Beschwerde bei der Aufsichtsbehörde",
|
||||
cookies: process.env.DATA_PROTECTION_COOKIES || "Wir verwenden technisch notwendige Cookies für die Funktionalität der Website",
|
||||
thirdPartyServices: process.env.THIRD_PARTY_SERVICES ? process.env.THIRD_PARTY_SERVICES.split(',') : ["Resend (E-Mail-Versand)"],
|
||||
dataSecurity: process.env.DATA_PROTECTION_SECURITY || "SSL-Verschlüsselung, sichere Server, regelmäßige Updates",
|
||||
contactDataProtection: process.env.DATA_PROTECTION_CONTACT || "Bei Fragen zum Datenschutz wenden Sie sich an: datenschutz@stargirlnails.de",
|
||||
},
|
||||
};
|
||||
export function getLegalConfig() {
|
||||
return defaultLegalConfig;
|
||||
}
|
14
server-dist/lib/openai.js
Normal file
14
server-dist/lib/openai.js
Normal file
@@ -0,0 +1,14 @@
|
||||
import { jsonrepair } from "jsonrepair";
|
||||
import { z } from "zod";
|
||||
import { makeParseableResponseFormat } from "openai/lib/parser";
|
||||
export function zodResponseFormat(zodObject, name, props) {
|
||||
return makeParseableResponseFormat({
|
||||
type: "json_schema",
|
||||
json_schema: {
|
||||
...props,
|
||||
name,
|
||||
strict: true,
|
||||
schema: z.toJSONSchema(zodObject, { target: "draft-7" }),
|
||||
},
|
||||
}, (content) => zodObject.parse(JSON.parse(jsonrepair(content))));
|
||||
}
|
117
server-dist/lib/rate-limiter.js
Normal file
117
server-dist/lib/rate-limiter.js
Normal file
@@ -0,0 +1,117 @@
|
||||
// Simple in-memory rate limiter for IP and email-based requests
|
||||
// For production with multiple instances, consider using Redis
|
||||
const rateLimitStore = new Map();
|
||||
// Cleanup old entries every 10 minutes to prevent memory leaks
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [key, entry] of rateLimitStore.entries()) {
|
||||
if (entry.resetAt < now) {
|
||||
rateLimitStore.delete(key);
|
||||
}
|
||||
}
|
||||
}, 10 * 60 * 1000);
|
||||
/**
|
||||
* Check if a request is allowed based on rate limiting
|
||||
* @param key - Unique identifier (IP, email, or combination)
|
||||
* @param config - Rate limit configuration
|
||||
* @returns RateLimitResult with allow status and metadata
|
||||
*/
|
||||
export function checkRateLimit(key, config) {
|
||||
const now = Date.now();
|
||||
const entry = rateLimitStore.get(key);
|
||||
// No existing entry or window expired - allow and create new entry
|
||||
if (!entry || entry.resetAt < now) {
|
||||
rateLimitStore.set(key, {
|
||||
count: 1,
|
||||
resetAt: now + config.windowMs,
|
||||
});
|
||||
return {
|
||||
allowed: true,
|
||||
remaining: config.maxRequests - 1,
|
||||
resetAt: now + config.windowMs,
|
||||
};
|
||||
}
|
||||
// Existing entry within window
|
||||
if (entry.count >= config.maxRequests) {
|
||||
// Rate limit exceeded
|
||||
const retryAfterSeconds = Math.ceil((entry.resetAt - now) / 1000);
|
||||
return {
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
resetAt: entry.resetAt,
|
||||
retryAfterSeconds,
|
||||
};
|
||||
}
|
||||
// Increment count and allow
|
||||
entry.count++;
|
||||
rateLimitStore.set(key, entry);
|
||||
return {
|
||||
allowed: true,
|
||||
remaining: config.maxRequests - entry.count,
|
||||
resetAt: entry.resetAt,
|
||||
};
|
||||
}
|
||||
/**
|
||||
* Check rate limit for booking creation
|
||||
* Applies multiple checks: per IP and per email
|
||||
*/
|
||||
export function checkBookingRateLimit(params) {
|
||||
const { ip, email } = params;
|
||||
// Config: max 3 bookings per email per hour
|
||||
const emailConfig = {
|
||||
maxRequests: 3,
|
||||
windowMs: 60 * 60 * 1000, // 1 hour
|
||||
};
|
||||
// Config: max 5 bookings per IP per 10 minutes
|
||||
const ipConfig = {
|
||||
maxRequests: 5,
|
||||
windowMs: 10 * 60 * 1000, // 10 minutes
|
||||
};
|
||||
// Check email rate limit
|
||||
const emailKey = `booking:email:${email.toLowerCase()}`;
|
||||
const emailResult = checkRateLimit(emailKey, emailConfig);
|
||||
if (!emailResult.allowed) {
|
||||
return {
|
||||
...emailResult,
|
||||
allowed: false,
|
||||
};
|
||||
}
|
||||
// Check IP rate limit (if IP is available)
|
||||
if (ip) {
|
||||
const ipKey = `booking:ip:${ip}`;
|
||||
const ipResult = checkRateLimit(ipKey, ipConfig);
|
||||
if (!ipResult.allowed) {
|
||||
return {
|
||||
...ipResult,
|
||||
allowed: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
// Both checks passed
|
||||
return {
|
||||
allowed: true,
|
||||
remaining: Math.min(emailResult.remaining, ip ? Infinity : emailResult.remaining),
|
||||
resetAt: emailResult.resetAt,
|
||||
};
|
||||
}
|
||||
/**
|
||||
* Get client IP from various headers (for proxy/load balancer support)
|
||||
*/
|
||||
export function getClientIP(headers) {
|
||||
// Check common proxy headers
|
||||
const forwardedFor = headers['x-forwarded-for'];
|
||||
if (forwardedFor) {
|
||||
// x-forwarded-for can contain multiple IPs, take the first one
|
||||
return forwardedFor.split(',')[0].trim();
|
||||
}
|
||||
const realIP = headers['x-real-ip'];
|
||||
if (realIP) {
|
||||
return realIP;
|
||||
}
|
||||
const cfConnectingIP = headers['cf-connecting-ip']; // Cloudflare
|
||||
if (cfConnectingIP) {
|
||||
return cfConnectingIP;
|
||||
}
|
||||
// No IP found
|
||||
return undefined;
|
||||
}
|
Reference in New Issue
Block a user