feat: CalDAV-Integration für Admin-Kalender
- Neue CalDAV-Route mit PROPFIND und GET-Endpoints - ICS-Format-Generator für Buchungsdaten - Token-basierte Authentifizierung für CalDAV-Zugriff - Admin-Interface mit CalDAV-Link-Generator - Schritt-für-Schritt-Anleitung für Kalender-Apps - 24h-Token-Ablaufzeit für Sicherheit - Unterstützung für Outlook, Google Calendar, Apple Calendar, Thunderbird Fixes: Admin kann jetzt Terminkalender in externen Apps abonnieren
This commit is contained in:
148
server-dist/rpc/auth.js
Normal file
148
server-dist/rpc/auth.js
Normal file
@@ -0,0 +1,148 @@
|
||||
import { os } from "@orpc/server";
|
||||
import { z } from "zod";
|
||||
import { randomUUID } from "crypto";
|
||||
import { createKV } from "../lib/create-kv.js";
|
||||
import { config } from "dotenv";
|
||||
// Load environment variables from .env file
|
||||
config();
|
||||
const UserSchema = z.object({
|
||||
id: z.string(),
|
||||
username: z.string().min(3, "Benutzername muss mindestens 3 Zeichen lang sein"),
|
||||
email: z.string().email("Ungültige E-Mail-Adresse"),
|
||||
passwordHash: z.string(),
|
||||
role: z.enum(["customer", "owner"]),
|
||||
createdAt: z.string(),
|
||||
});
|
||||
const SessionSchema = z.object({
|
||||
id: z.string(),
|
||||
userId: z.string(),
|
||||
expiresAt: z.string(),
|
||||
createdAt: z.string(),
|
||||
});
|
||||
const usersKV = createKV("users");
|
||||
const sessionsKV = createKV("sessions");
|
||||
// Simple password hashing (in production, use bcrypt or similar)
|
||||
const hashPassword = (password) => {
|
||||
return Buffer.from(password).toString('base64');
|
||||
};
|
||||
const verifyPassword = (password, hash) => {
|
||||
return hashPassword(password) === hash;
|
||||
};
|
||||
// Export hashPassword for external use (e.g., generating hashes for .env)
|
||||
export const generatePasswordHash = hashPassword;
|
||||
// Initialize default owner account
|
||||
const initializeOwner = async () => {
|
||||
const existingUsers = await usersKV.getAllItems();
|
||||
if (existingUsers.length === 0) {
|
||||
const ownerId = randomUUID();
|
||||
// Get admin credentials from environment variables
|
||||
const adminUsername = process.env.ADMIN_USERNAME || "owner";
|
||||
const adminPasswordHash = process.env.ADMIN_PASSWORD_HASH || hashPassword("admin123");
|
||||
const adminEmail = process.env.ADMIN_EMAIL || "owner@stargirlnails.de";
|
||||
const owner = {
|
||||
id: ownerId,
|
||||
username: adminUsername,
|
||||
email: adminEmail,
|
||||
passwordHash: adminPasswordHash,
|
||||
role: "owner",
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
await usersKV.setItem(ownerId, owner);
|
||||
console.log(`✅ Admin account created: username="${adminUsername}", email="${adminEmail}"`);
|
||||
}
|
||||
};
|
||||
// Initialize on module load
|
||||
initializeOwner();
|
||||
const login = os
|
||||
.input(z.object({
|
||||
username: z.string(),
|
||||
password: z.string(),
|
||||
}))
|
||||
.handler(async ({ input }) => {
|
||||
const users = await usersKV.getAllItems();
|
||||
const user = users.find(u => u.username === input.username);
|
||||
if (!user || !verifyPassword(input.password, user.passwordHash)) {
|
||||
throw new Error("Invalid credentials");
|
||||
}
|
||||
// Create session
|
||||
const sessionId = randomUUID();
|
||||
const expiresAt = new Date();
|
||||
expiresAt.setHours(expiresAt.getHours() + 24); // 24 hours
|
||||
const session = {
|
||||
id: sessionId,
|
||||
userId: user.id,
|
||||
expiresAt: expiresAt.toISOString(),
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
await sessionsKV.setItem(sessionId, session);
|
||||
return {
|
||||
sessionId,
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
},
|
||||
};
|
||||
});
|
||||
const logout = os
|
||||
.input(z.string()) // sessionId
|
||||
.handler(async ({ input }) => {
|
||||
await sessionsKV.removeItem(input);
|
||||
return { success: true };
|
||||
});
|
||||
const verifySession = os
|
||||
.input(z.string()) // sessionId
|
||||
.handler(async ({ input }) => {
|
||||
const session = await sessionsKV.getItem(input);
|
||||
if (!session) {
|
||||
throw new Error("Invalid session");
|
||||
}
|
||||
if (new Date(session.expiresAt) < new Date()) {
|
||||
await sessionsKV.removeItem(input);
|
||||
throw new Error("Session expired");
|
||||
}
|
||||
const user = await usersKV.getItem(session.userId);
|
||||
if (!user) {
|
||||
throw new Error("User not found");
|
||||
}
|
||||
return {
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
},
|
||||
};
|
||||
});
|
||||
const changePassword = os
|
||||
.input(z.object({
|
||||
sessionId: z.string(),
|
||||
currentPassword: z.string(),
|
||||
newPassword: z.string(),
|
||||
}))
|
||||
.handler(async ({ input }) => {
|
||||
const session = await sessionsKV.getItem(input.sessionId);
|
||||
if (!session) {
|
||||
throw new Error("Invalid session");
|
||||
}
|
||||
const user = await usersKV.getItem(session.userId);
|
||||
if (!user) {
|
||||
throw new Error("User not found");
|
||||
}
|
||||
if (!verifyPassword(input.currentPassword, user.passwordHash)) {
|
||||
throw new Error("Current password is incorrect");
|
||||
}
|
||||
const updatedUser = {
|
||||
...user,
|
||||
passwordHash: hashPassword(input.newPassword),
|
||||
};
|
||||
await usersKV.setItem(user.id, updatedUser);
|
||||
return { success: true };
|
||||
});
|
||||
export const router = {
|
||||
login,
|
||||
logout,
|
||||
verifySession,
|
||||
changePassword,
|
||||
};
|
748
server-dist/rpc/bookings.js
Normal file
748
server-dist/rpc/bookings.js
Normal file
@@ -0,0 +1,748 @@
|
||||
import { call, os } from "@orpc/server";
|
||||
import { z } from "zod";
|
||||
import { randomUUID } from "crypto";
|
||||
import { createKV } from "../lib/create-kv.js";
|
||||
import { sendEmail, sendEmailWithAGBAndCalendar, sendEmailWithInspirationPhoto } from "../lib/email.js";
|
||||
import { renderBookingPendingHTML, renderBookingConfirmedHTML, renderBookingCancelledHTML, renderAdminBookingNotificationHTML, renderBookingRescheduleProposalHTML, renderAdminRescheduleAcceptedHTML, renderAdminRescheduleDeclinedHTML } from "../lib/email-templates.js";
|
||||
import { createORPCClient } from "@orpc/client";
|
||||
import { RPCLink } from "@orpc/client/fetch";
|
||||
import { checkBookingRateLimit } from "../lib/rate-limiter.js";
|
||||
import { validateEmail } from "../lib/email-validator.js";
|
||||
// Create a server-side client to call other RPC endpoints
|
||||
const serverPort = process.env.PORT ? parseInt(process.env.PORT) : 3000;
|
||||
const link = new RPCLink({ url: `http://localhost:${serverPort}/rpc` });
|
||||
// Typisierung über any, um Build-Inkompatibilität mit NestedClient zu vermeiden (nur für interne Server-Calls)
|
||||
const queryClient = createORPCClient(link);
|
||||
// Helper function to convert date from yyyy-mm-dd to dd.mm.yyyy
|
||||
function formatDateGerman(dateString) {
|
||||
const [year, month, day] = dateString.split('-');
|
||||
return `${day}.${month}.${year}`;
|
||||
}
|
||||
// Helper function to generate URLs from DOMAIN environment variable
|
||||
function generateUrl(path = '') {
|
||||
const domain = process.env.DOMAIN || 'localhost:5173';
|
||||
const protocol = domain.includes('localhost') ? 'http' : 'https';
|
||||
return `${protocol}://${domain}${path}`;
|
||||
}
|
||||
// Helper function to parse time string to minutes since midnight
|
||||
function parseTime(timeStr) {
|
||||
const [hours, minutes] = timeStr.split(':').map(Number);
|
||||
return hours * 60 + minutes;
|
||||
}
|
||||
// Helper function to check if date is in time-off period
|
||||
function isDateInTimeOffPeriod(date, periods) {
|
||||
return periods.some(period => date >= period.startDate && date <= period.endDate);
|
||||
}
|
||||
// Helper function to validate booking time against recurring rules
|
||||
async function validateBookingAgainstRules(date, time, treatmentDuration) {
|
||||
// Parse date to get day of week
|
||||
const [year, month, day] = date.split('-').map(Number);
|
||||
const localDate = new Date(year, month - 1, day);
|
||||
const dayOfWeek = localDate.getDay();
|
||||
// Check time-off periods
|
||||
const timeOffPeriods = await timeOffPeriodsKV.getAllItems();
|
||||
if (isDateInTimeOffPeriod(date, timeOffPeriods)) {
|
||||
throw new Error("Dieser Tag ist nicht verfügbar (Urlaubszeit).");
|
||||
}
|
||||
// Find matching recurring rules
|
||||
const allRules = await recurringRulesKV.getAllItems();
|
||||
const matchingRules = allRules.filter(rule => rule.isActive === true && rule.dayOfWeek === dayOfWeek);
|
||||
if (matchingRules.length === 0) {
|
||||
throw new Error("Für diesen Wochentag sind keine Termine verfügbar.");
|
||||
}
|
||||
// Check if booking time falls within any rule's time span
|
||||
const bookingStartMinutes = parseTime(time);
|
||||
const bookingEndMinutes = bookingStartMinutes + treatmentDuration;
|
||||
const isWithinRules = matchingRules.some(rule => {
|
||||
const ruleStartMinutes = parseTime(rule.startTime);
|
||||
const ruleEndMinutes = parseTime(rule.endTime);
|
||||
// Booking must start at or after rule start and end at or before rule end
|
||||
return bookingStartMinutes >= ruleStartMinutes && bookingEndMinutes <= ruleEndMinutes;
|
||||
});
|
||||
if (!isWithinRules) {
|
||||
throw new Error("Die gewählte Uhrzeit liegt außerhalb der verfügbaren Zeiten.");
|
||||
}
|
||||
}
|
||||
// Helper function to check for booking conflicts
|
||||
async function checkBookingConflicts(date, time, treatmentDuration, excludeBookingId) {
|
||||
const allBookings = await kv.getAllItems();
|
||||
const dateBookings = allBookings.filter(booking => booking.appointmentDate === date &&
|
||||
['pending', 'confirmed', 'completed'].includes(booking.status) &&
|
||||
booking.id !== excludeBookingId);
|
||||
const bookingStartMinutes = parseTime(time);
|
||||
const bookingEndMinutes = bookingStartMinutes + treatmentDuration;
|
||||
// Cache treatment durations by ID to avoid N+1 lookups
|
||||
const uniqueTreatmentIds = [...new Set(dateBookings.map(booking => booking.treatmentId))];
|
||||
const treatmentDurationMap = new Map();
|
||||
for (const treatmentId of uniqueTreatmentIds) {
|
||||
const treatment = await treatmentsKV.getItem(treatmentId);
|
||||
treatmentDurationMap.set(treatmentId, treatment?.duration || 60);
|
||||
}
|
||||
// Check for overlaps with existing bookings
|
||||
for (const existingBooking of dateBookings) {
|
||||
// Use cached duration or fallback to bookedDurationMinutes if available
|
||||
let existingDuration = treatmentDurationMap.get(existingBooking.treatmentId) || 60;
|
||||
if (existingBooking.bookedDurationMinutes) {
|
||||
existingDuration = existingBooking.bookedDurationMinutes;
|
||||
}
|
||||
const existingStartMinutes = parseTime(existingBooking.appointmentTime);
|
||||
const existingEndMinutes = existingStartMinutes + existingDuration;
|
||||
// Check overlap: bookingStart < existingEnd && bookingEnd > existingStart
|
||||
if (bookingStartMinutes < existingEndMinutes && bookingEndMinutes > existingStartMinutes) {
|
||||
throw new Error("Dieser Zeitslot ist bereits gebucht. Bitte wähle eine andere Zeit.");
|
||||
}
|
||||
}
|
||||
}
|
||||
const CreateBookingInputSchema = z.object({
|
||||
treatmentId: z.string(),
|
||||
customerName: z.string().min(2, "Name muss mindestens 2 Zeichen lang sein"),
|
||||
customerEmail: z.string().email("Ungültige E-Mail-Adresse"),
|
||||
customerPhone: z.string().min(5, "Telefonnummer muss mindestens 5 Zeichen lang sein").optional(),
|
||||
appointmentDate: z.string(), // ISO date string
|
||||
appointmentTime: z.string(), // HH:MM format
|
||||
notes: z.string().optional(),
|
||||
inspirationPhoto: z.string().optional(), // Base64 encoded image data
|
||||
});
|
||||
const BookingSchema = z.object({
|
||||
id: z.string(),
|
||||
treatmentId: z.string(),
|
||||
customerName: z.string().min(2, "Name muss mindestens 2 Zeichen lang sein"),
|
||||
customerEmail: z.string().email("Ungültige E-Mail-Adresse").optional(),
|
||||
customerPhone: z.string().min(5, "Telefonnummer muss mindestens 5 Zeichen lang sein").optional(),
|
||||
appointmentDate: z.string(), // ISO date string
|
||||
appointmentTime: z.string(), // HH:MM format
|
||||
status: z.enum(["pending", "confirmed", "cancelled", "completed"]),
|
||||
notes: z.string().optional(),
|
||||
inspirationPhoto: z.string().optional(), // Base64 encoded image data
|
||||
bookedDurationMinutes: z.number().optional(), // Snapshot of treatment duration at booking time
|
||||
createdAt: z.string(),
|
||||
// DEPRECATED: slotId is no longer used for validation, kept for backward compatibility
|
||||
slotId: z.string().optional(),
|
||||
});
|
||||
const kv = createKV("bookings");
|
||||
const recurringRulesKV = createKV("recurringRules");
|
||||
const timeOffPeriodsKV = createKV("timeOffPeriods");
|
||||
const treatmentsKV = createKV("treatments");
|
||||
const create = os
|
||||
.input(CreateBookingInputSchema)
|
||||
.handler(async ({ input }) => {
|
||||
// console.log("Booking create called with input:", {
|
||||
// ...input,
|
||||
// inspirationPhoto: input.inspirationPhoto ? `[${input.inspirationPhoto.length} chars]` : null
|
||||
// });
|
||||
try {
|
||||
// Rate limiting check (ohne IP, falls Context-Header im Build nicht verfügbar sind)
|
||||
const rateLimitResult = checkBookingRateLimit({
|
||||
ip: undefined,
|
||||
email: input.customerEmail,
|
||||
});
|
||||
if (!rateLimitResult.allowed) {
|
||||
const retryMinutes = rateLimitResult.retryAfterSeconds
|
||||
? Math.ceil(rateLimitResult.retryAfterSeconds / 60)
|
||||
: 10;
|
||||
throw new Error(`Zu viele Buchungsanfragen. Bitte versuche es in ${retryMinutes} Minute${retryMinutes > 1 ? 'n' : ''} erneut.`);
|
||||
}
|
||||
// Email validation before slot reservation
|
||||
console.log(`Validating email: ${input.customerEmail}`);
|
||||
const emailValidation = await validateEmail(input.customerEmail);
|
||||
console.log(`Email validation result:`, emailValidation);
|
||||
if (!emailValidation.valid) {
|
||||
console.log(`Email validation failed: ${emailValidation.reason}`);
|
||||
throw new Error(emailValidation.reason || "Ungültige E-Mail-Adresse");
|
||||
}
|
||||
// Validate appointment time is on 15-minute grid
|
||||
const appointmentMinutes = parseTime(input.appointmentTime);
|
||||
if (appointmentMinutes % 15 !== 0) {
|
||||
throw new Error("Termine müssen auf 15-Minuten-Raster ausgerichtet sein (z.B. 09:00, 09:15, 09:30, 09:45).");
|
||||
}
|
||||
// Validate that the booking is not in the past
|
||||
const today = new Date().toISOString().split("T")[0]; // YYYY-MM-DD
|
||||
if (input.appointmentDate < today) {
|
||||
throw new Error("Buchungen für vergangene Termine sind nicht möglich.");
|
||||
}
|
||||
// For today's bookings, check if the time is not in the past
|
||||
if (input.appointmentDate === today) {
|
||||
const now = new Date();
|
||||
const currentTime = `${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}`;
|
||||
if (input.appointmentTime <= currentTime) {
|
||||
throw new Error("Buchungen für vergangene Uhrzeiten sind nicht möglich.");
|
||||
}
|
||||
}
|
||||
// Prevent double booking: same customer email with pending/confirmed on same date
|
||||
// Skip duplicate check when DISABLE_DUPLICATE_CHECK is set
|
||||
if (!process.env.DISABLE_DUPLICATE_CHECK) {
|
||||
const existing = await kv.getAllItems();
|
||||
const hasConflict = existing.some(b => (b.customerEmail && 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.");
|
||||
}
|
||||
}
|
||||
// Get treatment duration for validation
|
||||
const treatment = await treatmentsKV.getItem(input.treatmentId);
|
||||
if (!treatment) {
|
||||
throw new Error("Behandlung nicht gefunden.");
|
||||
}
|
||||
// Validate booking time against recurring rules
|
||||
await validateBookingAgainstRules(input.appointmentDate, input.appointmentTime, treatment.duration);
|
||||
// Check for booking conflicts
|
||||
await checkBookingConflicts(input.appointmentDate, input.appointmentTime, treatment.duration);
|
||||
const id = randomUUID();
|
||||
const booking = {
|
||||
id,
|
||||
...input,
|
||||
bookedDurationMinutes: treatment.duration, // Snapshot treatment duration
|
||||
status: "pending",
|
||||
createdAt: new Date().toISOString()
|
||||
};
|
||||
// Save the booking
|
||||
await kv.setItem(id, booking);
|
||||
// Notify customer: request received (pending)
|
||||
void (async () => {
|
||||
// Create booking access token for status viewing
|
||||
const bookingAccessToken = await queryClient.cancellation.createToken({ bookingId: id });
|
||||
const bookingUrl = generateUrl(`/booking/${bookingAccessToken.token}`);
|
||||
const formattedDate = formatDateGerman(input.appointmentDate);
|
||||
const homepageUrl = generateUrl();
|
||||
const html = await renderBookingPendingHTML({
|
||||
name: input.customerName,
|
||||
date: input.appointmentDate,
|
||||
time: input.appointmentTime,
|
||||
statusUrl: bookingUrl
|
||||
});
|
||||
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. Du erhältst eine weitere E-Mail, sobald der Termin bestätigt ist.\n\nTermin-Status ansehen: ${bookingUrl}\n\nRechtliche Informationen: ${generateUrl('/legal')}\nZur Website: ${homepageUrl}\n\nLiebe Grüße\nStargirlnails Kiel`,
|
||||
html,
|
||||
}).catch(() => { });
|
||||
})();
|
||||
// Notify admin: new booking request (with photo if available)
|
||||
void (async () => {
|
||||
if (!process.env.ADMIN_EMAIL)
|
||||
return;
|
||||
// Get treatment name from KV
|
||||
const allTreatments = await treatmentsKV.getAllItems();
|
||||
const treatment = allTreatments.find(t => t.id === input.treatmentId);
|
||||
const treatmentName = treatment?.name || "Unbekannte Behandlung";
|
||||
const adminHtml = await renderAdminBookingNotificationHTML({
|
||||
name: input.customerName,
|
||||
date: input.appointmentDate,
|
||||
time: input.appointmentTime,
|
||||
treatment: treatmentName,
|
||||
phone: input.customerPhone || "Nicht angegeben",
|
||||
notes: input.notes,
|
||||
hasInspirationPhoto: !!input.inspirationPhoto
|
||||
});
|
||||
const homepageUrl = generateUrl();
|
||||
const adminText = `Neue Buchungsanfrage eingegangen:\n\n` +
|
||||
`Name: ${input.customerName}\n` +
|
||||
`Telefon: ${input.customerPhone || "Nicht angegeben"}\n` +
|
||||
`Behandlung: ${treatmentName}\n` +
|
||||
`Datum: ${formatDateGerman(input.appointmentDate)}\n` +
|
||||
`Uhrzeit: ${input.appointmentTime}\n` +
|
||||
`${input.notes ? `Notizen: ${input.notes}\n` : ''}` +
|
||||
`Inspiration-Foto: ${input.inspirationPhoto ? 'Im Anhang verfügbar' : 'Kein Foto hochgeladen'}\n\n` +
|
||||
`Zur Website: ${homepageUrl}\n\n` +
|
||||
`Bitte logge dich in das Admin-Panel ein, um die Buchung zu bearbeiten.`;
|
||||
if (input.inspirationPhoto) {
|
||||
await sendEmailWithInspirationPhoto({
|
||||
to: process.env.ADMIN_EMAIL,
|
||||
subject: `Neue Buchungsanfrage - ${input.customerName}`,
|
||||
text: adminText,
|
||||
html: adminHtml,
|
||||
}, input.inspirationPhoto, input.customerName).catch(() => { });
|
||||
}
|
||||
else {
|
||||
await sendEmail({
|
||||
to: process.env.ADMIN_EMAIL,
|
||||
subject: `Neue Buchungsanfrage - ${input.customerName}`,
|
||||
text: adminText,
|
||||
html: adminHtml,
|
||||
}).catch(() => { });
|
||||
}
|
||||
})();
|
||||
return booking;
|
||||
}
|
||||
catch (error) {
|
||||
console.error("Booking creation error:", error);
|
||||
// Re-throw the error for oRPC to handle
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
const sessionsKV = createKV("sessions");
|
||||
const usersKV = createKV("users");
|
||||
async function assertOwner(sessionId) {
|
||||
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);
|
||||
// Note: Slot state management removed - bookings now validated against recurring rules
|
||||
// Email notifications on status changes
|
||||
try {
|
||||
if (input.status === "confirmed") {
|
||||
// Create booking access token for this booking (status + cancellation)
|
||||
const bookingAccessToken = await queryClient.cancellation.createToken({ bookingId: booking.id });
|
||||
const formattedDate = formatDateGerman(booking.appointmentDate);
|
||||
const bookingUrl = generateUrl(`/booking/${bookingAccessToken.token}`);
|
||||
const homepageUrl = generateUrl();
|
||||
const html = await renderBookingConfirmedHTML({
|
||||
name: booking.customerName,
|
||||
date: booking.appointmentDate,
|
||||
time: booking.appointmentTime,
|
||||
cancellationUrl: bookingUrl, // Now points to booking status page
|
||||
reviewUrl: generateUrl(`/review/${bookingAccessToken.token}`)
|
||||
});
|
||||
// Get treatment information for ICS file
|
||||
const allTreatments = await treatmentsKV.getAllItems();
|
||||
const treatment = allTreatments.find(t => t.id === booking.treatmentId);
|
||||
const treatmentName = treatment?.name || "Behandlung";
|
||||
// Use bookedDurationMinutes if available, otherwise fallback to treatment duration
|
||||
const treatmentDuration = booking.bookedDurationMinutes || treatment?.duration || 60;
|
||||
if (booking.customerEmail) {
|
||||
await sendEmailWithAGBAndCalendar({
|
||||
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\nTermin-Status ansehen und verwalten: ${bookingUrl}\nFalls du den Termin stornieren möchtest, kannst du das über den obigen Link tun.\n\nRechtliche Informationen: ${generateUrl('/legal')}\nZur Website: ${homepageUrl}\n\nBis bald!\nStargirlnails Kiel`,
|
||||
html,
|
||||
bcc: process.env.ADMIN_EMAIL ? [process.env.ADMIN_EMAIL] : undefined,
|
||||
}, {
|
||||
date: booking.appointmentDate,
|
||||
time: booking.appointmentTime,
|
||||
durationMinutes: treatmentDuration,
|
||||
customerName: booking.customerName,
|
||||
treatmentName: treatmentName
|
||||
});
|
||||
}
|
||||
}
|
||||
else if (input.status === "cancelled") {
|
||||
const formattedDate = formatDateGerman(booking.appointmentDate);
|
||||
const homepageUrl = generateUrl();
|
||||
const html = await renderBookingCancelledHTML({ name: booking.customerName, date: booking.appointmentDate, time: booking.appointmentTime });
|
||||
if (booking.customerEmail) {
|
||||
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\nRechtliche Informationen: ${generateUrl('/legal')}\nZur Website: ${homepageUrl}\n\nLiebe Grüße\nStargirlnails Kiel`,
|
||||
html,
|
||||
bcc: process.env.ADMIN_EMAIL ? [process.env.ADMIN_EMAIL] : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
console.error("Email send failed:", e);
|
||||
}
|
||||
return updatedBooking;
|
||||
});
|
||||
const remove = os
|
||||
.input(z.object({
|
||||
sessionId: z.string(),
|
||||
id: z.string(),
|
||||
sendEmail: z.boolean().optional().default(false)
|
||||
}))
|
||||
.handler(async ({ input }) => {
|
||||
await assertOwner(input.sessionId);
|
||||
const booking = await kv.getItem(input.id);
|
||||
if (!booking)
|
||||
throw new Error("Booking not found");
|
||||
// Guard against deletion of past bookings or completed bookings
|
||||
const today = new Date().toISOString().split("T")[0];
|
||||
const isPastDate = booking.appointmentDate < today;
|
||||
const isCompleted = booking.status === 'completed';
|
||||
if (isPastDate || isCompleted) {
|
||||
// For past/completed bookings, disable email sending to avoid confusing customers
|
||||
if (input.sendEmail) {
|
||||
console.log(`Email sending disabled for past/completed booking ${input.id}`);
|
||||
}
|
||||
input.sendEmail = false;
|
||||
}
|
||||
const wasAlreadyCancelled = booking.status === 'cancelled';
|
||||
const updatedBooking = { ...booking, status: "cancelled" };
|
||||
await kv.setItem(input.id, updatedBooking);
|
||||
if (input.sendEmail && !wasAlreadyCancelled && booking.customerEmail) {
|
||||
try {
|
||||
const formattedDate = formatDateGerman(booking.appointmentDate);
|
||||
const homepageUrl = generateUrl();
|
||||
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\nRechtliche Informationen: ${generateUrl('/legal')}\nZur Website: ${homepageUrl}\n\nLiebe Grüße\nStargirlnails Kiel`,
|
||||
html,
|
||||
bcc: process.env.ADMIN_EMAIL ? [process.env.ADMIN_EMAIL] : undefined,
|
||||
});
|
||||
}
|
||||
catch (e) {
|
||||
console.error("Email send failed:", e);
|
||||
}
|
||||
}
|
||||
return updatedBooking;
|
||||
});
|
||||
// Admin-only manual booking creation (immediately confirmed)
|
||||
const createManual = os
|
||||
.input(z.object({
|
||||
sessionId: z.string(),
|
||||
treatmentId: z.string(),
|
||||
customerName: z.string().min(2, "Name muss mindestens 2 Zeichen lang sein"),
|
||||
customerEmail: z.string().email("Ungültige E-Mail-Adresse").optional(),
|
||||
customerPhone: z.string().min(5, "Telefonnummer muss mindestens 5 Zeichen lang sein").optional(),
|
||||
appointmentDate: z.string(),
|
||||
appointmentTime: z.string(),
|
||||
notes: z.string().optional(),
|
||||
}))
|
||||
.handler(async ({ input }) => {
|
||||
// Admin authentication
|
||||
await assertOwner(input.sessionId);
|
||||
// Validate appointment time is on 15-minute grid
|
||||
const appointmentMinutes = parseTime(input.appointmentTime);
|
||||
if (appointmentMinutes % 15 !== 0) {
|
||||
throw new Error("Termine müssen auf 15-Minuten-Raster ausgerichtet sein (z.B. 09:00, 09:15, 09:30, 09:45).");
|
||||
}
|
||||
// Validate that the booking is not in the past
|
||||
const today = new Date().toISOString().split("T")[0];
|
||||
if (input.appointmentDate < today) {
|
||||
throw new Error("Buchungen für vergangene Termine sind nicht möglich.");
|
||||
}
|
||||
// For today's bookings, check if the time is not in the past
|
||||
if (input.appointmentDate === today) {
|
||||
const now = new Date();
|
||||
const currentTime = `${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}`;
|
||||
if (input.appointmentTime <= currentTime) {
|
||||
throw new Error("Buchungen für vergangene Uhrzeiten sind nicht möglich.");
|
||||
}
|
||||
}
|
||||
// Get treatment duration for validation
|
||||
const treatment = await treatmentsKV.getItem(input.treatmentId);
|
||||
if (!treatment) {
|
||||
throw new Error("Behandlung nicht gefunden.");
|
||||
}
|
||||
// Validate booking time against recurring rules
|
||||
await validateBookingAgainstRules(input.appointmentDate, input.appointmentTime, treatment.duration);
|
||||
// Check for booking conflicts
|
||||
await checkBookingConflicts(input.appointmentDate, input.appointmentTime, treatment.duration);
|
||||
const id = randomUUID();
|
||||
const booking = {
|
||||
id,
|
||||
treatmentId: input.treatmentId,
|
||||
customerName: input.customerName,
|
||||
customerEmail: input.customerEmail,
|
||||
customerPhone: input.customerPhone,
|
||||
appointmentDate: input.appointmentDate,
|
||||
appointmentTime: input.appointmentTime,
|
||||
notes: input.notes,
|
||||
bookedDurationMinutes: treatment.duration,
|
||||
status: "confirmed",
|
||||
createdAt: new Date().toISOString()
|
||||
};
|
||||
// Save the booking
|
||||
await kv.setItem(id, booking);
|
||||
// Create booking access token for status viewing and cancellation (always create token)
|
||||
const bookingAccessToken = await queryClient.cancellation.createToken({ bookingId: id });
|
||||
// Send confirmation email if email is provided
|
||||
if (input.customerEmail) {
|
||||
void (async () => {
|
||||
try {
|
||||
const bookingUrl = generateUrl(`/booking/${bookingAccessToken.token}`);
|
||||
const formattedDate = formatDateGerman(input.appointmentDate);
|
||||
const homepageUrl = generateUrl();
|
||||
const html = await renderBookingConfirmedHTML({
|
||||
name: input.customerName,
|
||||
date: input.appointmentDate,
|
||||
time: input.appointmentTime,
|
||||
cancellationUrl: bookingUrl,
|
||||
reviewUrl: generateUrl(`/review/${bookingAccessToken.token}`)
|
||||
});
|
||||
await sendEmailWithAGBAndCalendar({
|
||||
to: input.customerEmail,
|
||||
subject: "Dein Termin wurde bestätigt - AGB im Anhang",
|
||||
text: `Hallo ${input.customerName},\n\nwir haben deinen Termin am ${formattedDate} um ${input.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\nTermin-Status ansehen und verwalten: ${bookingUrl}\nFalls du den Termin stornieren möchtest, kannst du das über den obigen Link tun.\n\nRechtliche Informationen: ${generateUrl('/legal')}\nZur Website: ${homepageUrl}\n\nBis bald!\nStargirlnails Kiel`,
|
||||
html,
|
||||
}, {
|
||||
date: input.appointmentDate,
|
||||
time: input.appointmentTime,
|
||||
durationMinutes: treatment.duration,
|
||||
customerName: input.customerName,
|
||||
treatmentName: treatment.name
|
||||
});
|
||||
}
|
||||
catch (e) {
|
||||
console.error("Email send failed for manual booking:", e);
|
||||
}
|
||||
})();
|
||||
}
|
||||
// Optionally return the token in the RPC response for UI to copy/share (admin usage only)
|
||||
return {
|
||||
...booking,
|
||||
bookingAccessToken: bookingAccessToken.token
|
||||
};
|
||||
});
|
||||
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,
|
||||
createManual,
|
||||
updateStatus,
|
||||
remove,
|
||||
list,
|
||||
get,
|
||||
getByDate,
|
||||
live,
|
||||
// Admin proposes a reschedule for a confirmed booking
|
||||
proposeReschedule: os
|
||||
.input(z.object({
|
||||
sessionId: z.string(),
|
||||
bookingId: z.string(),
|
||||
proposedDate: z.string(),
|
||||
proposedTime: z.string(),
|
||||
}))
|
||||
.handler(async ({ input }) => {
|
||||
await assertOwner(input.sessionId);
|
||||
const booking = await kv.getItem(input.bookingId);
|
||||
if (!booking)
|
||||
throw new Error("Booking not found");
|
||||
if (booking.status !== "confirmed")
|
||||
throw new Error("Nur bestätigte Termine können umgebucht werden.");
|
||||
const treatment = await treatmentsKV.getItem(booking.treatmentId);
|
||||
if (!treatment)
|
||||
throw new Error("Behandlung nicht gefunden.");
|
||||
// Validate grid and not in past
|
||||
const appointmentMinutes = parseTime(input.proposedTime);
|
||||
if (appointmentMinutes % 15 !== 0) {
|
||||
throw new Error("Termine müssen auf 15-Minuten-Raster ausgerichtet sein (z.B. 09:00, 09:15, 09:30, 09:45).");
|
||||
}
|
||||
const today = new Date().toISOString().split("T")[0];
|
||||
if (input.proposedDate < today) {
|
||||
throw new Error("Buchungen für vergangene Termine sind nicht möglich.");
|
||||
}
|
||||
if (input.proposedDate === today) {
|
||||
const now = new Date();
|
||||
const currentTime = `${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}`;
|
||||
if (input.proposedTime <= currentTime) {
|
||||
throw new Error("Buchungen für vergangene Uhrzeiten sind nicht möglich.");
|
||||
}
|
||||
}
|
||||
await validateBookingAgainstRules(input.proposedDate, input.proposedTime, booking.bookedDurationMinutes || treatment.duration);
|
||||
await checkBookingConflicts(input.proposedDate, input.proposedTime, booking.bookedDurationMinutes || treatment.duration, booking.id);
|
||||
// Invalidate and create new reschedule token via cancellation router
|
||||
const res = await queryClient.cancellation.createRescheduleToken({
|
||||
bookingId: booking.id,
|
||||
proposedDate: input.proposedDate,
|
||||
proposedTime: input.proposedTime,
|
||||
});
|
||||
const acceptUrl = generateUrl(`/booking/${res.token}?action=accept`);
|
||||
const declineUrl = generateUrl(`/booking/${res.token}?action=decline`);
|
||||
// Send proposal email to customer
|
||||
if (booking.customerEmail) {
|
||||
const html = await renderBookingRescheduleProposalHTML({
|
||||
name: booking.customerName,
|
||||
originalDate: booking.appointmentDate,
|
||||
originalTime: booking.appointmentTime,
|
||||
proposedDate: input.proposedDate,
|
||||
proposedTime: input.proposedTime,
|
||||
treatmentName: (await treatmentsKV.getItem(booking.treatmentId))?.name || "Behandlung",
|
||||
acceptUrl,
|
||||
declineUrl,
|
||||
expiresAt: res.expiresAt,
|
||||
});
|
||||
await sendEmail({
|
||||
to: booking.customerEmail,
|
||||
subject: "Vorschlag zur Terminänderung",
|
||||
text: `Hallo ${booking.customerName}, wir schlagen vor, deinen Termin von ${formatDateGerman(booking.appointmentDate)} ${booking.appointmentTime} auf ${formatDateGerman(input.proposedDate)} ${input.proposedTime} zu verschieben. Akzeptieren: ${acceptUrl} | Ablehnen: ${declineUrl}`,
|
||||
html,
|
||||
bcc: process.env.ADMIN_EMAIL ? [process.env.ADMIN_EMAIL] : undefined,
|
||||
}).catch(() => { });
|
||||
}
|
||||
return { success: true, token: res.token };
|
||||
}),
|
||||
// Customer accepts reschedule via token
|
||||
acceptReschedule: os
|
||||
.input(z.object({ token: z.string() }))
|
||||
.handler(async ({ input }) => {
|
||||
const proposal = await queryClient.cancellation.getRescheduleProposal({ token: input.token });
|
||||
const booking = await kv.getItem(proposal.booking.id);
|
||||
if (!booking)
|
||||
throw new Error("Booking not found");
|
||||
if (booking.status !== "confirmed")
|
||||
throw new Error("Buchung ist nicht mehr in bestätigtem Zustand.");
|
||||
const treatment = await treatmentsKV.getItem(booking.treatmentId);
|
||||
const duration = booking.bookedDurationMinutes || treatment?.duration || 60;
|
||||
// Re-validate slot to ensure still available
|
||||
await validateBookingAgainstRules(proposal.proposed.date, proposal.proposed.time, duration);
|
||||
await checkBookingConflicts(proposal.proposed.date, proposal.proposed.time, duration, booking.id);
|
||||
const updated = { ...booking, appointmentDate: proposal.proposed.date, appointmentTime: proposal.proposed.time };
|
||||
await kv.setItem(updated.id, updated);
|
||||
// Remove token
|
||||
await queryClient.cancellation.removeRescheduleToken({ token: input.token });
|
||||
// Send confirmation to customer (no BCC to avoid duplicate admin emails)
|
||||
if (updated.customerEmail) {
|
||||
const bookingAccessToken = await queryClient.cancellation.createToken({ bookingId: updated.id });
|
||||
const html = await renderBookingConfirmedHTML({
|
||||
name: updated.customerName,
|
||||
date: updated.appointmentDate,
|
||||
time: updated.appointmentTime,
|
||||
cancellationUrl: generateUrl(`/booking/${bookingAccessToken.token}`),
|
||||
reviewUrl: generateUrl(`/review/${bookingAccessToken.token}`),
|
||||
});
|
||||
await sendEmailWithAGBAndCalendar({
|
||||
to: updated.customerEmail,
|
||||
subject: "Terminänderung bestätigt",
|
||||
text: `Hallo ${updated.customerName}, dein neuer Termin ist am ${formatDateGerman(updated.appointmentDate)} um ${updated.appointmentTime}.`,
|
||||
html,
|
||||
}, {
|
||||
date: updated.appointmentDate,
|
||||
time: updated.appointmentTime,
|
||||
durationMinutes: duration,
|
||||
customerName: updated.customerName,
|
||||
treatmentName: (await treatmentsKV.getItem(updated.treatmentId))?.name || "Behandlung",
|
||||
}).catch(() => { });
|
||||
}
|
||||
if (process.env.ADMIN_EMAIL) {
|
||||
const adminHtml = await renderAdminRescheduleAcceptedHTML({
|
||||
customerName: updated.customerName,
|
||||
originalDate: proposal.original.date,
|
||||
originalTime: proposal.original.time,
|
||||
newDate: updated.appointmentDate,
|
||||
newTime: updated.appointmentTime,
|
||||
treatmentName: (await treatmentsKV.getItem(updated.treatmentId))?.name || "Behandlung",
|
||||
});
|
||||
await sendEmail({
|
||||
to: process.env.ADMIN_EMAIL,
|
||||
subject: `Reschedule akzeptiert - ${updated.customerName}`,
|
||||
text: `Reschedule akzeptiert: ${updated.customerName} von ${formatDateGerman(proposal.original.date)} ${proposal.original.time} auf ${formatDateGerman(updated.appointmentDate)} ${updated.appointmentTime}.`,
|
||||
html: adminHtml,
|
||||
}).catch(() => { });
|
||||
}
|
||||
return { success: true, message: `Termin auf ${formatDateGerman(updated.appointmentDate)} um ${updated.appointmentTime} aktualisiert.` };
|
||||
}),
|
||||
// Customer declines reschedule via token
|
||||
declineReschedule: os
|
||||
.input(z.object({ token: z.string() }))
|
||||
.handler(async ({ input }) => {
|
||||
const proposal = await queryClient.cancellation.getRescheduleProposal({ token: input.token });
|
||||
const booking = await kv.getItem(proposal.booking.id);
|
||||
if (!booking)
|
||||
throw new Error("Booking not found");
|
||||
// Remove token
|
||||
await queryClient.cancellation.removeRescheduleToken({ token: input.token });
|
||||
// Notify customer that original stays
|
||||
if (booking.customerEmail) {
|
||||
const bookingAccessToken = await queryClient.cancellation.createToken({ bookingId: booking.id });
|
||||
await sendEmail({
|
||||
to: booking.customerEmail,
|
||||
subject: "Terminänderung abgelehnt",
|
||||
text: `Du hast den Vorschlag zur Terminänderung abgelehnt. Dein ursprünglicher Termin am ${formatDateGerman(booking.appointmentDate)} um ${booking.appointmentTime} bleibt bestehen.`,
|
||||
html: await renderBookingConfirmedHTML({ name: booking.customerName, date: booking.appointmentDate, time: booking.appointmentTime, cancellationUrl: generateUrl(`/booking/${bookingAccessToken.token}`), reviewUrl: generateUrl(`/review/${bookingAccessToken.token}`) }),
|
||||
}).catch(() => { });
|
||||
}
|
||||
// Notify admin
|
||||
if (process.env.ADMIN_EMAIL) {
|
||||
const html = await renderAdminRescheduleDeclinedHTML({
|
||||
customerName: booking.customerName,
|
||||
originalDate: proposal.original.date,
|
||||
originalTime: proposal.original.time,
|
||||
proposedDate: proposal.proposed.date,
|
||||
proposedTime: proposal.proposed.time,
|
||||
treatmentName: (await treatmentsKV.getItem(booking.treatmentId))?.name || "Behandlung",
|
||||
customerEmail: booking.customerEmail,
|
||||
customerPhone: booking.customerPhone,
|
||||
});
|
||||
await sendEmail({
|
||||
to: process.env.ADMIN_EMAIL,
|
||||
subject: `Reschedule abgelehnt - ${booking.customerName}`,
|
||||
text: `Abgelehnt: ${booking.customerName}. Ursprünglich: ${formatDateGerman(proposal.original.date)} ${proposal.original.time}. Vorschlag: ${formatDateGerman(proposal.proposed.date)} ${proposal.proposed.time}.`,
|
||||
html,
|
||||
}).catch(() => { });
|
||||
}
|
||||
return { success: true, message: "Du hast den Vorschlag abgelehnt. Dein ursprünglicher Termin bleibt bestehen." };
|
||||
}),
|
||||
// CalDAV Token für Admin generieren
|
||||
generateCalDAVToken: os
|
||||
.input(z.object({ sessionId: z.string() }))
|
||||
.handler(async ({ input }) => {
|
||||
await assertOwner(input.sessionId);
|
||||
// Generiere einen sicheren Token für CalDAV-Zugriff
|
||||
const token = randomUUID();
|
||||
// Hole Session-Daten für Token-Erstellung
|
||||
const session = await sessionsKV.getItem(input.sessionId);
|
||||
if (!session)
|
||||
throw new Error("Session nicht gefunden");
|
||||
// Speichere Token mit Ablaufzeit (24 Stunden)
|
||||
const tokenData = {
|
||||
id: token,
|
||||
sessionId: input.sessionId,
|
||||
userId: session.userId, // Benötigt für Session-Typ
|
||||
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), // 24 Stunden
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
// Verwende den sessionsKV Store für Token-Speicherung
|
||||
await sessionsKV.setItem(token, tokenData);
|
||||
const domain = process.env.DOMAIN || 'localhost:3000';
|
||||
const protocol = domain.includes('localhost') ? 'http' : 'https';
|
||||
const caldavUrl = `${protocol}://${domain}/caldav/calendar/events.ics?token=${token}`;
|
||||
return {
|
||||
token,
|
||||
caldavUrl,
|
||||
expiresAt: tokenData.expiresAt,
|
||||
instructions: {
|
||||
title: "CalDAV-Kalender abonnieren",
|
||||
steps: [
|
||||
"Kopiere die CalDAV-URL unten",
|
||||
"Füge sie in deiner Kalender-App als Abonnement hinzu:",
|
||||
"- Outlook: Datei → Konto hinzufügen → Internetkalender",
|
||||
"- Google Calendar: Andere Kalender hinzufügen → Von URL",
|
||||
"- Apple Calendar: Abonnement → Neue Abonnements",
|
||||
"- Thunderbird: Kalender hinzufügen → Im Netzwerk",
|
||||
"Der Kalender wird automatisch aktualisiert"
|
||||
],
|
||||
note: "Dieser Token ist 24 Stunden gültig. Bei Bedarf kannst du einen neuen Token generieren."
|
||||
}
|
||||
};
|
||||
}),
|
||||
};
|
310
server-dist/rpc/cancellation.js
Normal file
310
server-dist/rpc/cancellation.js
Normal file
@@ -0,0 +1,310 @@
|
||||
import { os } from "@orpc/server";
|
||||
import { z } from "zod";
|
||||
import { createKV } from "../lib/create-kv.js";
|
||||
import { createKV as createAvailabilityKV } from "../lib/create-kv.js";
|
||||
import { randomUUID } from "crypto";
|
||||
// Schema for booking access token (used for both status viewing and cancellation)
|
||||
const BookingAccessTokenSchema = z.object({
|
||||
id: z.string(),
|
||||
bookingId: z.string(),
|
||||
token: z.string(),
|
||||
expiresAt: z.string(),
|
||||
createdAt: z.string(),
|
||||
purpose: z.enum(["booking_access", "reschedule_proposal"]), // Extended for reschedule proposals
|
||||
// Optional metadata for reschedule proposals
|
||||
proposedDate: z.string().optional(),
|
||||
proposedTime: z.string().optional(),
|
||||
originalDate: z.string().optional(),
|
||||
originalTime: z.string().optional(),
|
||||
});
|
||||
const cancellationKV = createKV("cancellation_tokens");
|
||||
const bookingsKV = createKV("bookings");
|
||||
const availabilityKV = createAvailabilityKV("availability");
|
||||
// Helper function to convert date from yyyy-mm-dd to dd.mm.yyyy
|
||||
function formatDateGerman(dateString) {
|
||||
const [year, month, day] = dateString.split('-');
|
||||
return `${day}.${month}.${year}`;
|
||||
}
|
||||
// Helper to invalidate all reschedule tokens for a specific booking
|
||||
async function invalidateRescheduleTokensForBooking(bookingId) {
|
||||
const tokens = await cancellationKV.getAllItems();
|
||||
const related = tokens.filter(t => t.bookingId === bookingId && t.purpose === "reschedule_proposal");
|
||||
for (const tok of related) {
|
||||
await cancellationKV.removeItem(tok.id);
|
||||
}
|
||||
}
|
||||
// Create cancellation token for a booking
|
||||
const createToken = os
|
||||
.input(z.object({ bookingId: z.string() }))
|
||||
.handler(async ({ input }) => {
|
||||
const booking = await bookingsKV.getItem(input.bookingId);
|
||||
if (!booking) {
|
||||
throw new Error("Booking not found");
|
||||
}
|
||||
if (booking.status === "cancelled") {
|
||||
throw new Error("Booking is already cancelled");
|
||||
}
|
||||
// Create token that expires in 30 days
|
||||
const expiresAt = new Date();
|
||||
expiresAt.setDate(expiresAt.getDate() + 30);
|
||||
const token = randomUUID();
|
||||
const cancellationToken = {
|
||||
id: randomUUID(),
|
||||
bookingId: input.bookingId,
|
||||
token,
|
||||
expiresAt: expiresAt.toISOString(),
|
||||
createdAt: new Date().toISOString(),
|
||||
purpose: "booking_access",
|
||||
};
|
||||
await cancellationKV.setItem(cancellationToken.id, cancellationToken);
|
||||
return { token, expiresAt: expiresAt.toISOString() };
|
||||
});
|
||||
// Get booking details by token
|
||||
const getBookingByToken = os
|
||||
.input(z.object({ token: z.string() }))
|
||||
.handler(async ({ input }) => {
|
||||
const tokens = await cancellationKV.getAllItems();
|
||||
const validToken = tokens.find(t => t.token === input.token &&
|
||||
new Date(t.expiresAt) > new Date() &&
|
||||
t.purpose === 'booking_access');
|
||||
if (!validToken) {
|
||||
throw new Error("Invalid or expired cancellation token");
|
||||
}
|
||||
const booking = await bookingsKV.getItem(validToken.bookingId);
|
||||
if (!booking) {
|
||||
throw new Error("Booking not found");
|
||||
}
|
||||
// Get treatment details
|
||||
const treatmentsKV = createKV("treatments");
|
||||
const treatment = await treatmentsKV.getItem(booking.treatmentId);
|
||||
// Calculate if cancellation is still possible
|
||||
const minStornoTimespan = parseInt(process.env.MIN_STORNO_TIMESPAN || "24");
|
||||
const appointmentDateTime = new Date(`${booking.appointmentDate}T${booking.appointmentTime}:00`);
|
||||
const now = new Date();
|
||||
const timeDifferenceHours = (appointmentDateTime.getTime() - now.getTime()) / (1000 * 60 * 60);
|
||||
const canCancel = timeDifferenceHours >= minStornoTimespan &&
|
||||
booking.status !== "cancelled" &&
|
||||
booking.status !== "completed";
|
||||
return {
|
||||
id: booking.id,
|
||||
customerName: booking.customerName,
|
||||
customerEmail: booking.customerEmail,
|
||||
customerPhone: booking.customerPhone,
|
||||
appointmentDate: booking.appointmentDate,
|
||||
appointmentTime: booking.appointmentTime,
|
||||
treatmentId: booking.treatmentId,
|
||||
treatmentName: treatment?.name || "Unbekannte Behandlung",
|
||||
treatmentDuration: treatment?.duration || 60,
|
||||
treatmentPrice: treatment?.price || 0,
|
||||
status: booking.status,
|
||||
notes: booking.notes,
|
||||
formattedDate: formatDateGerman(booking.appointmentDate),
|
||||
createdAt: booking.createdAt,
|
||||
canCancel,
|
||||
hoursUntilAppointment: Math.max(0, Math.round(timeDifferenceHours)),
|
||||
};
|
||||
});
|
||||
// Cancel booking by token
|
||||
const cancelByToken = os
|
||||
.input(z.object({ token: z.string() }))
|
||||
.handler(async ({ input }) => {
|
||||
const tokens = await cancellationKV.getAllItems();
|
||||
const validToken = tokens.find(t => t.token === input.token &&
|
||||
new Date(t.expiresAt) > new Date());
|
||||
if (!validToken) {
|
||||
throw new Error("Invalid or expired cancellation token");
|
||||
}
|
||||
const booking = await bookingsKV.getItem(validToken.bookingId);
|
||||
if (!booking) {
|
||||
throw new Error("Booking not found");
|
||||
}
|
||||
// Check if booking is already cancelled
|
||||
if (booking.status === "cancelled") {
|
||||
throw new Error("Booking is already cancelled");
|
||||
}
|
||||
// Check minimum cancellation timespan from environment variable
|
||||
const minStornoTimespan = parseInt(process.env.MIN_STORNO_TIMESPAN || "24"); // Default: 24 hours
|
||||
const appointmentDateTime = new Date(`${booking.appointmentDate}T${booking.appointmentTime}:00`);
|
||||
const now = new Date();
|
||||
const timeDifferenceHours = (appointmentDateTime.getTime() - now.getTime()) / (1000 * 60 * 60);
|
||||
if (timeDifferenceHours < minStornoTimespan) {
|
||||
throw new Error(`Stornierung ist nur bis ${minStornoTimespan} Stunden vor dem Termin möglich. Der Termin liegt nur noch ${Math.round(timeDifferenceHours)} Stunden in der Zukunft.`);
|
||||
}
|
||||
// Check if booking is in the past (additional safety check)
|
||||
const today = new Date().toISOString().split("T")[0];
|
||||
if (booking.appointmentDate < today) {
|
||||
throw new Error("Cannot cancel past bookings");
|
||||
}
|
||||
// For today's bookings, check if the time is not in the past
|
||||
if (booking.appointmentDate === today) {
|
||||
const currentTime = `${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}`;
|
||||
if (booking.appointmentTime <= currentTime) {
|
||||
throw new Error("Cannot cancel bookings that have already started");
|
||||
}
|
||||
}
|
||||
// Update booking status
|
||||
const updatedBooking = { ...booking, status: "cancelled" };
|
||||
await bookingsKV.setItem(booking.id, updatedBooking);
|
||||
// Free the slot if it exists
|
||||
if (booking.slotId) {
|
||||
const slot = await availabilityKV.getItem(booking.slotId);
|
||||
if (slot) {
|
||||
const updatedSlot = {
|
||||
...slot,
|
||||
status: "free",
|
||||
reservedByBookingId: undefined,
|
||||
};
|
||||
await availabilityKV.setItem(slot.id, updatedSlot);
|
||||
}
|
||||
}
|
||||
// Invalidate the token
|
||||
await cancellationKV.removeItem(validToken.id);
|
||||
return {
|
||||
success: true,
|
||||
message: "Booking cancelled successfully",
|
||||
formattedDate: formatDateGerman(booking.appointmentDate),
|
||||
};
|
||||
});
|
||||
export const router = {
|
||||
createToken,
|
||||
getBookingByToken,
|
||||
cancelByToken,
|
||||
// Create a reschedule proposal token (48h expiry)
|
||||
createRescheduleToken: os
|
||||
.input(z.object({ bookingId: z.string(), proposedDate: z.string(), proposedTime: z.string() }))
|
||||
.handler(async ({ input }) => {
|
||||
const booking = await bookingsKV.getItem(input.bookingId);
|
||||
if (!booking) {
|
||||
throw new Error("Booking not found");
|
||||
}
|
||||
if (booking.status === "cancelled" || booking.status === "completed") {
|
||||
throw new Error("Reschedule not allowed for this booking");
|
||||
}
|
||||
// Invalidate existing reschedule proposals for this booking
|
||||
await invalidateRescheduleTokensForBooking(input.bookingId);
|
||||
// Create token that expires in 48 hours
|
||||
const expiresAt = new Date();
|
||||
expiresAt.setHours(expiresAt.getHours() + 48);
|
||||
const token = randomUUID();
|
||||
const rescheduleToken = {
|
||||
id: randomUUID(),
|
||||
bookingId: input.bookingId,
|
||||
token,
|
||||
expiresAt: expiresAt.toISOString(),
|
||||
createdAt: new Date().toISOString(),
|
||||
purpose: "reschedule_proposal",
|
||||
proposedDate: input.proposedDate,
|
||||
proposedTime: input.proposedTime,
|
||||
originalDate: booking.appointmentDate,
|
||||
originalTime: booking.appointmentTime,
|
||||
};
|
||||
await cancellationKV.setItem(rescheduleToken.id, rescheduleToken);
|
||||
return { token, expiresAt: expiresAt.toISOString() };
|
||||
}),
|
||||
// Get reschedule proposal details by token
|
||||
getRescheduleProposal: os
|
||||
.input(z.object({ token: z.string() }))
|
||||
.handler(async ({ input }) => {
|
||||
const tokens = await cancellationKV.getAllItems();
|
||||
const proposal = tokens.find(t => t.token === input.token && t.purpose === "reschedule_proposal");
|
||||
if (!proposal) {
|
||||
throw new Error("Ungültiger Reschedule-Token");
|
||||
}
|
||||
const booking = await bookingsKV.getItem(proposal.bookingId);
|
||||
if (!booking) {
|
||||
throw new Error("Booking not found");
|
||||
}
|
||||
const treatmentsKV = createKV("treatments");
|
||||
const treatment = await treatmentsKV.getItem(booking.treatmentId);
|
||||
const now = new Date();
|
||||
const isExpired = new Date(proposal.expiresAt) <= now;
|
||||
const hoursUntilExpiry = isExpired ? 0 : Math.max(0, Math.round((new Date(proposal.expiresAt).getTime() - now.getTime()) / (1000 * 60 * 60)));
|
||||
return {
|
||||
booking: {
|
||||
id: booking.id,
|
||||
customerName: booking.customerName,
|
||||
customerEmail: booking.customerEmail,
|
||||
customerPhone: booking.customerPhone,
|
||||
status: booking.status,
|
||||
treatmentId: booking.treatmentId,
|
||||
treatmentName: treatment?.name || "Unbekannte Behandlung",
|
||||
},
|
||||
original: {
|
||||
date: proposal.originalDate || booking.appointmentDate,
|
||||
time: proposal.originalTime || booking.appointmentTime,
|
||||
},
|
||||
proposed: {
|
||||
date: proposal.proposedDate,
|
||||
time: proposal.proposedTime,
|
||||
},
|
||||
expiresAt: proposal.expiresAt,
|
||||
hoursUntilExpiry,
|
||||
isExpired,
|
||||
canRespond: booking.status === "confirmed" && !isExpired,
|
||||
};
|
||||
}),
|
||||
// Helper endpoint to remove a reschedule token by value (used after accept/decline)
|
||||
removeRescheduleToken: os
|
||||
.input(z.object({ token: z.string() }))
|
||||
.handler(async ({ input }) => {
|
||||
const tokens = await cancellationKV.getAllItems();
|
||||
const proposal = tokens.find(t => t.token === input.token && t.purpose === "reschedule_proposal");
|
||||
if (proposal) {
|
||||
await cancellationKV.removeItem(proposal.id);
|
||||
}
|
||||
return { success: true };
|
||||
}),
|
||||
// Clean up expired reschedule proposals and notify admin
|
||||
sweepExpiredRescheduleProposals: os
|
||||
.handler(async () => {
|
||||
const tokens = await cancellationKV.getAllItems();
|
||||
const now = new Date();
|
||||
const expiredProposals = tokens.filter(t => t.purpose === "reschedule_proposal" &&
|
||||
new Date(t.expiresAt) <= now);
|
||||
if (expiredProposals.length === 0) {
|
||||
return { success: true, expiredCount: 0 };
|
||||
}
|
||||
// Get booking details for each expired proposal
|
||||
const expiredDetails = [];
|
||||
for (const proposal of expiredProposals) {
|
||||
const booking = await bookingsKV.getItem(proposal.bookingId);
|
||||
if (booking) {
|
||||
const treatmentsKV = createKV("treatments");
|
||||
const treatment = await treatmentsKV.getItem(booking.treatmentId);
|
||||
expiredDetails.push({
|
||||
customerName: booking.customerName,
|
||||
originalDate: proposal.originalDate || booking.appointmentDate,
|
||||
originalTime: proposal.originalTime || booking.appointmentTime,
|
||||
proposedDate: proposal.proposedDate,
|
||||
proposedTime: proposal.proposedTime,
|
||||
treatmentName: treatment?.name || "Unbekannte Behandlung",
|
||||
customerEmail: booking.customerEmail,
|
||||
customerPhone: booking.customerPhone,
|
||||
expiredAt: proposal.expiresAt,
|
||||
});
|
||||
}
|
||||
// Remove the expired token
|
||||
await cancellationKV.removeItem(proposal.id);
|
||||
}
|
||||
// Notify admin if there are expired proposals
|
||||
if (expiredDetails.length > 0 && process.env.ADMIN_EMAIL) {
|
||||
try {
|
||||
const { renderAdminRescheduleExpiredHTML } = await import("../lib/email-templates.js");
|
||||
const { sendEmail } = await import("../lib/email.js");
|
||||
const html = await renderAdminRescheduleExpiredHTML({
|
||||
expiredProposals: expiredDetails,
|
||||
});
|
||||
await sendEmail({
|
||||
to: process.env.ADMIN_EMAIL,
|
||||
subject: `${expiredDetails.length} abgelaufene Terminänderungsvorschläge`,
|
||||
text: `Es sind ${expiredDetails.length} Terminänderungsvorschläge abgelaufen. Details in der HTML-Version.`,
|
||||
html,
|
||||
});
|
||||
}
|
||||
catch (error) {
|
||||
console.error("Failed to send admin notification for expired proposals:", error);
|
||||
}
|
||||
}
|
||||
return { success: true, expiredCount: expiredDetails.length };
|
||||
}),
|
||||
};
|
79
server-dist/rpc/demo/ai.js
Normal file
79
server-dist/rpc/demo/ai.js
Normal file
@@ -0,0 +1,79 @@
|
||||
import OpenAI from "openai";
|
||||
import { os } from "@orpc/server";
|
||||
import { z } from "zod";
|
||||
import { zodResponseFormat } from "../../lib/openai";
|
||||
if (!process.env.OPENAI_BASE_URL) {
|
||||
throw new Error("OPENAI_BASE_URL is not set");
|
||||
}
|
||||
if (!process.env.OPENAI_API_KEY) {
|
||||
throw new Error("OPENAI_API_KEY is not set");
|
||||
}
|
||||
const openai = new OpenAI({
|
||||
baseURL: process.env.OPENAI_BASE_URL,
|
||||
apiKey: process.env.OPENAI_API_KEY,
|
||||
});
|
||||
if (!process.env.OPENAI_DEFAULT_MODEL) {
|
||||
throw new Error("OPENAI_DEFAULT_MODEL is not set");
|
||||
}
|
||||
const DEFAULT_MODEL = process.env.OPENAI_DEFAULT_MODEL;
|
||||
const ChatCompletionInputSchema = z.object({
|
||||
message: z.string(),
|
||||
systemPrompt: z.string().optional(),
|
||||
});
|
||||
const GeneratePersonInputSchema = z.object({
|
||||
prompt: z.string(),
|
||||
});
|
||||
const complete = os
|
||||
.input(ChatCompletionInputSchema)
|
||||
.handler(async ({ input }) => {
|
||||
const { message, systemPrompt } = input;
|
||||
const completion = await openai.chat.completions.create({
|
||||
model: DEFAULT_MODEL,
|
||||
messages: [
|
||||
...(systemPrompt
|
||||
? [{ role: "system", content: systemPrompt }]
|
||||
: []),
|
||||
{ role: "user", content: message },
|
||||
],
|
||||
});
|
||||
return {
|
||||
response: completion.choices[0]?.message?.content || "",
|
||||
};
|
||||
});
|
||||
// Object generation schemas only support nullability, not optionality.
|
||||
// Use .nullable() instead of .optional() for fields that may not have values.
|
||||
const DemoSchema = z.object({
|
||||
name: z.string().describe("The name of the person"),
|
||||
age: z.number().describe("The age of the person"),
|
||||
occupation: z.string().describe("The occupation of the person"),
|
||||
bio: z.string().describe("The bio of the person"),
|
||||
nickname: z
|
||||
.string()
|
||||
.nullable()
|
||||
.describe("The person's nickname, if they have one"),
|
||||
});
|
||||
const generate = os
|
||||
.input(GeneratePersonInputSchema)
|
||||
.handler(async ({ input }) => {
|
||||
const completion = await openai.chat.completions.parse({
|
||||
model: DEFAULT_MODEL,
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: `Generate a person based on this prompt: ${input.prompt}`,
|
||||
},
|
||||
],
|
||||
response_format: zodResponseFormat(DemoSchema, "person"),
|
||||
});
|
||||
const person = completion.choices[0]?.message?.parsed;
|
||||
if (!person) {
|
||||
throw new Error("No parsed data received from OpenAI");
|
||||
}
|
||||
return {
|
||||
person,
|
||||
};
|
||||
});
|
||||
export const router = {
|
||||
complete,
|
||||
generate,
|
||||
};
|
4
server-dist/rpc/demo/index.js
Normal file
4
server-dist/rpc/demo/index.js
Normal file
@@ -0,0 +1,4 @@
|
||||
import { router as storageRouter } from "./storage.js";
|
||||
export const demo = {
|
||||
storage: storageRouter,
|
||||
};
|
42
server-dist/rpc/demo/storage.js
Normal file
42
server-dist/rpc/demo/storage.js
Normal file
@@ -0,0 +1,42 @@
|
||||
import { call, os } from "@orpc/server";
|
||||
import { z } from "zod";
|
||||
import { randomUUID } from "crypto";
|
||||
import { createKV } from "../../lib/create-kv.js";
|
||||
const DemoSchema = z.object({
|
||||
id: z.string(),
|
||||
value: z.string(),
|
||||
});
|
||||
// createKV provides simple key-value storage with publisher/subscriber support
|
||||
// perfect for live queries and small amounts of data
|
||||
const kv = createKV("demo");
|
||||
// Handler with input validation using .input() and schema
|
||||
const create = os
|
||||
.input(DemoSchema.omit({ id: true }))
|
||||
.handler(async ({ input }) => {
|
||||
const id = randomUUID();
|
||||
const item = { id, value: input.value };
|
||||
await kv.setItem(id, item);
|
||||
});
|
||||
const remove = os.input(z.string()).handler(async ({ input }) => {
|
||||
await kv.removeItem(input);
|
||||
});
|
||||
// Handler without input - returns all items
|
||||
const list = os.handler(async () => {
|
||||
return kv.getAllItems();
|
||||
});
|
||||
// Live data stream using generator function
|
||||
// Yields initial data, then subscribes to changes for real-time updates
|
||||
const live = {
|
||||
list: os.handler(async function* ({ signal }) {
|
||||
yield call(list, {}, { signal });
|
||||
for await (const _ of kv.subscribe()) {
|
||||
yield call(list, {}, { signal });
|
||||
}
|
||||
}),
|
||||
};
|
||||
export const router = {
|
||||
create,
|
||||
remove,
|
||||
list,
|
||||
live,
|
||||
};
|
131
server-dist/rpc/gallery.js
Normal file
131
server-dist/rpc/gallery.js
Normal file
@@ -0,0 +1,131 @@
|
||||
import { call, os } from "@orpc/server";
|
||||
import { z } from "zod";
|
||||
import { randomUUID } from "crypto";
|
||||
import { createKV } from "../lib/create-kv.js";
|
||||
import { assertOwner } from "../lib/auth.js";
|
||||
// Schema Definition
|
||||
const GalleryPhotoSchema = z.object({
|
||||
id: z.string(),
|
||||
base64Data: z.string(),
|
||||
title: z.string().optional().default(""),
|
||||
order: z.number().int(),
|
||||
createdAt: z.string(),
|
||||
cover: z.boolean().optional().default(false),
|
||||
});
|
||||
// KV Storage
|
||||
const galleryPhotosKV = createKV("galleryPhotos");
|
||||
// Authentication centralized in ../lib/auth.ts
|
||||
// CRUD Endpoints
|
||||
const uploadPhoto = os
|
||||
.input(z.object({
|
||||
sessionId: z.string(),
|
||||
base64Data: z
|
||||
.string()
|
||||
.regex(/^data:image\/(png|jpe?g|webp|gif);base64,/i, 'Unsupported image format'),
|
||||
title: z.string().optional().default(""),
|
||||
}))
|
||||
.handler(async ({ input }) => {
|
||||
try {
|
||||
await assertOwner(input.sessionId);
|
||||
const id = randomUUID();
|
||||
const existing = await galleryPhotosKV.getAllItems();
|
||||
const maxOrder = existing.length > 0 ? Math.max(...existing.map((p) => p.order)) : -1;
|
||||
const nextOrder = maxOrder + 1;
|
||||
const photo = {
|
||||
id,
|
||||
base64Data: input.base64Data,
|
||||
title: input.title ?? "",
|
||||
order: nextOrder,
|
||||
createdAt: new Date().toISOString(),
|
||||
cover: false,
|
||||
};
|
||||
await galleryPhotosKV.setItem(id, photo);
|
||||
return photo;
|
||||
}
|
||||
catch (err) {
|
||||
console.error("gallery.uploadPhoto error", err);
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
const setCoverPhoto = os
|
||||
.input(z.object({ sessionId: z.string(), id: z.string() }))
|
||||
.handler(async ({ input }) => {
|
||||
await assertOwner(input.sessionId);
|
||||
const all = await galleryPhotosKV.getAllItems();
|
||||
let updatedCover = null;
|
||||
for (const p of all) {
|
||||
const isCover = p.id === input.id;
|
||||
const next = { ...p, cover: isCover };
|
||||
await galleryPhotosKV.setItem(p.id, next);
|
||||
if (isCover)
|
||||
updatedCover = next;
|
||||
}
|
||||
return updatedCover;
|
||||
});
|
||||
const deletePhoto = os
|
||||
.input(z.object({ sessionId: z.string(), id: z.string() }))
|
||||
.handler(async ({ input }) => {
|
||||
await assertOwner(input.sessionId);
|
||||
await galleryPhotosKV.removeItem(input.id);
|
||||
});
|
||||
const updatePhotoOrder = os
|
||||
.input(z.object({
|
||||
sessionId: z.string(),
|
||||
photoOrders: z.array(z.object({ id: z.string(), order: z.number().int() })),
|
||||
}))
|
||||
.handler(async ({ input }) => {
|
||||
await assertOwner(input.sessionId);
|
||||
const updated = [];
|
||||
for (const { id, order } of input.photoOrders) {
|
||||
const existing = await galleryPhotosKV.getItem(id);
|
||||
if (!existing)
|
||||
continue;
|
||||
const updatedPhoto = { ...existing, order };
|
||||
await galleryPhotosKV.setItem(id, updatedPhoto);
|
||||
updated.push(updatedPhoto);
|
||||
}
|
||||
const all = await galleryPhotosKV.getAllItems();
|
||||
return all.sort((a, b) => (a.order - b.order) || a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id));
|
||||
});
|
||||
const listPhotos = os.handler(async () => {
|
||||
const all = await galleryPhotosKV.getAllItems();
|
||||
return all.sort((a, b) => (a.order - b.order) || a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id));
|
||||
});
|
||||
const adminListPhotos = os
|
||||
.input(z.object({ sessionId: z.string() }))
|
||||
.handler(async ({ input }) => {
|
||||
await assertOwner(input.sessionId);
|
||||
const all = await galleryPhotosKV.getAllItems();
|
||||
return all.sort((a, b) => (a.order - b.order) || a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id));
|
||||
});
|
||||
// Live Queries
|
||||
const live = {
|
||||
listPhotos: os.handler(async function* ({ signal }) {
|
||||
yield call(listPhotos, {}, { signal });
|
||||
for await (const _ of galleryPhotosKV.subscribe()) {
|
||||
yield call(listPhotos, {}, { signal });
|
||||
}
|
||||
}),
|
||||
adminListPhotos: os
|
||||
.input(z.object({ sessionId: z.string() }))
|
||||
.handler(async function* ({ input, signal }) {
|
||||
await assertOwner(input.sessionId);
|
||||
const all = await galleryPhotosKV.getAllItems();
|
||||
const sorted = all.sort((a, b) => (a.order - b.order) || a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id));
|
||||
yield sorted;
|
||||
for await (const _ of galleryPhotosKV.subscribe()) {
|
||||
const updated = await galleryPhotosKV.getAllItems();
|
||||
const sortedUpdated = updated.sort((a, b) => (a.order - b.order) || a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id));
|
||||
yield sortedUpdated;
|
||||
}
|
||||
}),
|
||||
};
|
||||
export const router = {
|
||||
uploadPhoto,
|
||||
deletePhoto,
|
||||
updatePhotoOrder,
|
||||
listPhotos,
|
||||
adminListPhotos,
|
||||
setCoverPhoto,
|
||||
live,
|
||||
};
|
20
server-dist/rpc/index.js
Normal file
20
server-dist/rpc/index.js
Normal file
@@ -0,0 +1,20 @@
|
||||
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 recurringAvailability } from "./recurring-availability.js";
|
||||
import { router as cancellation } from "./cancellation.js";
|
||||
import { router as legal } from "./legal.js";
|
||||
import { router as gallery } from "./gallery.js";
|
||||
import { router as reviews } from "./reviews.js";
|
||||
export const router = {
|
||||
demo,
|
||||
treatments,
|
||||
bookings,
|
||||
auth,
|
||||
recurringAvailability,
|
||||
cancellation,
|
||||
legal,
|
||||
gallery,
|
||||
reviews,
|
||||
};
|
16
server-dist/rpc/legal.js
Normal file
16
server-dist/rpc/legal.js
Normal file
@@ -0,0 +1,16 @@
|
||||
import { os } from "@orpc/server";
|
||||
import { getLegalConfig } from "../lib/legal-config.js";
|
||||
export const router = {
|
||||
getConfig: os.handler(async () => {
|
||||
console.log("Legal getConfig called");
|
||||
try {
|
||||
const config = getLegalConfig();
|
||||
console.log("Legal config:", config);
|
||||
return config;
|
||||
}
|
||||
catch (error) {
|
||||
console.error("Legal config error:", error);
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
};
|
396
server-dist/rpc/recurring-availability.js
Normal file
396
server-dist/rpc/recurring-availability.js
Normal file
@@ -0,0 +1,396 @@
|
||||
import { call, os } from "@orpc/server";
|
||||
import { z } from "zod";
|
||||
import { randomUUID } from "crypto";
|
||||
import { createKV } from "../lib/create-kv.js";
|
||||
import { assertOwner } from "../lib/auth.js";
|
||||
// Datenmodelle
|
||||
const RecurringRuleSchema = z.object({
|
||||
id: z.string(),
|
||||
dayOfWeek: z.number().int().min(0).max(6), // 0=Sonntag, 1=Montag, ..., 6=Samstag
|
||||
startTime: z.string().regex(/^\d{2}:\d{2}$/), // HH:MM Format
|
||||
endTime: z.string().regex(/^\d{2}:\d{2}$/), // HH:MM Format
|
||||
isActive: z.boolean(),
|
||||
createdAt: z.string(),
|
||||
// LEGACY: slotDurationMinutes - deprecated field for generateSlots only, will be removed
|
||||
slotDurationMinutes: z.number().int().min(1).optional(),
|
||||
});
|
||||
const TimeOffPeriodSchema = z.object({
|
||||
id: z.string(),
|
||||
startDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), // YYYY-MM-DD
|
||||
endDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), // YYYY-MM-DD
|
||||
reason: z.string(),
|
||||
createdAt: z.string(),
|
||||
});
|
||||
// KV-Stores
|
||||
const recurringRulesKV = createKV("recurringRules");
|
||||
const timeOffPeriodsKV = createKV("timeOffPeriods");
|
||||
// Import bookings and treatments KV stores for getAvailableTimes endpoint
|
||||
const bookingsKV = createKV("bookings");
|
||||
const treatmentsKV = createKV("treatments");
|
||||
// Owner-Authentifizierung zentralisiert in ../lib/auth.ts
|
||||
// Helper-Funktionen
|
||||
function parseTime(timeStr) {
|
||||
const [hours, minutes] = timeStr.split(':').map(Number);
|
||||
return hours * 60 + minutes; // Minuten seit Mitternacht
|
||||
}
|
||||
function formatTime(minutes) {
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const mins = minutes % 60;
|
||||
return `${String(hours).padStart(2, '0')}:${String(mins).padStart(2, '0')}`;
|
||||
}
|
||||
function addDays(date, days) {
|
||||
const result = new Date(date);
|
||||
result.setDate(result.getDate() + days);
|
||||
return result;
|
||||
}
|
||||
function formatDate(date) {
|
||||
return date.toISOString().split('T')[0];
|
||||
}
|
||||
function isDateInTimeOffPeriod(date, periods) {
|
||||
return periods.some(period => date >= period.startDate && date <= period.endDate);
|
||||
}
|
||||
// Helper-Funktion zur Erkennung überlappender Regeln
|
||||
function detectOverlappingRules(newRule, existingRules) {
|
||||
const newStart = parseTime(newRule.startTime);
|
||||
const newEnd = parseTime(newRule.endTime);
|
||||
return existingRules.filter(rule => {
|
||||
// Gleicher Wochentag und nicht dieselbe Regel (bei Updates)
|
||||
if (rule.dayOfWeek !== newRule.dayOfWeek || rule.id === newRule.id) {
|
||||
return false;
|
||||
}
|
||||
const existingStart = parseTime(rule.startTime);
|
||||
const existingEnd = parseTime(rule.endTime);
|
||||
// Überlappung wenn: neue Startzeit < bestehende Endzeit UND neue Endzeit > bestehende Startzeit
|
||||
return newStart < existingEnd && newEnd > existingStart;
|
||||
});
|
||||
}
|
||||
// CRUD-Endpoints für Recurring Rules
|
||||
const createRule = os
|
||||
.input(z.object({
|
||||
sessionId: z.string(),
|
||||
dayOfWeek: z.number().int().min(0).max(6),
|
||||
startTime: z.string().regex(/^\d{2}:\d{2}$/),
|
||||
endTime: z.string().regex(/^\d{2}:\d{2}$/),
|
||||
}).passthrough())
|
||||
.handler(async ({ input }) => {
|
||||
try {
|
||||
await assertOwner(input.sessionId);
|
||||
// Validierung: startTime < endTime
|
||||
const startMinutes = parseTime(input.startTime);
|
||||
const endMinutes = parseTime(input.endTime);
|
||||
if (startMinutes >= endMinutes) {
|
||||
throw new Error("Startzeit muss vor der Endzeit liegen.");
|
||||
}
|
||||
// Überlappungsprüfung
|
||||
const allRules = await recurringRulesKV.getAllItems();
|
||||
const overlappingRules = detectOverlappingRules(input, allRules);
|
||||
if (overlappingRules.length > 0) {
|
||||
const overlappingTimes = overlappingRules.map(rule => `${rule.startTime}-${rule.endTime}`).join(", ");
|
||||
throw new Error(`Überlappung mit bestehenden Regeln erkannt: ${overlappingTimes}. Bitte Zeitfenster anpassen.`);
|
||||
}
|
||||
const id = randomUUID();
|
||||
const rule = {
|
||||
id,
|
||||
dayOfWeek: input.dayOfWeek,
|
||||
startTime: input.startTime,
|
||||
endTime: input.endTime,
|
||||
isActive: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
await recurringRulesKV.setItem(id, rule);
|
||||
return rule;
|
||||
}
|
||||
catch (err) {
|
||||
console.error("recurring-availability.createRule error", err);
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
const updateRule = os
|
||||
.input(RecurringRuleSchema.extend({ sessionId: z.string() }).passthrough())
|
||||
.handler(async ({ input }) => {
|
||||
await assertOwner(input.sessionId);
|
||||
// Validierung: startTime < endTime
|
||||
const startMinutes = parseTime(input.startTime);
|
||||
const endMinutes = parseTime(input.endTime);
|
||||
if (startMinutes >= endMinutes) {
|
||||
throw new Error("Startzeit muss vor der Endzeit liegen.");
|
||||
}
|
||||
// Überlappungsprüfung
|
||||
const allRules = await recurringRulesKV.getAllItems();
|
||||
const overlappingRules = detectOverlappingRules(input, allRules);
|
||||
if (overlappingRules.length > 0) {
|
||||
const overlappingTimes = overlappingRules.map(rule => `${rule.startTime}-${rule.endTime}`).join(", ");
|
||||
throw new Error(`Überlappung mit bestehenden Regeln erkannt: ${overlappingTimes}. Bitte Zeitfenster anpassen.`);
|
||||
}
|
||||
const { sessionId, ...rule } = input;
|
||||
await recurringRulesKV.setItem(rule.id, rule);
|
||||
return rule;
|
||||
});
|
||||
const deleteRule = os
|
||||
.input(z.object({ sessionId: z.string(), id: z.string() }))
|
||||
.handler(async ({ input }) => {
|
||||
await assertOwner(input.sessionId);
|
||||
await recurringRulesKV.removeItem(input.id);
|
||||
});
|
||||
const toggleRuleActive = os
|
||||
.input(z.object({ sessionId: z.string(), id: z.string() }))
|
||||
.handler(async ({ input }) => {
|
||||
await assertOwner(input.sessionId);
|
||||
const rule = await recurringRulesKV.getItem(input.id);
|
||||
if (!rule)
|
||||
throw new Error("Regel nicht gefunden.");
|
||||
rule.isActive = !rule.isActive;
|
||||
await recurringRulesKV.setItem(input.id, rule);
|
||||
return rule;
|
||||
});
|
||||
const listRules = os.handler(async () => {
|
||||
const allRules = await recurringRulesKV.getAllItems();
|
||||
return allRules.sort((a, b) => {
|
||||
if (a.dayOfWeek !== b.dayOfWeek)
|
||||
return a.dayOfWeek - b.dayOfWeek;
|
||||
return a.startTime.localeCompare(b.startTime);
|
||||
});
|
||||
});
|
||||
const adminListRules = os
|
||||
.input(z.object({ sessionId: z.string() }))
|
||||
.handler(async ({ input }) => {
|
||||
await assertOwner(input.sessionId);
|
||||
const allRules = await recurringRulesKV.getAllItems();
|
||||
return allRules.sort((a, b) => {
|
||||
if (a.dayOfWeek !== b.dayOfWeek)
|
||||
return a.dayOfWeek - b.dayOfWeek;
|
||||
return a.startTime.localeCompare(b.startTime);
|
||||
});
|
||||
});
|
||||
// CRUD-Endpoints für Time-Off Periods
|
||||
const createTimeOff = 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}$/),
|
||||
reason: z.string(),
|
||||
}))
|
||||
.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.");
|
||||
}
|
||||
const id = randomUUID();
|
||||
const timeOff = {
|
||||
id,
|
||||
startDate: input.startDate,
|
||||
endDate: input.endDate,
|
||||
reason: input.reason,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
await timeOffPeriodsKV.setItem(id, timeOff);
|
||||
return timeOff;
|
||||
}
|
||||
catch (err) {
|
||||
console.error("recurring-availability.createTimeOff error", err);
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
const updateTimeOff = os
|
||||
.input(TimeOffPeriodSchema.extend({ sessionId: z.string() }).passthrough())
|
||||
.handler(async ({ input }) => {
|
||||
await assertOwner(input.sessionId);
|
||||
// Validierung: startDate <= endDate
|
||||
if (input.startDate > input.endDate) {
|
||||
throw new Error("Startdatum muss vor oder am Enddatum liegen.");
|
||||
}
|
||||
const { sessionId, ...timeOff } = input;
|
||||
await timeOffPeriodsKV.setItem(timeOff.id, timeOff);
|
||||
return timeOff;
|
||||
});
|
||||
const deleteTimeOff = os
|
||||
.input(z.object({ sessionId: z.string(), id: z.string() }))
|
||||
.handler(async ({ input }) => {
|
||||
await assertOwner(input.sessionId);
|
||||
await timeOffPeriodsKV.removeItem(input.id);
|
||||
});
|
||||
const listTimeOff = os.handler(async () => {
|
||||
const allTimeOff = await timeOffPeriodsKV.getAllItems();
|
||||
return allTimeOff.sort((a, b) => a.startDate.localeCompare(b.startDate));
|
||||
});
|
||||
const adminListTimeOff = os
|
||||
.input(z.object({ sessionId: z.string() }))
|
||||
.handler(async ({ input }) => {
|
||||
await assertOwner(input.sessionId);
|
||||
const allTimeOff = await timeOffPeriodsKV.getAllItems();
|
||||
return allTimeOff.sort((a, b) => a.startDate.localeCompare(b.startDate));
|
||||
});
|
||||
// Get Available Times Endpoint
|
||||
const getAvailableTimes = os
|
||||
.input(z.object({
|
||||
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||
treatmentId: z.string(),
|
||||
}))
|
||||
.handler(async ({ input }) => {
|
||||
try {
|
||||
// Validate that the date is not in the past
|
||||
const today = new Date();
|
||||
const inputDate = new Date(input.date);
|
||||
today.setHours(0, 0, 0, 0);
|
||||
inputDate.setHours(0, 0, 0, 0);
|
||||
if (inputDate < today) {
|
||||
return [];
|
||||
}
|
||||
// Get treatment duration
|
||||
const treatment = await treatmentsKV.getItem(input.treatmentId);
|
||||
if (!treatment) {
|
||||
throw new Error("Behandlung nicht gefunden.");
|
||||
}
|
||||
const treatmentDuration = treatment.duration;
|
||||
// Parse the date to get day of week
|
||||
const [year, month, day] = input.date.split('-').map(Number);
|
||||
const localDate = new Date(year, month - 1, day);
|
||||
const dayOfWeek = localDate.getDay(); // 0=Sonntag, 1=Montag, ...
|
||||
// Find matching recurring rules
|
||||
const allRules = await recurringRulesKV.getAllItems();
|
||||
const matchingRules = allRules.filter(rule => rule.isActive === true && rule.dayOfWeek === dayOfWeek);
|
||||
if (matchingRules.length === 0) {
|
||||
return []; // No rules for this day of week
|
||||
}
|
||||
// Check time-off periods
|
||||
const timeOffPeriods = await timeOffPeriodsKV.getAllItems();
|
||||
if (isDateInTimeOffPeriod(input.date, timeOffPeriods)) {
|
||||
return []; // Date is blocked by time-off period
|
||||
}
|
||||
// Generate 15-minute intervals with boundary alignment
|
||||
const availableTimes = [];
|
||||
// Helper functions for 15-minute boundary alignment
|
||||
const ceilTo15 = (m) => m % 15 === 0 ? m : m + (15 - (m % 15));
|
||||
const floorTo15 = (m) => m - (m % 15);
|
||||
for (const rule of matchingRules) {
|
||||
const startMinutes = parseTime(rule.startTime);
|
||||
const endMinutes = parseTime(rule.endTime);
|
||||
let currentMinutes = ceilTo15(startMinutes);
|
||||
const endBound = floorTo15(endMinutes);
|
||||
while (currentMinutes + treatmentDuration <= endBound) {
|
||||
const timeStr = formatTime(currentMinutes);
|
||||
availableTimes.push(timeStr);
|
||||
currentMinutes += 15; // 15-minute intervals
|
||||
}
|
||||
}
|
||||
// Get all bookings for this date and their treatments
|
||||
const allBookings = await bookingsKV.getAllItems();
|
||||
const dateBookings = allBookings.filter(booking => booking.appointmentDate === input.date &&
|
||||
['pending', 'confirmed', 'completed'].includes(booking.status));
|
||||
// Optimize treatment duration lookup with Map caching
|
||||
const uniqueTreatmentIds = [...new Set(dateBookings.map(booking => booking.treatmentId))];
|
||||
const treatmentDurationMap = new Map();
|
||||
for (const treatmentId of uniqueTreatmentIds) {
|
||||
const treatment = await treatmentsKV.getItem(treatmentId);
|
||||
treatmentDurationMap.set(treatmentId, treatment?.duration || 60);
|
||||
}
|
||||
// Get treatment durations for all bookings using the cached map
|
||||
const bookingTreatments = new Map();
|
||||
for (const booking of dateBookings) {
|
||||
// Use bookedDurationMinutes if available, otherwise fallback to treatment duration
|
||||
const duration = booking.bookedDurationMinutes || treatmentDurationMap.get(booking.treatmentId) || 60;
|
||||
bookingTreatments.set(booking.id, duration);
|
||||
}
|
||||
// Filter out booking conflicts
|
||||
const availableTimesFiltered = availableTimes.filter(slotTime => {
|
||||
const slotStartMinutes = parseTime(slotTime);
|
||||
const slotEndMinutes = slotStartMinutes + treatmentDuration;
|
||||
// Check if this slot overlaps with any existing booking
|
||||
const hasConflict = dateBookings.some(booking => {
|
||||
const bookingStartMinutes = parseTime(booking.appointmentTime);
|
||||
const bookingDuration = bookingTreatments.get(booking.id) || 60;
|
||||
const bookingEndMinutes = bookingStartMinutes + bookingDuration;
|
||||
// Check overlap: slotStart < bookingEnd && slotEnd > bookingStart
|
||||
return slotStartMinutes < bookingEndMinutes && slotEndMinutes > bookingStartMinutes;
|
||||
});
|
||||
return !hasConflict;
|
||||
});
|
||||
// Filter out past times for today
|
||||
const now = new Date();
|
||||
const isToday = inputDate.getTime() === today.getTime();
|
||||
const finalAvailableTimes = isToday
|
||||
? availableTimesFiltered.filter(timeStr => {
|
||||
const slotTime = parseTime(timeStr);
|
||||
const currentTime = now.getHours() * 60 + now.getMinutes();
|
||||
return slotTime > currentTime;
|
||||
})
|
||||
: availableTimesFiltered;
|
||||
// Deduplicate and sort chronologically
|
||||
const unique = Array.from(new Set(finalAvailableTimes));
|
||||
return unique.sort((a, b) => a.localeCompare(b));
|
||||
}
|
||||
catch (err) {
|
||||
console.error("recurring-availability.getAvailableTimes error", err);
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
// Live-Queries
|
||||
const live = {
|
||||
listRules: os.handler(async function* ({ signal }) {
|
||||
yield call(listRules, {}, { signal });
|
||||
for await (const _ of recurringRulesKV.subscribe()) {
|
||||
yield call(listRules, {}, { signal });
|
||||
}
|
||||
}),
|
||||
listTimeOff: os.handler(async function* ({ signal }) {
|
||||
yield call(listTimeOff, {}, { signal });
|
||||
for await (const _ of timeOffPeriodsKV.subscribe()) {
|
||||
yield call(listTimeOff, {}, { signal });
|
||||
}
|
||||
}),
|
||||
adminListRules: os
|
||||
.input(z.object({ sessionId: z.string() }))
|
||||
.handler(async function* ({ input, signal }) {
|
||||
await assertOwner(input.sessionId);
|
||||
const allRules = await recurringRulesKV.getAllItems();
|
||||
const sortedRules = allRules.sort((a, b) => {
|
||||
if (a.dayOfWeek !== b.dayOfWeek)
|
||||
return a.dayOfWeek - b.dayOfWeek;
|
||||
return a.startTime.localeCompare(b.startTime);
|
||||
});
|
||||
yield sortedRules;
|
||||
for await (const _ of recurringRulesKV.subscribe()) {
|
||||
const updatedRules = await recurringRulesKV.getAllItems();
|
||||
const sortedUpdatedRules = updatedRules.sort((a, b) => {
|
||||
if (a.dayOfWeek !== b.dayOfWeek)
|
||||
return a.dayOfWeek - b.dayOfWeek;
|
||||
return a.startTime.localeCompare(b.startTime);
|
||||
});
|
||||
yield sortedUpdatedRules;
|
||||
}
|
||||
}),
|
||||
adminListTimeOff: os
|
||||
.input(z.object({ sessionId: z.string() }))
|
||||
.handler(async function* ({ input, signal }) {
|
||||
await assertOwner(input.sessionId);
|
||||
const allTimeOff = await timeOffPeriodsKV.getAllItems();
|
||||
const sortedTimeOff = allTimeOff.sort((a, b) => a.startDate.localeCompare(b.startDate));
|
||||
yield sortedTimeOff;
|
||||
for await (const _ of timeOffPeriodsKV.subscribe()) {
|
||||
const updatedTimeOff = await timeOffPeriodsKV.getAllItems();
|
||||
const sortedUpdatedTimeOff = updatedTimeOff.sort((a, b) => a.startDate.localeCompare(b.startDate));
|
||||
yield sortedUpdatedTimeOff;
|
||||
}
|
||||
}),
|
||||
};
|
||||
export const router = {
|
||||
// Recurring Rules
|
||||
createRule,
|
||||
updateRule,
|
||||
deleteRule,
|
||||
toggleRuleActive,
|
||||
listRules,
|
||||
adminListRules,
|
||||
// Time-Off Periods
|
||||
createTimeOff,
|
||||
updateTimeOff,
|
||||
deleteTimeOff,
|
||||
listTimeOff,
|
||||
adminListTimeOff,
|
||||
// Availability
|
||||
getAvailableTimes,
|
||||
// Live queries
|
||||
live,
|
||||
};
|
220
server-dist/rpc/reviews.js
Normal file
220
server-dist/rpc/reviews.js
Normal file
@@ -0,0 +1,220 @@
|
||||
import { call, os } from "@orpc/server";
|
||||
import { z } from "zod";
|
||||
import { createKV } from "../lib/create-kv.js";
|
||||
import { assertOwner, sessionsKV } from "../lib/auth.js";
|
||||
// Schema Definition
|
||||
const ReviewSchema = z.object({
|
||||
id: z.string(),
|
||||
bookingId: z.string(),
|
||||
customerName: z.string().min(2, "Kundenname muss mindestens 2 Zeichen lang sein"),
|
||||
customerEmail: z.string().email("Ungültige E-Mail-Adresse").optional(),
|
||||
rating: z.number().int().min(1).max(5),
|
||||
comment: z.string().min(10, "Kommentar muss mindestens 10 Zeichen lang sein"),
|
||||
status: z.enum(["pending", "approved", "rejected"]),
|
||||
createdAt: z.string(),
|
||||
reviewedAt: z.string().optional(),
|
||||
reviewedBy: z.string().optional(),
|
||||
});
|
||||
// KV Storage
|
||||
const reviewsKV = createKV("reviews");
|
||||
const cancellationKV = createKV("cancellation_tokens");
|
||||
const bookingsKV = createKV("bookings");
|
||||
// Helper Function: validateBookingToken
|
||||
async function validateBookingToken(token) {
|
||||
const tokens = await cancellationKV.getAllItems();
|
||||
const validToken = tokens.find(t => t.token === token &&
|
||||
new Date(t.expiresAt) > new Date() &&
|
||||
t.purpose === 'booking_access');
|
||||
if (!validToken) {
|
||||
throw new Error("Ungültiger oder abgelaufener Buchungs-Token");
|
||||
}
|
||||
const booking = await bookingsKV.getItem(validToken.bookingId);
|
||||
if (!booking) {
|
||||
throw new Error("Buchung nicht gefunden");
|
||||
}
|
||||
// Only allow reviews for completed appointments
|
||||
if (!(booking.status === "completed")) {
|
||||
throw new Error("Bewertungen sind nur für abgeschlossene Termine möglich");
|
||||
}
|
||||
return booking;
|
||||
}
|
||||
// Public Endpoint: submitReview
|
||||
const submitReview = os
|
||||
.input(z.object({
|
||||
bookingToken: z.string(),
|
||||
rating: z.number().int().min(1).max(5),
|
||||
comment: z.string().min(10, "Kommentar muss mindestens 10 Zeichen lang sein"),
|
||||
}))
|
||||
.handler(async ({ input }) => {
|
||||
try {
|
||||
// Validate bookingToken
|
||||
const booking = await validateBookingToken(input.bookingToken);
|
||||
// Enforce uniqueness by using booking.id as the KV key
|
||||
const existing = await reviewsKV.getItem(booking.id);
|
||||
if (existing) {
|
||||
throw new Error("Für diese Buchung wurde bereits eine Bewertung abgegeben");
|
||||
}
|
||||
// Create review object
|
||||
const review = {
|
||||
id: booking.id,
|
||||
bookingId: booking.id,
|
||||
customerName: booking.customerName,
|
||||
customerEmail: booking.customerEmail,
|
||||
rating: input.rating,
|
||||
comment: input.comment,
|
||||
status: "pending",
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
await reviewsKV.setItem(booking.id, review);
|
||||
return review;
|
||||
}
|
||||
catch (err) {
|
||||
console.error("reviews.submitReview error", err);
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
// Admin Endpoint: approveReview
|
||||
const approveReview = os
|
||||
.input(z.object({ sessionId: z.string(), id: z.string() }))
|
||||
.handler(async ({ input }) => {
|
||||
try {
|
||||
await assertOwner(input.sessionId);
|
||||
const review = await reviewsKV.getItem(input.id);
|
||||
if (!review) {
|
||||
throw new Error("Bewertung nicht gefunden");
|
||||
}
|
||||
const session = await sessionsKV.getItem(input.sessionId).catch(() => undefined);
|
||||
const updatedReview = {
|
||||
...review,
|
||||
status: "approved",
|
||||
reviewedAt: new Date().toISOString(),
|
||||
reviewedBy: session?.userId || review.reviewedBy,
|
||||
};
|
||||
await reviewsKV.setItem(input.id, updatedReview);
|
||||
return updatedReview;
|
||||
}
|
||||
catch (err) {
|
||||
console.error("reviews.approveReview error", err);
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
// Admin Endpoint: rejectReview
|
||||
const rejectReview = os
|
||||
.input(z.object({ sessionId: z.string(), id: z.string() }))
|
||||
.handler(async ({ input }) => {
|
||||
try {
|
||||
await assertOwner(input.sessionId);
|
||||
const review = await reviewsKV.getItem(input.id);
|
||||
if (!review) {
|
||||
throw new Error("Bewertung nicht gefunden");
|
||||
}
|
||||
const session = await sessionsKV.getItem(input.sessionId).catch(() => undefined);
|
||||
const updatedReview = {
|
||||
...review,
|
||||
status: "rejected",
|
||||
reviewedAt: new Date().toISOString(),
|
||||
reviewedBy: session?.userId || review.reviewedBy,
|
||||
};
|
||||
await reviewsKV.setItem(input.id, updatedReview);
|
||||
return updatedReview;
|
||||
}
|
||||
catch (err) {
|
||||
console.error("reviews.rejectReview error", err);
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
// Admin Endpoint: deleteReview
|
||||
const deleteReview = os
|
||||
.input(z.object({ sessionId: z.string(), id: z.string() }))
|
||||
.handler(async ({ input }) => {
|
||||
try {
|
||||
await assertOwner(input.sessionId);
|
||||
await reviewsKV.removeItem(input.id);
|
||||
}
|
||||
catch (err) {
|
||||
console.error("reviews.deleteReview error", err);
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
// Public Endpoint: listPublishedReviews
|
||||
const listPublishedReviews = os.handler(async () => {
|
||||
try {
|
||||
const allReviews = await reviewsKV.getAllItems();
|
||||
const published = allReviews.filter(r => r.status === "approved");
|
||||
const sorted = published.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
||||
const publicSafe = sorted.map(r => ({
|
||||
customerName: r.customerName,
|
||||
rating: r.rating,
|
||||
comment: r.comment,
|
||||
status: r.status,
|
||||
bookingId: r.bookingId,
|
||||
createdAt: r.createdAt,
|
||||
}));
|
||||
return publicSafe;
|
||||
}
|
||||
catch (err) {
|
||||
console.error("reviews.listPublishedReviews error", err);
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
// Admin Endpoint: adminListReviews
|
||||
const adminListReviews = os
|
||||
.input(z.object({
|
||||
sessionId: z.string(),
|
||||
statusFilter: z.enum(["all", "pending", "approved", "rejected"]).optional().default("all"),
|
||||
}))
|
||||
.handler(async ({ input }) => {
|
||||
try {
|
||||
await assertOwner(input.sessionId);
|
||||
const allReviews = await reviewsKV.getAllItems();
|
||||
const filtered = input.statusFilter === "all"
|
||||
? allReviews
|
||||
: allReviews.filter(r => r.status === input.statusFilter);
|
||||
const sorted = filtered.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
||||
return sorted;
|
||||
}
|
||||
catch (err) {
|
||||
console.error("reviews.adminListReviews error", err);
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
// Live Queries
|
||||
const live = {
|
||||
listPublishedReviews: os.handler(async function* ({ signal }) {
|
||||
yield call(listPublishedReviews, {}, { signal });
|
||||
for await (const _ of reviewsKV.subscribe()) {
|
||||
yield call(listPublishedReviews, {}, { signal });
|
||||
}
|
||||
}),
|
||||
adminListReviews: os
|
||||
.input(z.object({
|
||||
sessionId: z.string(),
|
||||
statusFilter: z.enum(["all", "pending", "approved", "rejected"]).optional().default("all"),
|
||||
}))
|
||||
.handler(async function* ({ input, signal }) {
|
||||
await assertOwner(input.sessionId);
|
||||
const allReviews = await reviewsKV.getAllItems();
|
||||
const filtered = input.statusFilter === "all"
|
||||
? allReviews
|
||||
: allReviews.filter(r => r.status === input.statusFilter);
|
||||
const sorted = filtered.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
||||
yield sorted;
|
||||
for await (const _ of reviewsKV.subscribe()) {
|
||||
const updated = await reviewsKV.getAllItems();
|
||||
const filteredUpdated = input.statusFilter === "all"
|
||||
? updated
|
||||
: updated.filter(r => r.status === input.statusFilter);
|
||||
const sortedUpdated = filteredUpdated.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
||||
yield sortedUpdated;
|
||||
}
|
||||
}),
|
||||
};
|
||||
export const router = {
|
||||
submitReview,
|
||||
approveReview,
|
||||
rejectReview,
|
||||
deleteReview,
|
||||
listPublishedReviews,
|
||||
adminListReviews,
|
||||
live,
|
||||
};
|
52
server-dist/rpc/treatments.js
Normal file
52
server-dist/rpc/treatments.js
Normal file
@@ -0,0 +1,52 @@
|
||||
import { call, os } from "@orpc/server";
|
||||
import { z } from "zod";
|
||||
import { randomUUID } from "crypto";
|
||||
import { createKV } from "../lib/create-kv.js";
|
||||
const TreatmentSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
description: z.string(),
|
||||
duration: z.number(), // duration in minutes
|
||||
price: z.number(), // price in cents
|
||||
category: z.string(),
|
||||
});
|
||||
const kv = createKV("treatments");
|
||||
const create = os
|
||||
.input(TreatmentSchema.omit({ id: true }))
|
||||
.handler(async ({ input }) => {
|
||||
const id = randomUUID();
|
||||
const treatment = { id, ...input };
|
||||
await kv.setItem(id, treatment);
|
||||
return treatment;
|
||||
});
|
||||
const update = os
|
||||
.input(TreatmentSchema)
|
||||
.handler(async ({ input }) => {
|
||||
await kv.setItem(input.id, input);
|
||||
return input;
|
||||
});
|
||||
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 live = {
|
||||
list: os.handler(async function* ({ signal }) {
|
||||
yield call(list, {}, { signal });
|
||||
for await (const _ of kv.subscribe()) {
|
||||
yield call(list, {}, { signal });
|
||||
}
|
||||
}),
|
||||
};
|
||||
export const router = {
|
||||
create,
|
||||
update,
|
||||
remove,
|
||||
list,
|
||||
get,
|
||||
live,
|
||||
};
|
Reference in New Issue
Block a user