4 Commits

Author SHA1 Message Date
5baa231d3c 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
2025-10-02 13:39:13 +02:00
73cf733c5f Fix E-Mail-Versand und verbessere Fehlerbehandlung
- Behebe Port-Konfiguration für interne RPC-Verbindungen (5173 -> 3000)
- Verbessere oRPC-Fehlerbehandlung: ursprüngliche Fehlermeldungen werden beibehalten
- Erweitere Frontend-Fehlerbehandlung für bessere oRPC-Integration
- Deaktiviere Duplikat-Prüfung in Development-Modus (NODE_ENV=development)
- Lokale Entwicklung ermöglicht mehrere Buchungen pro E-Mail-Adresse
- Produktion behält Duplikat-Schutz bei
2025-10-02 10:01:01 +02:00
f2e12df6d5 Add rebuild script for Windows 2025-10-02 09:28:01 +02:00
d663abb1ab Add restart script 2025-10-02 08:52:11 +02:00
8 changed files with 94 additions and 41 deletions

1
.gitignore vendored
View File

@@ -22,6 +22,7 @@ Thumbs.db
*.njsproj *.njsproj
*.sln *.sln
*.sw? *.sw?
.notes.txt
# Turbo # Turbo
.turbo .turbo

View File

@@ -16,3 +16,4 @@ services:
start_period: 40s start_period: 40s
environment: environment:
- NODE_ENV=production - NODE_ENV=production
- DISABLE_DUPLICATE_CHECK=true

6
scripts/rebuild-dev.cmd Normal file
View File

@@ -0,0 +1,6 @@
@echo off
docker compose -f docker-compose.yml down
git pull
docker compose -f docker-compose.yml build
docker compose -f docker-compose.yml up -d
docker compose -f docker-compose.yml logs -f stargirlnails

6
scripts/rebuild-dev.sh Normal file
View File

@@ -0,0 +1,6 @@
#! /bin/bash
sudo docker compose -f docker-compose.yml down
git pull
sudo docker compose -f docker-compose.yml build --no-cache
sudo docker compose -f docker-compose.yml up -d
sudo docker compose -f docker-compose.yml logs -f stargirlnails

View File

@@ -138,7 +138,8 @@ export function BookingForm() {
if (fileInput) fileInput.value = ''; if (fileInput) fileInput.value = '';
}; };
const handleSubmit = (e: React.FormEvent) => {
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
setErrorMessage(""); // Clear any previous error messages setErrorMessage(""); // Clear any previous error messages
@@ -160,6 +161,8 @@ export function BookingForm() {
setErrorMessage("Bitte bestätige die Kenntnisnahme der Allgemeinen Geschäftsbedingungen."); setErrorMessage("Bitte bestätige die Kenntnisnahme der Allgemeinen Geschäftsbedingungen.");
return; return;
} }
// Email validation now handled in backend before slot reservation
const slot = availableSlots.find((s) => s.id === selectedSlotId); const slot = availableSlots.find((s) => s.id === selectedSlotId);
const appointmentTime = slot?.time || ""; const appointmentTime = slot?.time || "";
// console.log("Creating booking with data:", { // console.log("Creating booking with data:", {
@@ -205,7 +208,16 @@ export function BookingForm() {
}, },
onError: (error: any) => { onError: (error: any) => {
console.error("Booking error:", error); console.error("Booking error:", error);
const errorText = error?.message || error?.toString() || "Ein unbekannter Fehler ist aufgetreten.";
// Simple error handling for oRPC errors
let errorText = "Ein unbekannter Fehler ist aufgetreten.";
if (error?.cause?.message) {
errorText = error.cause.message;
} else if (error?.message && error.message !== "Internal server error") {
errorText = error.message;
}
setErrorMessage(errorText); setErrorMessage(errorText);
}, },
} }

View File

