391 lines
16 KiB
TypeScript
391 lines
16 KiB
TypeScript
import { call, os } from "@orpc/server";
|
|
import { z } from "zod";
|
|
import { randomUUID } from "crypto";
|
|
import { createKV } from "../lib/create-kv";
|
|
import { createKV as createAvailabilityKV } from "../lib/create-kv";
|
|
import { sendEmail, sendEmailWithAGB, sendEmailWithAGBAndCalendar, sendEmailWithInspirationPhoto } from "../lib/email";
|
|
import { renderBookingPendingHTML, renderBookingConfirmedHTML, renderBookingCancelledHTML, renderAdminBookingNotificationHTML } from "../lib/email-templates";
|
|
import { router as rootRouter } from "./index";
|
|
import { createORPCClient } from "@orpc/client";
|
|
import { RPCLink } from "@orpc/client/fetch";
|
|
import { checkBookingRateLimit, getClientIP } from "../lib/rate-limiter";
|
|
import { validateEmail } from "../lib/email-validator";
|
|
|
|
// Create a server-side client to call other RPC endpoints
|
|
const link = new RPCLink({ url: "http://localhost:5173/rpc" });
|
|
// Typisierung über any, um Build-Inkompatibilität mit NestedClient zu vermeiden (nur für interne Server-Calls)
|
|
const queryClient = createORPCClient<any>(link);
|
|
|
|
// Helper function to convert date from yyyy-mm-dd to dd.mm.yyyy
|
|
function formatDateGerman(dateString: string): string {
|
|
const [year, month, day] = dateString.split('-');
|
|
return `${day}.${month}.${year}`;
|
|
}
|
|
|
|
// Helper function to generate URLs from DOMAIN environment variable
|
|
function generateUrl(path: string = ''): string {
|
|
const domain = process.env.DOMAIN || 'localhost:5173';
|
|
const protocol = domain.includes('localhost') ? 'http' : 'https';
|
|
return `${protocol}://${domain}${path}`;
|
|
}
|
|
|
|
const BookingSchema = z.object({
|
|
id: z.string(),
|
|
treatmentId: 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"]),
|
|
notes: z.string().optional(),
|
|
inspirationPhoto: z.string().optional(), // Base64 encoded image data
|
|
createdAt: z.string(),
|
|
slotId: z.string().optional(),
|
|
});
|
|
|
|
type Booking = z.output<typeof BookingSchema>;
|
|
|
|
const kv = createKV<Booking>("bookings");
|
|
type Availability = {
|
|
id: string;
|
|
date: string;
|
|
time: string;
|
|
durationMinutes: number;
|
|
status: "free" | "reserved";
|
|
reservedByBookingId?: string;
|
|
createdAt: string;
|
|
};
|
|
const availabilityKV = createAvailabilityKV<Availability>("availability");
|
|
|
|
// Import treatments KV for admin notifications
|
|
import { createKV as createTreatmentsKV } from "../lib/create-kv";
|
|
type Treatment = {
|
|
id: string;
|
|
name: string;
|
|
description: string;
|
|
price: number;
|
|
duration: number;
|
|
category: string;
|
|
createdAt: string;
|
|
};
|
|
const treatmentsKV = createTreatmentsKV<Treatment>("treatments");
|
|
|
|
const create = os
|
|
.input(BookingSchema.omit({ id: true, createdAt: true, status: true }))
|
|
.handler(async ({ input }) => {
|
|
// console.log("Booking create called with input:", {
|
|
// ...input,
|
|
// inspirationPhoto: input.inspirationPhoto ? `[${input.inspirationPhoto.length} chars]` : null
|
|
// });
|
|
|
|
try {
|
|
// Rate limiting check (ohne IP, falls Context-Header im Build nicht verfügbar sind)
|
|
const rateLimitResult = checkBookingRateLimit({
|
|
ip: undefined,
|
|
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) {
|
|
throw new Error("Buchungen für vergangene Termine sind nicht möglich.");
|
|
}
|
|
|
|
// For today's bookings, check if the time is not in the past
|
|
if (input.appointmentDate === today) {
|
|
const now = new Date();
|
|
const currentTime = `${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}`;
|
|
if (input.appointmentTime <= currentTime) {
|
|
throw new Error("Buchungen für vergangene Uhrzeiten sind nicht möglich.");
|
|
}
|
|
}
|
|
|
|
// Prevent double booking: same customer email with pending/confirmed on same date
|
|
const existing = await kv.getAllItems();
|
|
const hasConflict = existing.some(b =>
|
|
b.customerEmail.toLowerCase() === input.customerEmail.toLowerCase() &&
|
|
b.appointmentDate === input.appointmentDate &&
|
|
(b.status === "pending" || b.status === "confirmed")
|
|
);
|
|
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 booking = {
|
|
id,
|
|
...input,
|
|
status: "pending" as const,
|
|
createdAt: new Date().toISOString()
|
|
};
|
|
// If a slotId is provided, tentatively reserve the slot (mark reserved but pending)
|
|
if (booking.slotId) {
|
|
const slot = await availabilityKV.getItem(booking.slotId);
|
|
if (!slot) throw new Error("Availability slot not found");
|
|
if (slot.status !== "free") throw new Error("Slot not available");
|
|
const updatedSlot: Availability = {
|
|
...slot,
|
|
status: "reserved",
|
|
reservedByBookingId: id,
|
|
};
|
|
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 bookingUrl = generateUrl(`/booking/${bookingAccessToken.token}`);
|
|
|
|
const formattedDate = formatDateGerman(input.appointmentDate);
|
|
const homepageUrl = generateUrl();
|
|
const html = await renderBookingPendingHTML({
|
|
name: input.customerName,
|
|
date: input.appointmentDate,
|
|
time: input.appointmentTime,
|
|
statusUrl: bookingUrl
|
|
});
|
|
await sendEmail({
|
|
to: input.customerEmail,
|
|
subject: "Deine Terminanfrage ist eingegangen",
|
|
text: `Hallo ${input.customerName},\n\nwir haben deine Anfrage für ${formattedDate} um ${input.appointmentTime} erhalten. Wir bestätigen deinen Termin in Kürze. Du erhältst eine weitere E-Mail, sobald der Termin bestätigt ist.\n\nTermin-Status ansehen: ${bookingUrl}\n\nRechtliche Informationen: ${generateUrl('/legal')}\nZur Website: ${homepageUrl}\n\nLiebe Grüße\nStargirlnails Kiel`,
|
|
html,
|
|
}).catch(() => {});
|
|
})();
|
|
|
|
// Notify admin: new booking request (with photo if available)
|
|
void (async () => {
|
|
if (!process.env.ADMIN_EMAIL) return;
|
|
|
|
// Get treatment name from KV
|
|
const allTreatments = await treatmentsKV.getAllItems();
|
|
const treatment = allTreatments.find(t => t.id === input.treatmentId);
|
|
const treatmentName = treatment?.name || "Unbekannte Behandlung";
|
|
|
|
const adminHtml = await renderAdminBookingNotificationHTML({
|
|
name: input.customerName,
|
|
date: input.appointmentDate,
|
|
time: input.appointmentTime,
|
|
treatment: treatmentName,
|
|
phone: input.customerPhone,
|
|
notes: input.notes,
|
|
hasInspirationPhoto: !!input.inspirationPhoto
|
|
});
|
|
|
|
const homepageUrl = generateUrl();
|
|
|
|
const adminText = `Neue Buchungsanfrage eingegangen:\n\n` +
|
|
`Name: ${input.customerName}\n` +
|
|
`Telefon: ${input.customerPhone}\n` +
|
|
`Behandlung: ${treatmentName}\n` +
|
|
`Datum: ${formatDateGerman(input.appointmentDate)}\n` +
|
|
`Uhrzeit: ${input.appointmentTime}\n` +
|
|
`${input.notes ? `Notizen: ${input.notes}\n` : ''}` +
|
|
`Inspiration-Foto: ${input.inspirationPhoto ? 'Im Anhang verfügbar' : 'Kein Foto hochgeladen'}\n\n` +
|
|
`Zur Website: ${homepageUrl}\n\n` +
|
|
`Bitte logge dich in das Admin-Panel ein, um die Buchung zu bearbeiten.`;
|
|
|
|
if (input.inspirationPhoto) {
|
|
await sendEmailWithInspirationPhoto({
|
|
to: process.env.ADMIN_EMAIL,
|
|
subject: `Neue Buchungsanfrage - ${input.customerName}`,
|
|
text: adminText,
|
|
html: adminHtml,
|
|
}, input.inspirationPhoto, input.customerName).catch(() => {});
|
|
} else {
|
|
await sendEmail({
|
|
to: process.env.ADMIN_EMAIL,
|
|
subject: `Neue Buchungsanfrage - ${input.customerName}`,
|
|
text: adminText,
|
|
html: adminHtml,
|
|
}).catch(() => {});
|
|
}
|
|
})();
|
|
return booking;
|
|
} catch (error) {
|
|
console.error("Booking creation error:", error);
|
|
throw error;
|
|
}
|
|
});
|
|
|
|
// Owner check reuse (simple inline version)
|
|
type Session = { id: string; userId: string; expiresAt: string; createdAt: string };
|
|
type User = { id: string; username: string; email: string; passwordHash: string; role: "customer" | "owner"; createdAt: string };
|
|
const sessionsKV = createKV<Session>("sessions");
|
|
const usersKV = createKV<User>("users");
|
|
async function assertOwner(sessionId: string): Promise<void> {
|
|
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");
|
|
}
|
|
|
|
const updateStatus = os
|
|
.input(z.object({
|
|
sessionId: z.string(),
|
|
id: z.string(),
|
|
status: z.enum(["pending", "confirmed", "cancelled", "completed"])
|
|
}))
|
|
.handler(async ({ input }) => {
|
|
await assertOwner(input.sessionId);
|
|
const booking = await kv.getItem(input.id);
|
|
if (!booking) throw new Error("Booking not found");
|
|
|
|
const previousStatus = booking.status;
|
|
const updatedBooking = { ...booking, status: input.status };
|
|
await kv.setItem(input.id, updatedBooking);
|
|
|
|
// Manage availability slot state transitions
|
|
if (booking.slotId) {
|
|
const slot = await availabilityKV.getItem(booking.slotId);
|
|
if (slot) {
|
|
// console.log(`Updating slot ${slot.id} (${slot.date} ${slot.time}) from ${slot.status} to ${input.status}`);
|
|
|
|
if (input.status === "cancelled") {
|
|
// Free the slot again
|
|
await availabilityKV.setItem(slot.id, {
|
|
...slot,
|
|
status: "free",
|
|
reservedByBookingId: undefined,
|
|
});
|
|
// console.log(`Slot ${slot.id} freed due to cancellation`);
|
|
} else if (input.status === "pending") {
|
|
// keep reserved as pending
|
|
if (slot.status !== "reserved") {
|
|
await availabilityKV.setItem(slot.id, {
|
|
...slot,
|
|
status: "reserved",
|
|
reservedByBookingId: booking.id,
|
|
});
|
|
// console.log(`Slot ${slot.id} reserved for pending booking`);
|
|
}
|
|
} else if (input.status === "confirmed" || input.status === "completed") {
|
|
// keep reserved; optionally noop
|
|
if (slot.status !== "reserved" || slot.reservedByBookingId !== booking.id) {
|
|
await availabilityKV.setItem(slot.id, {
|
|
...slot,
|
|
status: "reserved",
|
|
reservedByBookingId: booking.id,
|
|
});
|
|
// console.log(`Slot ${slot.id} confirmed as reserved`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// Email notifications on status changes
|
|
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 formattedDate = formatDateGerman(booking.appointmentDate);
|
|
const bookingUrl = generateUrl(`/booking/${bookingAccessToken.token}`);
|
|
const homepageUrl = generateUrl();
|
|
|
|
const html = await renderBookingConfirmedHTML({
|
|
name: booking.customerName,
|
|
date: booking.appointmentDate,
|
|
time: booking.appointmentTime,
|
|
cancellationUrl: bookingUrl // Now points to booking status page
|
|
});
|
|
|
|
// 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\nTermin-Status ansehen und verwalten: ${bookingUrl}\nFalls du den Termin stornieren möchtest, kannst du das über den obigen Link tun.\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);
|
|
const homepageUrl = generateUrl();
|
|
const html = await renderBookingCancelledHTML({ name: booking.customerName, date: booking.appointmentDate, time: booking.appointmentTime });
|
|
await sendEmail({
|
|
to: booking.customerEmail,
|
|
subject: "Dein Termin wurde abgesagt",
|
|
text: `Hallo ${booking.customerName},\n\nleider wurde dein Termin am ${formattedDate} um ${booking.appointmentTime} abgesagt. Bitte buche einen neuen Termin.\n\nRechtliche Informationen: ${generateUrl('/legal')}\nZur Website: ${homepageUrl}\n\nLiebe Grüße\nStargirlnails Kiel`,
|
|
html,
|
|
cc: process.env.ADMIN_EMAIL ? [process.env.ADMIN_EMAIL] : undefined,
|
|
});
|
|
}
|
|
} catch (e) {
|
|
console.error("Email send failed:", e);
|
|
}
|
|
return updatedBooking;
|
|
});
|
|
|
|
const remove = os.input(z.string()).handler(async ({ input }) => {
|
|
await kv.removeItem(input);
|
|
});
|
|
|
|
const list = os.handler(async () => {
|
|
return kv.getAllItems();
|
|
});
|
|
|
|
const get = os.input(z.string()).handler(async ({ input }) => {
|
|
return kv.getItem(input);
|
|
});
|
|
|
|
const getByDate = os
|
|
.input(z.string()) // YYYY-MM-DD format
|
|
.handler(async ({ input }) => {
|
|
const allBookings = await kv.getAllItems();
|
|
return allBookings.filter(booking => booking.appointmentDate === input);
|
|
});
|
|
|
|
const live = {
|
|
list: os.handler(async function* ({ signal }) {
|
|
yield call(list, {}, { signal });
|
|
for await (const _ of kv.subscribe()) {
|
|
yield call(list, {}, { signal });
|
|
}
|
|
}),
|
|
|
|
byDate: os
|
|
.input(z.string())
|
|
.handler(async function* ({ input, signal }) {
|
|
yield call(getByDate, input, { signal });
|
|
for await (const _ of kv.subscribe()) {
|
|
yield call(getByDate, input, { signal });
|
|
}
|
|
}),
|
|
};
|
|
|
|
export const router = {
|
|
create,
|
|
updateStatus,
|
|
remove,
|
|
list,
|
|
get,
|
|
getByDate,
|
|
live,
|
|
}; |