Entferne Slots-Tab und Slot-RPCs; bereinige recurring-availability; Texte angepasst

This commit is contained in:
2025-10-05 17:21:56 +02:00
parent 6cf657168b
commit 6d7e8eceba
5 changed files with 8 additions and 835 deletions

View File

@@ -1,250 +0,0 @@
import { call, os } from "@orpc/server";
import { z } from "zod";
import { randomUUID } from "crypto";
import { createKV } from "../lib/create-kv.js";
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();
// Auto-delete past slots
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")}`;
let deletedCount = 0;
const slotsToDelete: string[] = [];
// Identify past slots for deletion
allSlots.forEach(slot => {
const isPastDate = slot.date < today;
const isPastTime = slot.date === today && slot.time <= currentTime;
if (isPastDate || isPastTime) {
slotsToDelete.push(slot.id);
}
});
// Delete past slots (only if not reserved)
for (const slotId of slotsToDelete) {
const slot = await kv.getItem(slotId);
if (slot && slot.status !== "reserved") {
await kv.removeItem(slotId);
deletedCount++;
}
}
if (deletedCount > 0) {
console.log(`Auto-deleted ${deletedCount} past availability slots`);
}
// Return remaining slots (all are now current/future)
const remainingSlots = allSlots.filter(slot => !slotsToDelete.includes(slot.id));
return remainingSlots;
});
// Non-mutating function that returns all slots without auto-cleanup
const peekAll = os.handler(async () => {
const allSlots = await kv.getAllItems();
return allSlots;
});
// Helper function to check if a date is in a time-off period
function isDateInTimeOffPeriod(date: string, periods: Array<{startDate: string, endDate: string}>): boolean {
return periods.some(period => date >= period.startDate && date <= period.endDate);
}
// Filtered list that excludes slots in time-off periods (for customer-facing endpoints)
const listFiltered = os.handler(async () => {
const allSlots = await kv.getAllItems();
// Auto-delete past slots
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")}`;
let deletedCount = 0;
const slotsToDelete: string[] = [];
// Identify past slots for deletion
allSlots.forEach(slot => {
const isPastDate = slot.date < today;
const isPastTime = slot.date === today && slot.time <= currentTime;
if (isPastDate || isPastTime) {
slotsToDelete.push(slot.id);
}
});
// Delete past slots (only if not reserved)
for (const slotId of slotsToDelete) {
const slot = await kv.getItem(slotId);
if (slot && slot.status !== "reserved") {
await kv.removeItem(slotId);
deletedCount++;
}
}
if (deletedCount > 0) {
console.log(`Auto-deleted ${deletedCount} past availability slots`);
}
// Get remaining slots (all are now current/future)
let remainingSlots = allSlots.filter(slot => !slotsToDelete.includes(slot.id));
// Note: Time-off filtering would be added here if we had access to time-off periods
// For now, we'll rely on the slot generation logic to not create slots during time-off periods
return remainingSlots;
});
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);
});
// Cleanup function to manually delete past slots
const cleanupPastSlots = os
.input(z.object({ sessionId: z.string() }))
.handler(async ({ input }) => {
await assertOwner(input.sessionId);
const allSlots = await kv.getAllItems();
const today = new Date().toISOString().split("T")[0];
const now = new Date();
const currentTime = `${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}`;
let deletedCount = 0;
for (const slot of allSlots) {
const isPastDate = slot.date < today;
const isPastTime = slot.date === today && slot.time <= currentTime;
if ((isPastDate || isPastTime) && slot.status !== "reserved") {
await kv.removeItem(slot.id);
deletedCount++;
}
}
console.log(`Manual cleanup: deleted ${deletedCount} past availability slots`);
return { deletedCount, message: `${deletedCount} vergangene Slots wurden gelöscht.` };
});
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,
listFiltered,
peekAll,
get,
getByDate,
cleanupPastSlots,
live,
};

View File