@@ -3,15 +3,17 @@
// Privacy-focused, no data storage, completely free // Privacy-focused, no data storage, completely free
type EmailValidationResult = { type EmailValidationResult = {
valid: boolean;
email: string; email: string;
domain?: string; validations: {
disposable?: boolean; syntax: boolean;
role?: boolean; domain_exists: boolean;
typo?: boolean; mx_records: boolean;
suggestion?: string; mailbox_exists: boolean;
mx?: boolean; is_disposable: boolean;
error?: string; is_role_based: boolean;
};
score: number;
status: string;
}; };
/** /**
@@ -25,7 +27,7 @@ export async function validateEmail(email: string): Promise<{
}> { }> {
try { try {
// Call Rapid Email Validator API // 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', method: 'GET',
headers: { headers: {
'Accept': 'application/json', 'Accept': 'application/json',
@@ -33,15 +35,18 @@ export async function validateEmail(email: string): Promise<{
}); });
if (!response.ok) { if (!response.ok) {
console.warn(`Email validation API error: ${response.status}`); console.error(`Email validation API error: ${response.status}`);
// If API is down, allow the email (fallback to Zod validation only) // If API is down, reject the email with error message
return { valid: true }; 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(); const data: EmailValidationResult = await response.json();
// Check if email is disposable/temporary // Check if email is disposable/temporary
if (data.disposable) { if (data.validations.is_disposable) {
return { return {
valid: false, valid: false,
reason: 'Temporäre oder Wegwerf-E-Mail-Adressen sind nicht erlaubt. Bitte verwende eine echte E-Mail-Adresse.', 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) // Check if MX records exist (deliverable)
if (data.mx === false) { if (!data.validations.mx_records) {
return { return {
valid: false, valid: false,
reason: 'Diese E-Mail-Adresse kann keine E-Mails empfangen. Bitte überprüfe deine E-Mail-Adresse.', reason: 'Diese E-Mail-Adresse kann keine E-Mails empfangen. Bitte überprüfe deine E-Mail-Adresse.',
}; };
} }
// Check if email is generally valid // Check if domain exists
if (!data.valid) { if (!data.validations.domain_exists) {
const suggestion = data.suggestion ? ` Meintest du vielleicht: ${data.suggestion}?` : '';
return { return {
valid: false, valid: false,
reason: `Ungültige E-Mail-Adresse.${suggestion}`, reason: 'Die E-Mail-Domain existiert nicht. Bitte überprüfe deine E-Mail-Adresse.',
suggestion: data.suggestion, };
}
// 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) { } catch (error) {
console.error('Email validation error:', error); console.error('Email validation error:', error);
// If validation fails, allow the email (fallback to Zod validation only) // If validation fails, reject the email with error message
// This ensures the booking system continues to work even if the API is down return {
return { valid: true }; valid: false,
reason: 'E-Mail-Validierung ist derzeit nicht verfügbar. Bitte überprüfe deine E-Mail-Adresse und versuche es erneut.'
};
} }
} }

View File

@@ -20,6 +20,8 @@ rpcApp.all("/*", async (c) => {
return c.json({ error: "Not found" }, 404); return c.json({ error: "Not found" }, 404);
} catch (error) { } catch (error) {
console.error("RPC Handler error:", error); console.error("RPC Handler error:", error);
return c.json({ error: "Internal server error" }, 500);
// Let oRPC handle errors properly
throw error;
} }
}); });

View File

@@ -12,7 +12,8 @@ import { checkBookingRateLimit, getClientIP } from "../lib/rate-limiter.js";
import { validateEmail } from "../lib/email-validator.js"; import { validateEmail } from "../lib/email-validator.js";
// Create a server-side client to call other RPC endpoints // Create a server-side client to call other RPC endpoints
const link = new RPCLink({ url: "http://localhost:5173/rpc" }); const serverPort = process.env.PORT ? parseInt(process.env.PORT) : 3000;
const link = new RPCLink({ url: `http://localhost:${serverPort}/rpc` });
// Typisierung über any, um Build-Inkompatibilität mit NestedClient zu vermeiden (nur für interne Server-Calls) // Typisierung über any, um Build-Inkompatibilität mit NestedClient zu vermeiden (nur für interne Server-Calls)
const queryClient = createORPCClient<any>(link); const queryClient = createORPCClient<any>(link);
@@ -95,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); const emailValidation = await validateEmail(input.customerEmail);
console.log(`Email validation result:`, emailValidation);
if (!emailValidation.valid) { if (!emailValidation.valid) {
console.log(`Email validation failed: ${emailValidation.reason}`);
throw new Error(emailValidation.reason || "Ungültige E-Mail-Adresse"); throw new Error(emailValidation.reason || "Ungültige E-Mail-Adresse");
} }
@@ -117,14 +121,17 @@ const create = os
} }
// Prevent double booking: same customer email with pending/confirmed on same date // Prevent double booking: same customer email with pending/confirmed on same date
const existing = await kv.getAllItems(); // Skip duplicate check when DISABLE_DUPLICATE_CHECK is set
const hasConflict = existing.some(b => if (!process.env.DISABLE_DUPLICATE_CHECK) {
b.customerEmail.toLowerCase() === input.customerEmail.toLowerCase() && const existing = await kv.getAllItems();
b.appointmentDate === input.appointmentDate && const hasConflict = existing.some(b =>
(b.status === "pending" || b.status === "confirmed") b.customerEmail.toLowerCase() === input.customerEmail.toLowerCase() &&
); b.appointmentDate === input.appointmentDate &&
if (hasConflict) { (b.status === "pending" || b.status === "confirmed")
throw new Error("Du hast bereits eine Buchung für dieses Datum. Bitte wähle einen anderen Tag oder storniere zuerst."); );
if (hasConflict) {
throw new Error("Du hast bereits eine Buchung für dieses Datum. Bitte wähle einen anderen Tag oder storniere zuerst.");
}
} }
const id = randomUUID(); const id = randomUUID();
const booking = { const booking = {
@@ -133,7 +140,11 @@ const create = os
status: "pending" as const, status: "pending" as const,
createdAt: new Date().toISOString() 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) { if (booking.slotId) {
const slot = await availabilityKV.getItem(booking.slotId); const slot = await availabilityKV.getItem(booking.slotId);
if (!slot) throw new Error("Availability slot not found"); if (!slot) throw new Error("Availability slot not found");
@@ -145,12 +156,11 @@ const create = os
}; };
await availabilityKV.setItem(slot.id, updatedSlot); await availabilityKV.setItem(slot.id, updatedSlot);
} }
await kv.setItem(id, booking);
// Notify customer: request received (pending) // Notify customer: request received (pending)
void (async () => { void (async () => {
// Create booking access token for status viewing // 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 bookingUrl = generateUrl(`/booking/${bookingAccessToken.token}`);
const formattedDate = formatDateGerman(input.appointmentDate); const formattedDate = formatDateGerman(input.appointmentDate);
@@ -218,8 +228,10 @@ const create = os
} }
})(); })();
return booking; return booking;
} catch (error) { } catch (error) {
console.error("Booking creation error:", error); console.error("Booking creation error:", error);
// Re-throw the error for oRPC to handle
throw error; throw error;
} }
}); });
@@ -293,7 +305,7 @@ const updateStatus = os
try { try {
if (input.status === "confirmed") { if (input.status === "confirmed") {
// Create booking access token for this booking (status + cancellation) // 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 formattedDate = formatDateGerman(booking.appointmentDate);
const bookingUrl = generateUrl(`/booking/${bookingAccessToken.token}`); const bookingUrl = generateUrl(`/booking/${bookingAccessToken.token}`);