Initial commit: Kalender, Buchungen mit Slot-Reservierung, Resend-E-Mails, Admin-UI, Startscript
This commit is contained in:
@@ -2,6 +2,9 @@ import { call, os } from "@orpc/server";
|
||||
import { z } from "zod";
|
||||
import { randomUUID } from "crypto";
|
||||
import { createKV } from "@/server/lib/create-kv";
|
||||
import { createKV as createAvailabilityKV } from "@/server/lib/create-kv";
|
||||
import { sendEmail } from "@/server/lib/email";
|
||||
import { renderBookingPendingHTML, renderBookingConfirmedHTML, renderBookingCancelledHTML } from "@/server/lib/email-templates";
|
||||
|
||||
const BookingSchema = z.object({
|
||||
id: z.string(),
|
||||
@@ -14,15 +17,36 @@ const BookingSchema = z.object({
|
||||
status: z.enum(["pending", "confirmed", "cancelled", "completed"]),
|
||||
notes: z.string().optional(),
|
||||
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");
|
||||
|
||||
const create = os
|
||||
.input(BookingSchema.omit({ id: true, createdAt: true, status: true }))
|
||||
.handler(async ({ input }) => {
|
||||
// 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,
|
||||
@@ -30,21 +54,118 @@ const create = os
|
||||
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 () => {
|
||||
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 ${input.appointmentDate} um ${input.appointmentTime} erhalten. Wir bestätigen deinen Termin in Kürze.\n\nLiebe Grüße\nStargirlnails Kiel`,
|
||||
html,
|
||||
cc: process.env.ADMIN_EMAIL ? [process.env.ADMIN_EMAIL] : undefined,
|
||||
}).catch(() => {});
|
||||
})();
|
||||
return booking;
|
||||
});
|
||||
|
||||
// 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");
|
||||
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) {
|
||||
if (input.status === "cancelled") {
|
||||
// Free the slot again
|
||||
await availabilityKV.setItem(slot.id, {
|
||||
...slot,
|
||||
status: "free",
|
||||
reservedByBookingId: undefined,
|
||||
});
|
||||
} else if (input.status === "pending") {
|
||||
// keep reserved as pending
|
||||
if (slot.status !== "reserved") {
|
||||
await availabilityKV.setItem(slot.id, {
|
||||
...slot,
|
||||
status: "reserved",
|
||||
reservedByBookingId: booking.id,
|
||||
});
|
||||
}
|
||||
} 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Email notifications on status changes
|
||||
try {
|
||||
if (input.status === "confirmed") {
|
||||
const html = await renderBookingConfirmedHTML({ name: booking.customerName, date: booking.appointmentDate, time: booking.appointmentTime });
|
||||
await sendEmail({
|
||||
to: booking.customerEmail,
|
||||
subject: "Dein Termin wurde bestätigt",
|
||||
text: `Hallo ${booking.customerName},\n\nwir haben deinen Termin am ${booking.appointmentDate} um ${booking.appointmentTime} bestätigt.\n\nBis bald!\nStargirlnails Kiel`,
|
||||
html,
|
||||
cc: process.env.ADMIN_EMAIL ? [process.env.ADMIN_EMAIL] : undefined,
|
||||
});
|
||||
} else if (input.status === "cancelled") {
|
||||
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 ${booking.appointmentDate} um ${booking.appointmentTime} abgesagt. Bitte buche einen neuen Termin.\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;
|
||||
});
|
||||
|
||||
|
Reference in New Issue
Block a user