217 lines
7.9 KiB
TypeScript
217 lines
7.9 KiB
TypeScript
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(),
|
|
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<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,
|
|
...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 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;
|
|
});
|
|
|
|
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,
|
|
}; |