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, sendEmailWithAGB } from "@/server/lib/email"; import { renderBookingPendingHTML, renderBookingConfirmedHTML, renderBookingCancelledHTML } from "@/server/lib/email-templates"; // 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}`; } const BookingSchema = z.object({ id: z.string(), treatmentId: z.string(), customerName: z.string(), customerEmail: z.string(), customerPhone: z.string(), appointmentDate: z.string(), // ISO date string appointmentTime: z.string(), // HH:MM format status: z.enum(["pending", "confirmed", "cancelled", "completed"]), notes: z.string().optional(), createdAt: z.string(), slotId: z.string().optional(), }); type Booking = z.output; const kv = createKV("bookings"); type Availability = { id: string; date: string; time: string; durationMinutes: number; status: "free" | "reserved"; reservedByBookingId?: string; createdAt: string; }; const availabilityKV = createAvailabilityKV("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, ...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 () => { const formattedDate = formatDateGerman(input.appointmentDate); 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`, 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("sessions"); const usersKV = createAvailabilityKV("users"); async function assertOwner(sessionId: string): Promise { 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 formattedDate = formatDateGerman(booking.appointmentDate); const html = await renderBookingConfirmedHTML({ name: booking.customerName, date: booking.appointmentDate, time: booking.appointmentTime }); 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`, html, cc: process.env.ADMIN_EMAIL ? [process.env.ADMIN_EMAIL] : undefined, }); } else if (input.status === "cancelled") { const formattedDate = formatDateGerman(booking.appointmentDate); 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`, 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, };