- 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
157 lines
4.7 KiB
TypeScript
157 lines
4.7 KiB
TypeScript
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 }) => {
|
|
try {
|
|
await assertOwner(input.sessionId);
|
|
// Prevent duplicate slot on same date+time
|
|
const existing = await kv.getAllItems();
|
|
const conflict = existing.some((s) => s.date === input.date && s.time === input.time);
|
|
if (conflict) {
|
|
throw new Error("Es existiert bereits ein Slot zu diesem Datum und dieser Uhrzeit.");
|
|
}
|
|
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;
|
|
} catch (err) {
|
|
console.error("availability.create error", err);
|
|
throw err;
|
|
}
|
|
});
|
|
|
|
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 () => {
|
|
const allSlots = await kv.getAllItems();
|
|
|
|
// Filter out past slots automatically
|
|
const today = new Date().toISOString().split("T")[0]; // YYYY-MM-DD
|
|
const now = new Date();
|
|
const currentTime = `${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}`;
|
|
|
|
const filteredSlots = allSlots.filter(slot => {
|
|
// Keep slots for future dates
|
|
if (slot.date > today) return true;
|
|
|
|
// For today: only keep future time slots
|
|
if (slot.date === today) {
|
|
return slot.time > currentTime;
|
|
}
|
|
|
|
// Remove past slots
|
|
return false;
|
|
});
|
|
|
|
// Debug logging (commented out - uncomment if needed)
|
|
// const statusCounts = filteredSlots.reduce((acc, slot) => {
|
|
// acc[slot.status] = (acc[slot.status] || 0) + 1;
|
|
// return acc;
|
|
// }, {} as Record<string, number>);
|
|
// console.log(`Availability list: ${filteredSlots.length} slots (${JSON.stringify(statusCounts)})`);
|
|
|
|
return filteredSlots;
|
|
});
|
|
|
|
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,
|
|
};
|
|
|
|
|