Implementiere Stornierungssystem und E-Mail-Links zur Hauptseite

- Neues Stornierungssystem mit sicheren Token-basierten Links
- Stornierungsfrist konfigurierbar über MIN_STORNO_TIMESPAN (24h Standard)
- Stornierungs-Seite mit Buchungsdetails und Ein-Klick-Stornierung
- Automatische Slot-Freigabe bei Stornierung
- Stornierungs-Link in Bestätigungs-E-Mails integriert
- Alle E-Mails enthalten jetzt Links zur Hauptseite (DOMAIN Variable)
- Schöne HTML-Buttons und Text-Links in allen E-Mail-Templates
- Vollständige Validierung: Vergangenheits-Check, Token-Ablauf, Stornierungsfrist
- Responsive Stornierungs-Seite mit Loading-States und Fehlerbehandlung
- Dokumentation in README.md aktualisiert
This commit is contained in:
2025-09-30 17:48:03 +02:00
parent e5384e46ce
commit 55923e0426
13 changed files with 741 additions and 30 deletions

View File

@@ -5,6 +5,13 @@ import { createKV } from "@/server/lib/create-kv";
import { createKV as createAvailabilityKV } from "@/server/lib/create-kv";
import { sendEmail, sendEmailWithAGB, sendEmailWithInspirationPhoto } from "@/server/lib/email";
import { renderBookingPendingHTML, renderBookingConfirmedHTML, renderBookingCancelledHTML, renderAdminBookingNotificationHTML } from "@/server/lib/email-templates";
import { router } from "@/server/rpc";
import { createORPCClient } from "@orpc/client";
import { RPCLink } from "@orpc/client/fetch";
// Create a server-side client to call other RPC endpoints
const link = new RPCLink({ url: "http://localhost:5173/rpc" });
const queryClient = createORPCClient<typeof router>(link);
// Helper function to convert date from yyyy-mm-dd to dd.mm.yyyy
function formatDateGerman(dateString: string): string {
@@ -57,6 +64,27 @@ 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 {
// 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 =>
@@ -91,11 +119,14 @@ const create = os
// Notify customer: request received (pending)
void (async () => {
const formattedDate = formatDateGerman(input.appointmentDate);
const domain = process.env.DOMAIN || 'localhost:5173';
const protocol = domain.includes('localhost') ? 'http' : 'https';
const homepageUrl = `${protocol}://${domain}`;
const html = await renderBookingPendingHTML({ name: input.customerName, date: input.appointmentDate, time: input.appointmentTime });
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.\n\nLiebe Grüße\nStargirlnails Kiel`,
text: `Hallo ${input.customerName},\n\nwir haben deine Anfrage für ${formattedDate} um ${input.appointmentTime} erhalten. Wir bestätigen deinen Termin in Kürze.\n\nZur Website: ${homepageUrl}\n\nLiebe Grüße\nStargirlnails Kiel`,
html,
}).catch(() => {});
})();
@@ -119,6 +150,10 @@ const create = os
hasInspirationPhoto: !!input.inspirationPhoto
});
const domain = process.env.DOMAIN || 'localhost:5173';
const protocol = domain.includes('localhost') ? 'http' : 'https';
const homepageUrl = `${protocol}://${domain}`;
const adminText = `Neue Buchungsanfrage eingegangen:\n\n` +
`Name: ${input.customerName}\n` +
`Telefon: ${input.customerPhone}\n` +
@@ -127,6 +162,7 @@ const create = os
`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) {
@@ -146,13 +182,17 @@ const create = os
}
})();
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 = createAvailabilityKV<Session>("sessions");
const usersKV = createAvailabilityKV<User>("users");
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");
@@ -180,6 +220,8 @@ const updateStatus = os
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, {
@@ -187,6 +229,7 @@ const updateStatus = os
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") {
@@ -195,6 +238,7 @@ const updateStatus = os
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
@@ -204,6 +248,7 @@ const updateStatus = os
status: "reserved",
reservedByBookingId: booking.id,
});
// console.log(`Slot ${slot.id} confirmed as reserved`);
}
}
}
@@ -211,22 +256,40 @@ const updateStatus = os
// Email notifications on status changes
try {
if (input.status === "confirmed") {
// Create cancellation token for this booking
const cancellationToken = await queryClient.cancellation.createToken({ bookingId: booking.id });
const formattedDate = formatDateGerman(booking.appointmentDate);
const html = await renderBookingConfirmedHTML({ name: booking.customerName, date: booking.appointmentDate, time: booking.appointmentTime });
const cancellationUrl = `${process.env.FRONTEND_URL || 'http://localhost:5173'}/cancel/${cancellationToken.token}`;
const html = await renderBookingConfirmedHTML({
name: booking.customerName,
date: booking.appointmentDate,
time: booking.appointmentTime,
cancellationUrl
});
const domain = process.env.DOMAIN || 'localhost:5173';
const protocol = domain.includes('localhost') ? 'http' : 'https';
const homepageUrl = `${protocol}://${domain}`;
await sendEmailWithAGB({
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\nBis bald!\nStargirlnails Kiel`,
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\nFalls du den Termin stornieren möchtest, kannst du das hier tun: ${cancellationUrl}\n\nZur Website: ${homepageUrl}\n\nBis bald!\nStargirlnails Kiel`,
html,
cc: process.env.ADMIN_EMAIL ? [process.env.ADMIN_EMAIL] : undefined,
});
} else if (input.status === "cancelled") {
const formattedDate = formatDateGerman(booking.appointmentDate);
const domain = process.env.DOMAIN || 'localhost:5173';
const protocol = domain.includes('localhost') ? 'http' : 'https';
const homepageUrl = `${protocol}://${domain}`;
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\nLiebe Grüße\nStargirlnails Kiel`,
text: `Hallo ${booking.customerName},\n\nleider wurde dein Termin am ${formattedDate} um ${booking.appointmentTime} abgesagt. Bitte buche einen neuen Termin.\n\nZur Website: ${homepageUrl}\n\nLiebe Grüße\nStargirlnails Kiel`,
html,
cc: process.env.ADMIN_EMAIL ? [process.env.ADMIN_EMAIL] : undefined,
});