@@ -2,7 +2,6 @@ import { demo } from "./demo/index.js";
import { router as treatments } from "./treatments.js";
import { router as bookings } from "./bookings.js";
import { router as auth } from "./auth.js";
import { router as availability } from "./availability.js";
import { router as recurringAvailability } from "./recurring-availability.js";
import { router as cancellation } from "./cancellation.js";
import { router as legal } from "./legal.js";
@@ -12,7 +11,6 @@ export const router = {
treatments,
bookings,
auth,
availability,
recurringAvailability,
cancellation,
legal,

View File

@@ -30,14 +30,12 @@ export type TimeOffPeriod = z.output<typeof TimeOffPeriodSchema>;
const recurringRulesKV = createKV<RecurringRule>("recurringRules");
const timeOffPeriodsKV = createKV<TimeOffPeriod>("timeOffPeriods");
// Import existing availability KV
import { router as availabilityRouter } from "./availability.js";
// Import bookings and treatments KV stores for getAvailableTimes endpoint
const bookingsKV = createKV<any>("bookings");
const treatmentsKV = createKV<any>("treatments");
// Owner-Authentifizierung (kopiert aus availability.ts)
// Owner-Authentifizierung
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");
@@ -236,23 +234,8 @@ const createTimeOff = os
createdAt: new Date().toISOString(),
};
// Blockiere bestehende Slots in diesem Zeitraum
const existingSlots = await call(availabilityRouter.peekAll, {}, {});
let blockedCount = 0;
for (const slot of existingSlots) {
if (slot.date >= input.startDate && slot.date <= input.endDate && slot.status === "free") {
await call(availabilityRouter.remove, { sessionId: input.sessionId, id: slot.id }, {});
blockedCount++;
}
}
if (blockedCount > 0) {
console.log(`Blocked ${blockedCount} existing slots for time-off period: ${input.reason}`);
}
await timeOffPeriodsKV.setItem(id, timeOff);
return { ...timeOff, blockedSlots: blockedCount };
return timeOff;
} catch (err) {
console.error("recurring-availability.createTimeOff error", err);
throw err;
@@ -294,153 +277,6 @@ const adminListTimeOff = os
return allTimeOff.sort((a, b) => a.startDate.localeCompare(b.startDate));
});
// Slot-Generator-Endpoint
// DEPRECATED: This endpoint will be removed in a future version.
// The system is transitioning to dynamic availability calculation with 15-minute intervals.
// Slots are no longer pre-generated based on recurring rules.
const generateSlots = os
.input(
z.object({
sessionId: z.string(),
startDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
endDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
overwriteExisting: z.boolean().default(false),
})
)
.handler(async ({ input }) => {
try {
await assertOwner(input.sessionId);
// Validierung: startDate <= endDate
if (input.startDate > input.endDate) {
throw new Error("Startdatum muss vor oder am Enddatum liegen.");
}
// Validierung: maximal 12 Wochen Zeitraum
const start = new Date(input.startDate);
const end = new Date(input.endDate);
const daysDiff = Math.ceil((end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24));
if (daysDiff > 84) { // 12 Wochen = 84 Tage
throw new Error("Zeitraum darf maximal 12 Wochen betragen.");
}
// Lade alle aktiven Regeln
const allRules = await recurringRulesKV.getAllItems();
const activeRules = allRules.filter(rule => rule.isActive);
// Lade alle Urlaubszeiten
const timeOffPeriods = await timeOffPeriodsKV.getAllItems();
// Lade bestehende Slots (ohne Auto-Cleanup)
const existingSlots = await call(availabilityRouter.peekAll, {}, {});
// Erstelle Set für effiziente Duplikat-Prüfung
const existing = new Set(existingSlots.map(s => `${s.date}T${s.time}`));
let created = 0;
let skipped = 0;
let updated = 0;
// Iteriere über jeden Tag im Zeitraum
const currentDate = new Date(start);
while (currentDate <= end) {
const dateStr = formatDate(currentDate);
// Verwende lokale Datumskomponenten für korrekte Wochentag-Berechnung
const [y, m, d] = dateStr.split('-').map(Number);
const localDate = new Date(y, m - 1, d);
const dayOfWeek = localDate.getDay(); // 0=Sonntag, 1=Montag, ...
// Prüfe, ob Datum in einer Urlaubszeit liegt
if (isDateInTimeOffPeriod(dateStr, timeOffPeriods)) {
currentDate.setDate(currentDate.getDate() + 1);
continue;
}
// Finde alle Regeln für diesen Wochentag
const matchingRules = activeRules.filter(rule => rule.dayOfWeek === dayOfWeek);
for (const rule of matchingRules) {
// Skip rules without slotDurationMinutes (legacy field for deprecated generateSlots)
if (!rule.slotDurationMinutes) {
console.log(`Skipping rule ${rule.id} - no slotDurationMinutes defined (legacy field)`);
continue;
}
const startMinutes = parseTime(rule.startTime);
const endMinutes = parseTime(rule.endTime);
// Generiere Slots in slotDurationMinutes-Schritten
let currentMinutes = startMinutes;
while (currentMinutes + rule.slotDurationMinutes <= endMinutes) {
const timeStr = formatTime(currentMinutes);
const key = `${dateStr}T${timeStr}`;
// Prüfe, ob bereits ein Slot für dieses Datum+Zeit existiert
if (existing.has(key)) {
if (input.overwriteExisting) {
// Finde den bestehenden Slot für Update
const existingSlot = existingSlots.find(
slot => slot.date === dateStr && slot.time === timeStr
);
if (existingSlot && existingSlot.status === "free") {
// Überschreibe Dauer des bestehenden Slots
const updatedSlot = {
...existingSlot,
durationMinutes: rule.slotDurationMinutes,
};
await call(availabilityRouter.update, {
sessionId: input.sessionId,
...updatedSlot
}, {});
updated++;
} else {
skipped++;
}
} else {
skipped++;
}
} else {
// Erstelle neuen Slot mit try/catch für Duplikat-Konflikte
try {
await call(availabilityRouter.create, {
sessionId: input.sessionId,
date: dateStr,
time: timeStr,
durationMinutes: rule.slotDurationMinutes,
}, {});
existing.add(key);
created++;
} catch (err: any) {
// Behandle bekannte Duplikat-Fehler
if (err.message && err.message.includes("bereits ein Slot")) {
skipped++;
} else {
throw err; // Re-throw unbekannte Fehler
}
}
}
currentMinutes += rule.slotDurationMinutes;
}
}
currentDate.setDate(currentDate.getDate() + 1);
}
const message = `${created} Slots erstellt, ${updated} aktualisiert, ${skipped} übersprungen.`;
console.log(`Slot generation completed: ${message}`);
return {
created,
updated,
skipped,
message,
};
} catch (err) {
console.error("recurring-availability.generateSlots error", err);
throw err;
}
});
// Get Available Times Endpoint
const getAvailableTimes = os
@@ -643,9 +479,6 @@ export const router = {
listTimeOff,
adminListTimeOff,
// Generator
generateSlots,
// Availability
getAvailableTimes,