Fix: Slot reservation only after successful email validation
- Move email validation before slot reservation in backend - Remove duplicate frontend email validation - Slots are no longer blocked by failed booking attempts - Clean up unused email error UI components - Ensure slots remain available if email validation fails
This commit is contained in:
@@ -15,4 +15,5 @@ services:
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
environment:
|
||||
- NODE_ENV=development
|
||||
- NODE_ENV=production
|
||||
- DISABLE_DUPLICATE_CHECK=true
|
||||
|
@@ -138,7 +138,8 @@ export function BookingForm() {
|
||||
if (fileInput) fileInput.value = '';
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setErrorMessage(""); // Clear any previous error messages
|
||||
|
||||
@@ -160,6 +161,8 @@ export function BookingForm() {
|
||||
setErrorMessage("Bitte bestätige die Kenntnisnahme der Allgemeinen Geschäftsbedingungen.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Email validation now handled in backend before slot reservation
|
||||
const slot = availableSlots.find((s) => s.id === selectedSlotId);
|
||||
const appointmentTime = slot?.time || "";
|
||||
// console.log("Creating booking with data:", {
|
||||
@@ -206,19 +209,13 @@ export function BookingForm() {
|
||||
onError: (error: any) => {
|
||||
console.error("Booking error:", error);
|
||||
|
||||
// Extract error message from oRPC error structure
|
||||
// Simple error handling for oRPC errors
|
||||
let errorText = "Ein unbekannter Fehler ist aufgetreten.";
|
||||
|
||||
if (error?.message) {
|
||||
if (error?.cause?.message) {
|
||||
errorText = error.cause.message;
|
||||
} else if (error?.message && error.message !== "Internal server error") {
|
||||
errorText = error.message;
|
||||
} else if (error?.data?.message) {
|
||||
errorText = error.data.message;
|
||||
} else if (error?.data?.error) {
|
||||
errorText = error.data.error;
|
||||
} else if (typeof error === 'string') {
|
||||
errorText = error;
|
||||
} else if (error?.toString) {
|
||||
errorText = error.toString();
|
||||
}
|
||||
|
||||
setErrorMessage(errorText);
|
||||
|
@@ -3,15 +3,17 @@
|
||||
// Privacy-focused, no data storage, completely free
|
||||
|
||||
type EmailValidationResult = {
|
||||
valid: boolean;
|
||||
email: string;
|
||||
domain?: string;
|
||||
disposable?: boolean;
|
||||
role?: boolean;
|
||||
typo?: boolean;
|
||||
suggestion?: string;
|
||||
mx?: boolean;
|
||||
error?: string;
|
||||
validations: {
|
||||
syntax: boolean;
|
||||
domain_exists: boolean;
|
||||
mx_records: boolean;
|
||||
mailbox_exists: boolean;
|
||||
is_disposable: boolean;
|
||||
is_role_based: boolean;
|
||||
};
|
||||
score: number;
|
||||
status: string;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -25,7 +27,7 @@ export async function validateEmail(email: string): Promise<{
|
||||
}> {
|
||||
try {
|
||||
// Call Rapid Email Validator API
|
||||
const response = await fetch(`https://rapid-email-verifier.fly.dev/verify/${encodeURIComponent(email)}`, {
|
||||
const response = await fetch(`https://rapid-email-verifier.fly.dev/api/validate?email=${encodeURIComponent(email)}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
@@ -33,15 +35,18 @@ export async function validateEmail(email: string): Promise<{
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.warn(`Email validation API error: ${response.status}`);
|
||||
// If API is down, allow the email (fallback to Zod validation only)
|
||||
return { valid: true };
|
||||
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: EmailValidationResult = await response.json();
|
||||
|
||||
// Check if email is disposable/temporary
|
||||
if (data.disposable) {
|
||||
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.',
|
||||
@@ -49,20 +54,26 @@ export async function validateEmail(email: string): Promise<{
|
||||
}
|
||||
|
||||
// Check if MX records exist (deliverable)
|
||||
if (data.mx === false) {
|
||||
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 email is generally valid
|
||||
if (!data.valid) {
|
||||
const suggestion = data.suggestion ? ` Meintest du vielleicht: ${data.suggestion}?` : '';
|
||||
// Check if domain exists
|
||||
if (!data.validations.domain_exists) {
|
||||
return {
|
||||
valid: false,
|
||||
reason: `Ungültige E-Mail-Adresse.${suggestion}`,
|
||||
suggestion: data.suggestion,
|
||||
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.',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -71,9 +82,11 @@ export async function validateEmail(email: string): Promise<{
|
||||
|
||||
} catch (error) {
|
||||
console.error('Email validation error:', error);
|
||||
// If validation fails, allow the email (fallback to Zod validation only)
|
||||
// This ensures the booking system continues to work even if the API is down
|
||||
return { valid: true };
|
||||
// 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.'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -21,10 +21,7 @@ rpcApp.all("/*", async (c) => {
|
||||
} catch (error) {
|
||||
console.error("RPC Handler error:", error);
|
||||
|
||||
// Preserve the original error message if it's a known error
|
||||
const errorMessage = error instanceof Error ? error.message : "Internal server error";
|
||||
const statusCode = error instanceof Error && error.message.includes("bereits eine Buchung") ? 400 : 500;
|
||||
|
||||
return c.json({ error: errorMessage }, statusCode);
|
||||
// Let oRPC handle errors properly
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
@@ -96,9 +96,12 @@ const create = os
|
||||
);
|
||||
}
|
||||
|
||||
// Deep email validation using Rapid Email Validator API
|
||||
// Email validation before slot reservation
|
||||
console.log(`Validating email: ${input.customerEmail}`);
|
||||
const emailValidation = await validateEmail(input.customerEmail);
|
||||
console.log(`Email validation result:`, emailValidation);
|
||||
if (!emailValidation.valid) {
|
||||
console.log(`Email validation failed: ${emailValidation.reason}`);
|
||||
throw new Error(emailValidation.reason || "Ungültige E-Mail-Adresse");
|
||||
}
|
||||
|
||||
@@ -118,8 +121,8 @@ const create = os
|
||||
}
|
||||
|
||||
// Prevent double booking: same customer email with pending/confirmed on same date
|
||||
// Skip duplicate check in development mode
|
||||
if (process.env.NODE_ENV !== 'development') {
|
||||
// Skip duplicate check when DISABLE_DUPLICATE_CHECK is set
|
||||
if (!process.env.DISABLE_DUPLICATE_CHECK) {
|
||||
const existing = await kv.getAllItems();
|
||||
const hasConflict = existing.some(b =>
|
||||
b.customerEmail.toLowerCase() === input.customerEmail.toLowerCase() &&
|
||||
@@ -137,7 +140,11 @@ const create = os
|
||||
status: "pending" as const,
|
||||
createdAt: new Date().toISOString()
|
||||
};
|
||||
// If a slotId is provided, tentatively reserve the slot (mark reserved but pending)
|
||||
|
||||
// First save the booking
|
||||
await kv.setItem(id, booking);
|
||||
|
||||
// Then reserve the slot only after successful booking creation
|
||||
if (booking.slotId) {
|
||||
const slot = await availabilityKV.getItem(booking.slotId);
|
||||
if (!slot) throw new Error("Availability slot not found");
|
||||
@@ -149,12 +156,11 @@ const create = os
|
||||
};
|
||||
await availabilityKV.setItem(slot.id, updatedSlot);
|
||||
}
|
||||
await kv.setItem(id, booking);
|
||||
|
||||
// Notify customer: request received (pending)
|
||||
void (async () => {
|
||||
// Create booking access token for status viewing
|
||||
const bookingAccessToken = await queryClient.cancellation.createToken({ input: { bookingId: id } });
|
||||
const bookingAccessToken = await queryClient.cancellation.createToken({ bookingId: id });
|
||||
const bookingUrl = generateUrl(`/booking/${bookingAccessToken.token}`);
|
||||
|
||||
const formattedDate = formatDateGerman(input.appointmentDate);
|
||||
@@ -224,6 +230,8 @@ const create = os
|
||||
return booking;
|
||||
} catch (error) {
|
||||
console.error("Booking creation error:", error);
|
||||
|
||||
// Re-throw the error for oRPC to handle
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
@@ -297,7 +305,7 @@ const updateStatus = os
|
||||
try {
|
||||
if (input.status === "confirmed") {
|
||||
// Create booking access token for this booking (status + cancellation)
|
||||
const bookingAccessToken = await queryClient.cancellation.createToken({ input: { bookingId: booking.id } });
|
||||
const bookingAccessToken = await queryClient.cancellation.createToken({ bookingId: booking.id });
|
||||
|
||||
const formattedDate = formatDateGerman(booking.appointmentDate);
|
||||
const bookingUrl = generateUrl(`/booking/${bookingAccessToken.token}`);
|
||||
|
Reference in New Issue
Block a user