Compare commits
4 Commits
c0b0edc00e
...
5baa231d3c
Author | SHA1 | Date | |
---|---|---|---|
5baa231d3c | |||
73cf733c5f | |||
f2e12df6d5 | |||
d663abb1ab |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -22,6 +22,7 @@ Thumbs.db
|
|||||||
*.njsproj
|
*.njsproj
|
||||||
*.sln
|
*.sln
|
||||||
*.sw?
|
*.sw?
|
||||||
|
.notes.txt
|
||||||
|
|
||||||
# Turbo
|
# Turbo
|
||||||
.turbo
|
.turbo
|
||||||
|
@@ -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
6
scripts/rebuild-dev.cmd
Normal 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
6
scripts/rebuild-dev.sh
Normal 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
|
@@ -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);
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
@@ -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.'
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -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;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
@@ -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,6 +121,8 @@ 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
|
||||||
|
// Skip duplicate check when DISABLE_DUPLICATE_CHECK is set
|
||||||
|
if (!process.env.DISABLE_DUPLICATE_CHECK) {
|
||||||
const existing = await kv.getAllItems();
|
const existing = await kv.getAllItems();
|
||||||
const hasConflict = existing.some(b =>
|
const hasConflict = existing.some(b =>
|
||||||
b.customerEmail.toLowerCase() === input.customerEmail.toLowerCase() &&
|
b.customerEmail.toLowerCase() === input.customerEmail.toLowerCase() &&
|
||||||
@@ -126,6 +132,7 @@ const create = os
|
|||||||
if (hasConflict) {
|
if (hasConflict) {
|
||||||
throw new Error("Du hast bereits eine Buchung für dieses Datum. Bitte wähle einen anderen Tag oder storniere zuerst.");
|
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 = {
|
||||||
id,
|
id,
|
||||||
@@ -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);
|
||||||
@@ -220,6 +230,8 @@ 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}`);
|
||||||
|
Reference in New Issue
Block a user