808 lines
42 KiB
JavaScript
808 lines
42 KiB
JavaScript
import { call, os } from "@orpc/server";
|
||
import { z } from "zod";
|
||
import { randomUUID } from "crypto";
|
||
import { createKV } from "../lib/create-kv.js";
|
||
import { sendEmail, sendEmailWithAGBAndCalendar, sendEmailWithInspirationPhoto } from "../lib/email.js";
|
||
import { renderBookingPendingHTML, renderBookingConfirmedHTML, renderBookingCancelledHTML, renderAdminBookingNotificationHTML, renderBookingRescheduleProposalHTML, renderAdminRescheduleAcceptedHTML, renderAdminRescheduleDeclinedHTML, renderCustomerMessageHTML } from "../lib/email-templates.js";
|
||
import { createORPCClient } from "@orpc/client";
|
||
import { RPCLink } from "@orpc/client/fetch";
|
||
import { checkBookingRateLimit } from "../lib/rate-limiter.js";
|
||
import { validateEmail } from "../lib/email-validator.js";
|
||
// Create a server-side client to call other RPC endpoints
|
||
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)
|
||
const queryClient = createORPCClient(link);
|
||
// Helper function to convert date from yyyy-mm-dd to dd.mm.yyyy
|
||
function formatDateGerman(dateString) {
|
||
const [year, month, day] = dateString.split('-');
|
||
return `${day}.${month}.${year}`;
|
||
}
|
||
// Helper function to generate URLs from DOMAIN environment variable
|
||
function generateUrl(path = '') {
|
||
const domain = process.env.DOMAIN || 'localhost:5173';
|
||
const protocol = domain.includes('localhost') ? 'http' : 'https';
|
||
return `${protocol}://${domain}${path}`;
|
||
}
|
||
// Helper function to parse time string to minutes since midnight
|
||
function parseTime(timeStr) {
|
||
const [hours, minutes] = timeStr.split(':').map(Number);
|
||
return hours * 60 + minutes;
|
||
}
|
||
// Helper function to check if date is in time-off period
|
||
function isDateInTimeOffPeriod(date, periods) {
|
||
return periods.some(period => date >= period.startDate && date <= period.endDate);
|
||
}
|
||
// Helper function to validate booking time against recurring rules
|
||
async function validateBookingAgainstRules(date, time, treatmentDuration) {
|
||
// Parse date to get day of week
|
||
const [year, month, day] = date.split('-').map(Number);
|
||
const localDate = new Date(year, month - 1, day);
|
||
const dayOfWeek = localDate.getDay();
|
||
// Check time-off periods
|
||
const timeOffPeriods = await timeOffPeriodsKV.getAllItems();
|
||
if (isDateInTimeOffPeriod(date, timeOffPeriods)) {
|
||
throw new Error("Dieser Tag ist nicht verfügbar (Urlaubszeit).");
|
||
}
|
||
// Find matching recurring rules
|
||
const allRules = await recurringRulesKV.getAllItems();
|
||
const matchingRules = allRules.filter(rule => rule.isActive === true && rule.dayOfWeek === dayOfWeek);
|
||
if (matchingRules.length === 0) {
|
||
throw new Error("Für diesen Wochentag sind keine Termine verfügbar.");
|
||
}
|
||
// Check if booking time falls within any rule's time span
|
||
const bookingStartMinutes = parseTime(time);
|
||
const bookingEndMinutes = bookingStartMinutes + treatmentDuration;
|
||
const isWithinRules = matchingRules.some(rule => {
|
||
const ruleStartMinutes = parseTime(rule.startTime);
|
||
const ruleEndMinutes = parseTime(rule.endTime);
|
||
// Booking must start at or after rule start and end at or before rule end
|
||
return bookingStartMinutes >= ruleStartMinutes && bookingEndMinutes <= ruleEndMinutes;
|
||
});
|
||
if (!isWithinRules) {
|
||
throw new Error("Die gewählte Uhrzeit liegt außerhalb der verfügbaren Zeiten.");
|
||
}
|
||
}
|
||
// Helper function to check for booking conflicts
|
||
async function checkBookingConflicts(date, time, treatmentDuration, excludeBookingId) {
|
||
const allBookings = await kv.getAllItems();
|
||
const dateBookings = allBookings.filter(booking => booking.appointmentDate === date &&
|
||
['pending', 'confirmed', 'completed'].includes(booking.status) &&
|
||
booking.id !== excludeBookingId);
|
||
const bookingStartMinutes = parseTime(time);
|
||
const bookingEndMinutes = bookingStartMinutes + treatmentDuration;
|
||
// Cache treatment durations by ID to avoid N+1 lookups
|
||
const uniqueTreatmentIds = [...new Set(dateBookings.map(booking => booking.treatmentId))];
|
||
const treatmentDurationMap = new Map();
|
||
for (const treatmentId of uniqueTreatmentIds) {
|
||
const treatment = await treatmentsKV.getItem(treatmentId);
|
||
treatmentDurationMap.set(treatmentId, treatment?.duration || 60);
|
||
}
|
||
// Check for overlaps with existing bookings
|
||
for (const existingBooking of dateBookings) {
|
||
// Use cached duration or fallback to bookedDurationMinutes if available
|
||
let existingDuration = treatmentDurationMap.get(existingBooking.treatmentId) || 60;
|
||
if (existingBooking.bookedDurationMinutes) {
|
||
existingDuration = existingBooking.bookedDurationMinutes;
|
||
}
|
||
const existingStartMinutes = parseTime(existingBooking.appointmentTime);
|
||
const existingEndMinutes = existingStartMinutes + existingDuration;
|
||
// Check overlap: bookingStart < existingEnd && bookingEnd > existingStart
|
||
if (bookingStartMinutes < existingEndMinutes && bookingEndMinutes > existingStartMinutes) {
|
||
throw new Error("Dieser Zeitslot ist bereits gebucht. Bitte wähle eine andere Zeit.");
|
||
}
|
||
}
|
||
}
|
||
const CreateBookingInputSchema = z.object({
|
||
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").optional(),
|
||
appointmentDate: z.string(), // ISO date string
|
||
appointmentTime: z.string(), // HH:MM format
|
||
notes: z.string().optional(),
|
||
inspirationPhoto: z.string().optional(), // Base64 encoded image data
|
||
});
|
||
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").optional(),
|
||
customerPhone: z.string().min(5, "Telefonnummer muss mindestens 5 Zeichen lang sein").optional(),
|
||
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
|
||
bookedDurationMinutes: z.number().optional(), // Snapshot of treatment duration at booking time
|
||
createdAt: z.string(),
|
||
// DEPRECATED: slotId is no longer used for validation, kept for backward compatibility
|
||
slotId: z.string().optional(),
|
||
});
|
||
const kv = createKV("bookings");
|
||
const recurringRulesKV = createKV("recurringRules");
|
||
const timeOffPeriodsKV = createKV("timeOffPeriods");
|
||
const treatmentsKV = createKV("treatments");
|
||
const create = os
|
||
.input(CreateBookingInputSchema)
|
||
.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.`);
|
||
}
|
||
// 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");
|
||
}
|
||
// Validate appointment time is on 15-minute grid
|
||
const appointmentMinutes = parseTime(input.appointmentTime);
|
||
if (appointmentMinutes % 15 !== 0) {
|
||
throw new Error("Termine müssen auf 15-Minuten-Raster ausgerichtet sein (z.B. 09:00, 09:15, 09:30, 09:45).");
|
||
}
|
||
// 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
|
||
// 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 && 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.");
|
||
}
|
||
}
|
||
// Get treatment duration for validation
|
||
const treatment = await treatmentsKV.getItem(input.treatmentId);
|
||
if (!treatment) {
|
||
throw new Error("Behandlung nicht gefunden.");
|
||
}
|
||
// Validate booking time against recurring rules
|
||
await validateBookingAgainstRules(input.appointmentDate, input.appointmentTime, treatment.duration);
|
||
// Check for booking conflicts
|
||
await checkBookingConflicts(input.appointmentDate, input.appointmentTime, treatment.duration);
|
||
const id = randomUUID();
|
||
const booking = {
|
||
id,
|
||
...input,
|
||
bookedDurationMinutes: treatment.duration, // Snapshot treatment duration
|
||
status: "pending",
|
||
createdAt: new Date().toISOString()
|
||
};
|
||
// Save the booking
|
||
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({ 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 || "Nicht angegeben",
|
||
notes: input.notes,
|
||
hasInspirationPhoto: !!input.inspirationPhoto
|
||
});
|
||
const homepageUrl = generateUrl();
|
||
const adminText = `Neue Buchungsanfrage eingegangen:\n\n` +
|
||
`Name: ${input.customerName}\n` +
|
||
`Telefon: ${input.customerPhone || "Nicht angegeben"}\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);
|
||
// Re-throw the error for oRPC to handle
|
||
throw error;
|
||
}
|
||
});
|
||
const sessionsKV = createKV("sessions");
|
||
const usersKV = createKV("users");
|
||
async function assertOwner(sessionId) {
|
||
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);
|
||
// Note: Slot state management removed - bookings now validated against recurring rules
|
||
// 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({ 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
|
||
reviewUrl: generateUrl(`/review/${bookingAccessToken.token}`)
|
||
});
|
||
// 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";
|
||
// Use bookedDurationMinutes if available, otherwise fallback to treatment duration
|
||
const treatmentDuration = booking.bookedDurationMinutes || treatment?.duration || 60;
|
||
if (booking.customerEmail) {
|
||
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,
|
||
bcc: 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 });
|
||
if (booking.customerEmail) {
|
||
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,
|
||
bcc: process.env.ADMIN_EMAIL ? [process.env.ADMIN_EMAIL] : undefined,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
catch (e) {
|
||
console.error("Email send failed:", e);
|
||
}
|
||
return updatedBooking;
|
||
});
|
||
const remove = os
|
||
.input(z.object({
|
||
sessionId: z.string(),
|
||
id: z.string(),
|
||
sendEmail: z.boolean().optional().default(false)
|
||
}))
|
||
.handler(async ({ input }) => {
|
||
await assertOwner(input.sessionId);
|
||
const booking = await kv.getItem(input.id);
|
||
if (!booking)
|
||
throw new Error("Booking not found");
|
||
// Guard against deletion of past bookings or completed bookings
|
||
const today = new Date().toISOString().split("T")[0];
|
||
const isPastDate = booking.appointmentDate < today;
|
||
const isCompleted = booking.status === 'completed';
|
||
if (isPastDate || isCompleted) {
|
||
// For past/completed bookings, disable email sending to avoid confusing customers
|
||
if (input.sendEmail) {
|
||
console.log(`Email sending disabled for past/completed booking ${input.id}`);
|
||
}
|
||
input.sendEmail = false;
|
||
}
|
||
const wasAlreadyCancelled = booking.status === 'cancelled';
|
||
const updatedBooking = { ...booking, status: "cancelled" };
|
||
await kv.setItem(input.id, updatedBooking);
|
||
if (input.sendEmail && !wasAlreadyCancelled && booking.customerEmail) {
|
||
try {
|
||
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,
|
||
bcc: process.env.ADMIN_EMAIL ? [process.env.ADMIN_EMAIL] : undefined,
|
||
});
|
||
}
|
||
catch (e) {
|
||
console.error("Email send failed:", e);
|
||
}
|
||
}
|
||
return updatedBooking;
|
||
});
|
||
// Admin-only manual booking creation (immediately confirmed)
|
||
const createManual = os
|
||
.input(z.object({
|
||
sessionId: 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").optional(),
|
||
customerPhone: z.string().min(5, "Telefonnummer muss mindestens 5 Zeichen lang sein").optional(),
|
||
appointmentDate: z.string(),
|
||
appointmentTime: z.string(),
|
||
notes: z.string().optional(),
|
||
}))
|
||
.handler(async ({ input }) => {
|
||
// Admin authentication
|
||
await assertOwner(input.sessionId);
|
||
// Validate appointment time is on 15-minute grid
|
||
const appointmentMinutes = parseTime(input.appointmentTime);
|
||
if (appointmentMinutes % 15 !== 0) {
|
||
throw new Error("Termine müssen auf 15-Minuten-Raster ausgerichtet sein (z.B. 09:00, 09:15, 09:30, 09:45).");
|
||
}
|
||
// Validate that the booking is not in the past
|
||
const today = new Date().toISOString().split("T")[0];
|
||
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.");
|
||
}
|
||
}
|
||
// Get treatment duration for validation
|
||
const treatment = await treatmentsKV.getItem(input.treatmentId);
|
||
if (!treatment) {
|
||
throw new Error("Behandlung nicht gefunden.");
|
||
}
|
||
// Validate booking time against recurring rules
|
||
await validateBookingAgainstRules(input.appointmentDate, input.appointmentTime, treatment.duration);
|
||
// Check for booking conflicts
|
||
await checkBookingConflicts(input.appointmentDate, input.appointmentTime, treatment.duration);
|
||
const id = randomUUID();
|
||
const booking = {
|
||
id,
|
||
treatmentId: input.treatmentId,
|
||
customerName: input.customerName,
|
||
customerEmail: input.customerEmail,
|
||
customerPhone: input.customerPhone,
|
||
appointmentDate: input.appointmentDate,
|
||
appointmentTime: input.appointmentTime,
|
||
notes: input.notes,
|
||
bookedDurationMinutes: treatment.duration,
|
||
status: "confirmed",
|
||
createdAt: new Date().toISOString()
|
||
};
|
||
// Save the booking
|
||
await kv.setItem(id, booking);
|
||
// Create booking access token for status viewing and cancellation (always create token)
|
||
const bookingAccessToken = await queryClient.cancellation.createToken({ bookingId: id });
|
||
// Send confirmation email if email is provided
|
||
if (input.customerEmail) {
|
||
void (async () => {
|
||
try {
|
||
const bookingUrl = generateUrl(`/booking/${bookingAccessToken.token}`);
|
||
const formattedDate = formatDateGerman(input.appointmentDate);
|
||
const homepageUrl = generateUrl();
|
||
const html = await renderBookingConfirmedHTML({
|
||
name: input.customerName,
|
||
date: input.appointmentDate,
|
||
time: input.appointmentTime,
|
||
cancellationUrl: bookingUrl,
|
||
reviewUrl: generateUrl(`/review/${bookingAccessToken.token}`)
|
||
});
|
||
await sendEmailWithAGBAndCalendar({
|
||
to: input.customerEmail,
|
||
subject: "Dein Termin wurde bestätigt - AGB im Anhang",
|
||
text: `Hallo ${input.customerName},\n\nwir haben deinen Termin am ${formattedDate} um ${input.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,
|
||
}, {
|
||
date: input.appointmentDate,
|
||
time: input.appointmentTime,
|
||
durationMinutes: treatment.duration,
|
||
customerName: input.customerName,
|
||
treatmentName: treatment.name
|
||
});
|
||
}
|
||
catch (e) {
|
||
console.error("Email send failed for manual booking:", e);
|
||
}
|
||
})();
|
||
}
|
||
// Optionally return the token in the RPC response for UI to copy/share (admin usage only)
|
||
return {
|
||
...booking,
|
||
bookingAccessToken: bookingAccessToken.token
|
||
};
|
||
});
|
||
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,
|
||
createManual,
|
||
updateStatus,
|
||
remove,
|
||
list,
|
||
get,
|
||
getByDate,
|
||
live,
|
||
// Admin proposes a reschedule for a confirmed booking
|
||
proposeReschedule: os
|
||
.input(z.object({
|
||
sessionId: z.string(),
|
||
bookingId: z.string(),
|
||
proposedDate: z.string(),
|
||
proposedTime: z.string(),
|
||
}))
|
||
.handler(async ({ input }) => {
|
||
await assertOwner(input.sessionId);
|
||
const booking = await kv.getItem(input.bookingId);
|
||
if (!booking)
|
||
throw new Error("Booking not found");
|
||
if (booking.status !== "confirmed")
|
||
throw new Error("Nur bestätigte Termine können umgebucht werden.");
|
||
const treatment = await treatmentsKV.getItem(booking.treatmentId);
|
||
if (!treatment)
|
||
throw new Error("Behandlung nicht gefunden.");
|
||
// Validate grid and not in past
|
||
const appointmentMinutes = parseTime(input.proposedTime);
|
||
if (appointmentMinutes % 15 !== 0) {
|
||
throw new Error("Termine müssen auf 15-Minuten-Raster ausgerichtet sein (z.B. 09:00, 09:15, 09:30, 09:45).");
|
||
}
|
||
const today = new Date().toISOString().split("T")[0];
|
||
if (input.proposedDate < today) {
|
||
throw new Error("Buchungen für vergangene Termine sind nicht möglich.");
|
||
}
|
||
if (input.proposedDate === today) {
|
||
const now = new Date();
|
||
const currentTime = `${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}`;
|
||
if (input.proposedTime <= currentTime) {
|
||
throw new Error("Buchungen für vergangene Uhrzeiten sind nicht möglich.");
|
||
}
|
||
}
|
||
await validateBookingAgainstRules(input.proposedDate, input.proposedTime, booking.bookedDurationMinutes || treatment.duration);
|
||
await checkBookingConflicts(input.proposedDate, input.proposedTime, booking.bookedDurationMinutes || treatment.duration, booking.id);
|
||
// Invalidate and create new reschedule token via cancellation router
|
||
const res = await queryClient.cancellation.createRescheduleToken({
|
||
bookingId: booking.id,
|
||
proposedDate: input.proposedDate,
|
||
proposedTime: input.proposedTime,
|
||
});
|
||
const acceptUrl = generateUrl(`/booking/${res.token}?action=accept`);
|
||
const declineUrl = generateUrl(`/booking/${res.token}?action=decline`);
|
||
// Send proposal email to customer
|
||
if (booking.customerEmail) {
|
||
const html = await renderBookingRescheduleProposalHTML({
|
||
name: booking.customerName,
|
||
originalDate: booking.appointmentDate,
|
||
originalTime: booking.appointmentTime,
|
||
proposedDate: input.proposedDate,
|
||
proposedTime: input.proposedTime,
|
||
treatmentName: (await treatmentsKV.getItem(booking.treatmentId))?.name || "Behandlung",
|
||
acceptUrl,
|
||
declineUrl,
|
||
expiresAt: res.expiresAt,
|
||
});
|
||
await sendEmail({
|
||
to: booking.customerEmail,
|
||
subject: "Vorschlag zur Terminänderung",
|
||
text: `Hallo ${booking.customerName}, wir schlagen vor, deinen Termin von ${formatDateGerman(booking.appointmentDate)} ${booking.appointmentTime} auf ${formatDateGerman(input.proposedDate)} ${input.proposedTime} zu verschieben. Akzeptieren: ${acceptUrl} | Ablehnen: ${declineUrl}`,
|
||
html,
|
||
bcc: process.env.ADMIN_EMAIL ? [process.env.ADMIN_EMAIL] : undefined,
|
||
}).catch(() => { });
|
||
}
|
||
return { success: true, token: res.token };
|
||
}),
|
||
// Customer accepts reschedule via token
|
||
acceptReschedule: os
|
||
.input(z.object({ token: z.string() }))
|
||
.handler(async ({ input }) => {
|
||
const proposal = await queryClient.cancellation.getRescheduleProposal({ token: input.token });
|
||
const booking = await kv.getItem(proposal.booking.id);
|
||
if (!booking)
|
||
throw new Error("Booking not found");
|
||
if (booking.status !== "confirmed")
|
||
throw new Error("Buchung ist nicht mehr in bestätigtem Zustand.");
|
||
const treatment = await treatmentsKV.getItem(booking.treatmentId);
|
||
const duration = booking.bookedDurationMinutes || treatment?.duration || 60;
|
||
// Re-validate slot to ensure still available
|
||
await validateBookingAgainstRules(proposal.proposed.date, proposal.proposed.time, duration);
|
||
await checkBookingConflicts(proposal.proposed.date, proposal.proposed.time, duration, booking.id);
|
||
const updated = { ...booking, appointmentDate: proposal.proposed.date, appointmentTime: proposal.proposed.time };
|
||
await kv.setItem(updated.id, updated);
|
||
// Remove token
|
||
await queryClient.cancellation.removeRescheduleToken({ token: input.token });
|
||
// Send confirmation to customer (no BCC to avoid duplicate admin emails)
|
||
if (updated.customerEmail) {
|
||
const bookingAccessToken = await queryClient.cancellation.createToken({ bookingId: updated.id });
|
||
const html = await renderBookingConfirmedHTML({
|
||
name: updated.customerName,
|
||
date: updated.appointmentDate,
|
||
time: updated.appointmentTime,
|
||
cancellationUrl: generateUrl(`/booking/${bookingAccessToken.token}`),
|
||
reviewUrl: generateUrl(`/review/${bookingAccessToken.token}`),
|
||
});
|
||
await sendEmailWithAGBAndCalendar({
|
||
to: updated.customerEmail,
|
||
subject: "Terminänderung bestätigt",
|
||
text: `Hallo ${updated.customerName}, dein neuer Termin ist am ${formatDateGerman(updated.appointmentDate)} um ${updated.appointmentTime}.`,
|
||
html,
|
||
}, {
|
||
date: updated.appointmentDate,
|
||
time: updated.appointmentTime,
|
||
durationMinutes: duration,
|
||
customerName: updated.customerName,
|
||
treatmentName: (await treatmentsKV.getItem(updated.treatmentId))?.name || "Behandlung",
|
||
}).catch(() => { });
|
||
}
|
||
if (process.env.ADMIN_EMAIL) {
|
||
const adminHtml = await renderAdminRescheduleAcceptedHTML({
|
||
customerName: updated.customerName,
|
||
originalDate: proposal.original.date,
|
||
originalTime: proposal.original.time,
|
||
newDate: updated.appointmentDate,
|
||
newTime: updated.appointmentTime,
|
||
treatmentName: (await treatmentsKV.getItem(updated.treatmentId))?.name || "Behandlung",
|
||
});
|
||
await sendEmail({
|
||
to: process.env.ADMIN_EMAIL,
|
||
subject: `Reschedule akzeptiert - ${updated.customerName}`,
|
||
text: `Reschedule akzeptiert: ${updated.customerName} von ${formatDateGerman(proposal.original.date)} ${proposal.original.time} auf ${formatDateGerman(updated.appointmentDate)} ${updated.appointmentTime}.`,
|
||
html: adminHtml,
|
||
}).catch(() => { });
|
||
}
|
||
return { success: true, message: `Termin auf ${formatDateGerman(updated.appointmentDate)} um ${updated.appointmentTime} aktualisiert.` };
|
||
}),
|
||
// Customer declines reschedule via token
|
||
declineReschedule: os
|
||
.input(z.object({ token: z.string() }))
|
||
.handler(async ({ input }) => {
|
||
const proposal = await queryClient.cancellation.getRescheduleProposal({ token: input.token });
|
||
const booking = await kv.getItem(proposal.booking.id);
|
||
if (!booking)
|
||
throw new Error("Booking not found");
|
||
// Remove token
|
||
await queryClient.cancellation.removeRescheduleToken({ token: input.token });
|
||
// Notify customer that original stays
|
||
if (booking.customerEmail) {
|
||
const bookingAccessToken = await queryClient.cancellation.createToken({ bookingId: booking.id });
|
||
await sendEmail({
|
||
to: booking.customerEmail,
|
||
subject: "Terminänderung abgelehnt",
|
||
text: `Du hast den Vorschlag zur Terminänderung abgelehnt. Dein ursprünglicher Termin am ${formatDateGerman(booking.appointmentDate)} um ${booking.appointmentTime} bleibt bestehen.`,
|
||
html: await renderBookingConfirmedHTML({ name: booking.customerName, date: booking.appointmentDate, time: booking.appointmentTime, cancellationUrl: generateUrl(`/booking/${bookingAccessToken.token}`), reviewUrl: generateUrl(`/review/${bookingAccessToken.token}`) }),
|
||
}).catch(() => { });
|
||
}
|
||
// Notify admin
|
||
if (process.env.ADMIN_EMAIL) {
|
||
const html = await renderAdminRescheduleDeclinedHTML({
|
||
customerName: booking.customerName,
|
||
originalDate: proposal.original.date,
|
||
originalTime: proposal.original.time,
|
||
proposedDate: proposal.proposed.date,
|
||
proposedTime: proposal.proposed.time,
|
||
treatmentName: (await treatmentsKV.getItem(booking.treatmentId))?.name || "Behandlung",
|
||
customerEmail: booking.customerEmail,
|
||
customerPhone: booking.customerPhone,
|
||
});
|
||
await sendEmail({
|
||
to: process.env.ADMIN_EMAIL,
|
||
subject: `Reschedule abgelehnt - ${booking.customerName}`,
|
||
text: `Abgelehnt: ${booking.customerName}. Ursprünglich: ${formatDateGerman(proposal.original.date)} ${proposal.original.time}. Vorschlag: ${formatDateGerman(proposal.proposed.date)} ${proposal.proposed.time}.`,
|
||
html,
|
||
}).catch(() => { });
|
||
}
|
||
return { success: true, message: "Du hast den Vorschlag abgelehnt. Dein ursprünglicher Termin bleibt bestehen." };
|
||
}),
|
||
// CalDAV Token für Admin generieren
|
||
generateCalDAVToken: os
|
||
.input(z.object({ sessionId: z.string() }))
|
||
.handler(async ({ input }) => {
|
||
await assertOwner(input.sessionId);
|
||
// Generiere einen sicheren Token für CalDAV-Zugriff
|
||
const token = randomUUID();
|
||
// Hole Session-Daten für Token-Erstellung
|
||
const session = await sessionsKV.getItem(input.sessionId);
|
||
if (!session)
|
||
throw new Error("Session nicht gefunden");
|
||
// Speichere Token mit Ablaufzeit (24 Stunden)
|
||
const tokenData = {
|
||
id: token,
|
||
sessionId: input.sessionId,
|
||
userId: session.userId, // Benötigt für Session-Typ
|
||
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), // 24 Stunden
|
||
createdAt: new Date().toISOString(),
|
||
};
|
||
// Verwende den sessionsKV Store für Token-Speicherung
|
||
await sessionsKV.setItem(token, tokenData);
|
||
const domain = process.env.DOMAIN || 'localhost:3000';
|
||
const protocol = domain.includes('localhost') ? 'http' : 'https';
|
||
const caldavUrl = `${protocol}://${domain}/caldav/calendar/events.ics?token=${token}`;
|
||
return {
|
||
token,
|
||
caldavUrl,
|
||
expiresAt: tokenData.expiresAt,
|
||
instructions: {
|
||
title: "CalDAV-Kalender abonnieren",
|
||
steps: [
|
||
"Kopiere die CalDAV-URL unten",
|
||
"Füge sie in deiner Kalender-App als Abonnement hinzu:",
|
||
"- Outlook: Datei → Konto hinzufügen → Internetkalender",
|
||
"- Google Calendar: Andere Kalender hinzufügen → Von URL",
|
||
"- Apple Calendar: Abonnement → Neue Abonnements",
|
||
"- Thunderbird: Kalender hinzufügen → Im Netzwerk",
|
||
"Der Kalender wird automatisch aktualisiert"
|
||
],
|
||
note: "Dieser Token ist 24 Stunden gültig. Bei Bedarf kannst du einen neuen Token generieren."
|
||
}
|
||
};
|
||
}),
|
||
// Admin sendet Nachricht an Kunden
|
||
sendCustomerMessage: os
|
||
.input(z.object({
|
||
sessionId: z.string(),
|
||
bookingId: z.string(),
|
||
message: z.string().min(1, "Nachricht darf nicht leer sein").max(5000, "Nachricht ist zu lang (max. 5000 Zeichen)"),
|
||
}))
|
||
.handler(async ({ input }) => {
|
||
await assertOwner(input.sessionId);
|
||
const booking = await kv.getItem(input.bookingId);
|
||
if (!booking)
|
||
throw new Error("Buchung nicht gefunden");
|
||
// Check if booking has customer email
|
||
if (!booking.customerEmail) {
|
||
throw new Error("Diese Buchung hat keine E-Mail-Adresse. Bitte kontaktiere den Kunden telefonisch.");
|
||
}
|
||
// Check if booking is in the future
|
||
const today = new Date().toISOString().split("T")[0];
|
||
const bookingDate = booking.appointmentDate;
|
||
if (bookingDate < today) {
|
||
throw new Error("Nachrichten können nur für zukünftige Termine gesendet werden.");
|
||
}
|
||
// Get treatment name for context
|
||
const treatment = await treatmentsKV.getItem(booking.treatmentId);
|
||
const treatmentName = treatment?.name || "Behandlung";
|
||
// Prepare email with Reply-To header
|
||
const ownerName = process.env.OWNER_NAME || "Stargirlnails Kiel";
|
||
const emailFrom = process.env.EMAIL_FROM || "Stargirlnails <no-reply@stargirlnails.de>";
|
||
const replyToEmail = process.env.ADMIN_EMAIL;
|
||
const formattedDate = formatDateGerman(bookingDate);
|
||
const html = await renderCustomerMessageHTML({
|
||
customerName: booking.customerName,
|
||
message: input.message,
|
||
appointmentDate: bookingDate,
|
||
appointmentTime: booking.appointmentTime,
|
||
treatmentName: treatmentName,
|
||
});
|
||
const textContent = `Hallo ${booking.customerName},\n\nZu deinem Termin:\nBehandlung: ${treatmentName}\nDatum: ${formattedDate}\nUhrzeit: ${booking.appointmentTime}\n\nNachricht von ${ownerName}:\n${input.message}\n\nBei Fragen oder Anliegen kannst du einfach auf diese E-Mail antworten – wir helfen dir gerne weiter!\n\nLiebe Grüße,\n${ownerName}`;
|
||
// Send email with BCC to admin for monitoring
|
||
// Note: Not using explicit 'from' or 'replyTo' to match behavior of other system emails
|
||
console.log(`Sending customer message to ${booking.customerEmail} for booking ${input.bookingId}`);
|
||
console.log(`Email config: from=${emailFrom}, replyTo=${replyToEmail}, bcc=${replyToEmail}`);
|
||
const emailResult = await sendEmail({
|
||
to: booking.customerEmail,
|
||
subject: `Nachricht zu deinem Termin am ${formattedDate}`,
|
||
text: textContent,
|
||
html: html,
|
||
bcc: replyToEmail ? [replyToEmail] : undefined,
|
||
});
|
||
if (!emailResult.success) {
|
||
console.error(`Failed to send customer message to ${booking.customerEmail}`);
|
||
throw new Error("E-Mail konnte nicht versendet werden. Bitte überprüfe die E-Mail-Konfiguration oder versuche es später erneut.");
|
||
}
|
||
console.log(`Successfully sent customer message to ${booking.customerEmail}`);
|
||
return {
|
||
success: true,
|
||
message: `Nachricht wurde erfolgreich an ${booking.customerEmail} gesendet.`
|
||
};
|
||
}),
|
||
};
|