Initial commit: Kalender, Buchungen mit Slot-Reservierung, Resend-E-Mails, Admin-UI, Startscript

This commit is contained in:
2025-09-29 19:10:42 +02:00
parent a3d032af9f
commit b33036300f
13 changed files with 571 additions and 58 deletions

View File

@@ -0,0 +1,118 @@
import { call, os } from "@orpc/server";
import { z } from "zod";
import { randomUUID } from "crypto";
import { createKV } from "@/server/lib/create-kv";
const AvailabilitySchema = z.object({
id: z.string(),
date: z.string(), // YYYY-MM-DD
time: z.string(), // HH:MM
durationMinutes: z.number().int().positive(),
status: z.enum(["free", "reserved"]),
reservedByBookingId: z.string().optional(),
createdAt: z.string(),
});
export type Availability = z.output<typeof AvailabilitySchema>;
const kv = createKV<Availability>("availability");
// Minimal Owner-Prüfung über Sessions/Users KV
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 = 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");
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 create = os
.input(
z.object({
sessionId: z.string(),
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
time: z.string().regex(/^\d{2}:\d{2}$/),
durationMinutes: z.number().int().positive(),
})
)
.handler(async ({ input }) => {
await assertOwner(input.sessionId);
const id = randomUUID();
const slot: Availability = {
id,
date: input.date,
time: input.time,
durationMinutes: input.durationMinutes,
status: "free",
createdAt: new Date().toISOString(),
};
await kv.setItem(id, slot);
return slot;
});
const update = os
.input(AvailabilitySchema.extend({ sessionId: z.string() }))
.handler(async ({ input }) => {
await assertOwner(input.sessionId);
const { sessionId, ...rest } = input as any;
await kv.setItem(rest.id, rest as Availability);
return rest as Availability;
});
const remove = os
.input(z.object({ sessionId: z.string(), id: z.string() }))
.handler(async ({ input }) => {
await assertOwner(input.sessionId);
const slot = await kv.getItem(input.id);
if (slot && slot.status === "reserved") throw new Error("Cannot delete reserved slot");
await kv.removeItem(input.id);
});
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
.handler(async ({ input }) => {
const all = await kv.getAllItems();
return all.filter((s) => s.date === 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,
update,
remove,
list,
get,
getByDate,
live,
};

View File

@@ -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;
});

View File

@@ -2,10 +2,12 @@ import { demo } from "./demo";
import { router as treatments } from "./treatments";
import { router as bookings } from "./bookings";
import { router as auth } from "./auth";
import { router as availability } from "./availability";
export const router = {
demo,
treatments,
bookings,
auth,
availability,
};