feat: ICS-Kalendereinträge, Rate-Limiting und erweiterte E-Mail-Validierung
- ICS-Dateianhänge in Bestätigungsmails mit Europe/Berlin Zeitzone - Rate-Limiting: IP-basiert (5/10min) und E-Mail-basiert (3/1h) - Mehrschichtige E-Mail-Validierung mit Rapid Email Validator API - Disposable Email Detection (blockiert Wegwerf-Adressen) - MX Record Verification - Domain Verification - Typo-Erkennung mit Vorschlägen - Zod-Schema-Validierung für Name, E-Mail und Telefonnummer - Dokumentation für Rate-Limiting und E-Mail-Validierung - README mit neuen Features aktualisiert - Backlog aktualisiert
This commit is contained in:
@@ -3,11 +3,13 @@ import { z } from "zod";
|
||||
import { randomUUID } from "crypto";
|
||||
import { createKV } from "@/server/lib/create-kv";
|
||||
import { createKV as createAvailabilityKV } from "@/server/lib/create-kv";
|
||||
import { sendEmail, sendEmailWithAGB, sendEmailWithInspirationPhoto } from "@/server/lib/email";
|
||||
import { sendEmail, sendEmailWithAGB, sendEmailWithAGBAndCalendar, sendEmailWithInspirationPhoto } from "@/server/lib/email";
|
||||
import { renderBookingPendingHTML, renderBookingConfirmedHTML, renderBookingCancelledHTML, renderAdminBookingNotificationHTML } from "@/server/lib/email-templates";
|
||||
import { router } from "@/server/rpc";
|
||||
import { createORPCClient } from "@orpc/client";
|
||||
import { RPCLink } from "@orpc/client/fetch";
|
||||
import { checkBookingRateLimit, getClientIP } from "@/server/lib/rate-limiter";
|
||||
import { validateEmail } from "@/server/lib/email-validator";
|
||||
|
||||
// Create a server-side client to call other RPC endpoints
|
||||
const link = new RPCLink({ url: "http://localhost:5173/rpc" });
|
||||
@@ -29,9 +31,9 @@ function generateUrl(path: string = ''): string {
|
||||
const BookingSchema = z.object({
|
||||
id: z.string(),
|
||||
treatmentId: z.string(),
|
||||
customerName: z.string(),
|
||||
customerEmail: z.string(),
|
||||
customerPhone: z.string(),
|
||||
customerName: z.string().min(2, "Name muss mindestens 2 Zeichen lang sein"),
|
||||
customerEmail: z.string().email("Ungültige E-Mail-Adresse"),
|
||||
customerPhone: z.string().min(5, "Telefonnummer muss mindestens 5 Zeichen lang sein"),
|
||||
appointmentDate: z.string(), // ISO date string
|
||||
appointmentTime: z.string(), // HH:MM format
|
||||
status: z.enum(["pending", "confirmed", "cancelled", "completed"]),
|
||||
@@ -70,13 +72,44 @@ const treatmentsKV = createTreatmentsKV<Treatment>("treatments");
|
||||
|
||||
const create = os
|
||||
.input(BookingSchema.omit({ id: true, createdAt: true, status: true }))
|
||||
.handler(async ({ input }) => {
|
||||
.handler(async ({ input, context }) => {
|
||||
// console.log("Booking create called with input:", {
|
||||
// ...input,
|
||||
// inspirationPhoto: input.inspirationPhoto ? `[${input.inspirationPhoto.length} chars]` : null
|
||||
// });
|
||||
|
||||
try {
|
||||
// Rate limiting check
|
||||
const headers = context.request?.headers || {};
|
||||
const headersObj: Record<string, string | undefined> = {};
|
||||
if (headers) {
|
||||
// Convert Headers object to plain object
|
||||
headers.forEach((value: string, key: string) => {
|
||||
headersObj[key.toLowerCase()] = value;
|
||||
});
|
||||
}
|
||||
const clientIP = getClientIP(headersObj);
|
||||
|
||||
const rateLimitResult = checkBookingRateLimit({
|
||||
ip: clientIP,
|
||||
email: input.customerEmail,
|
||||
});
|
||||
|
||||
if (!rateLimitResult.allowed) {
|
||||
const retryMinutes = rateLimitResult.retryAfterSeconds
|
||||
? Math.ceil(rateLimitResult.retryAfterSeconds / 60)
|
||||
: 10;
|
||||
throw new Error(
|
||||
`Zu viele Buchungsanfragen. Bitte versuche es in ${retryMinutes} Minute${retryMinutes > 1 ? 'n' : ''} erneut.`
|
||||
);
|
||||
}
|
||||
|
||||
// Deep email validation using Rapid Email Validator API
|
||||
const emailValidation = await validateEmail(input.customerEmail);
|
||||
if (!emailValidation.valid) {
|
||||
throw new Error(emailValidation.reason || "Ungültige E-Mail-Adresse");
|
||||
}
|
||||
|
||||
// Validate that the booking is not in the past
|
||||
const today = new Date().toISOString().split("T")[0]; // YYYY-MM-DD
|
||||
if (input.appointmentDate < today) {
|
||||
@@ -273,12 +306,24 @@ const updateStatus = os
|
||||
cancellationUrl
|
||||
});
|
||||
|
||||
await sendEmailWithAGB({
|
||||
// Get treatment information for ICS file
|
||||
const allTreatments = await treatmentsKV.getAllItems();
|
||||
const treatment = allTreatments.find(t => t.id === booking.treatmentId);
|
||||
const treatmentName = treatment?.name || "Behandlung";
|
||||
const treatmentDuration = treatment?.duration || 60; // Default 60 minutes if not found
|
||||
|
||||
await sendEmailWithAGBAndCalendar({
|
||||
to: booking.customerEmail,
|
||||
subject: "Dein Termin wurde bestätigt - AGB im Anhang",
|
||||
text: `Hallo ${booking.customerName},\n\nwir haben deinen Termin am ${formattedDate} um ${booking.appointmentTime} bestätigt.\n\nWichtiger Hinweis: Die Allgemeinen Geschäftsbedingungen (AGB) findest du im Anhang dieser E-Mail. Bitte lies sie vor deinem Termin durch.\n\nFalls du den Termin stornieren möchtest, kannst du das hier tun: ${cancellationUrl}\n\nRechtliche Informationen: ${generateUrl('/legal')}\nZur Website: ${homepageUrl}\n\nBis bald!\nStargirlnails Kiel`,
|
||||
html,
|
||||
cc: process.env.ADMIN_EMAIL ? [process.env.ADMIN_EMAIL] : undefined,
|
||||
}, {
|
||||
date: booking.appointmentDate,
|
||||
time: booking.appointmentTime,
|
||||
durationMinutes: treatmentDuration,
|
||||
customerName: booking.customerName,
|
||||
treatmentName: treatmentName
|
||||
});
|
||||
} else if (input.status === "cancelled") {
|
||||
const formattedDate = formatDateGerman(booking.appointmentDate);
|
||||
|
Reference in New Issue
Block a user