diff --git a/server-dist/index.js b/server-dist/index.js
new file mode 100644
index 0000000..fa4d17b
--- /dev/null
+++ b/server-dist/index.js
@@ -0,0 +1,71 @@
+import { Hono } from "hono";
+import { serve } from '@hono/node-server';
+import { serveStatic } from '@hono/node-server/serve-static';
+import { rpcApp } from "./routes/rpc.js";
+import { caldavApp } from "./routes/caldav.js";
+import { clientEntry } from "./routes/client-entry.js";
+const app = new Hono();
+// Allow all hosts for Tailscale Funnel
+app.use("*", async (c, next) => {
+ // Accept requests from any host
+ return next();
+});
+// Health check endpoint
+app.get("/health", (c) => {
+ return c.json({ status: "ok", timestamp: new Date().toISOString() });
+});
+// Legal config endpoint (temporary fix for RPC issue)
+app.get("/api/legal-config", async (c) => {
+ try {
+ const { getLegalConfig } = await import("./lib/legal-config.js");
+ const config = getLegalConfig();
+ return c.json(config);
+ }
+ catch (error) {
+ console.error("Legal config error:", error);
+ return c.json({ error: "Failed to load legal config" }, 500);
+ }
+});
+// Security.txt endpoint (RFC 9116)
+app.get("/.well-known/security.txt", (c) => {
+ const securityContact = process.env.SECURITY_CONTACT || "security@example.com";
+ const securityText = `Contact: ${securityContact}
+Expires: 2025-12-31T23:59:59.000Z
+Preferred-Languages: de, en
+Canonical: https://${process.env.DOMAIN || 'localhost:5173'}/.well-known/security.txt
+
+# Security Policy
+# Please report security vulnerabilities responsibly by contacting us via email.
+# We will respond to security reports within 48 hours.
+#
+# Scope: This security policy applies to the Stargirlnails booking system.
+#
+# Rewards: We appreciate security researchers who help us improve our security.
+# While we don't have a formal bug bounty program, we may offer recognition
+# for significant security improvements.
+`;
+ return c.text(securityText, 200, {
+ "Content-Type": "text/plain; charset=utf-8",
+ "Cache-Control": "public, max-age=86400", // Cache for 24 hours
+ });
+});
+// Serve static files (only in production)
+if (process.env.NODE_ENV === 'production') {
+ app.use('/static/*', serveStatic({ root: './dist' }));
+ app.use('/assets/*', serveStatic({ root: './dist' }));
+}
+app.use('/favicon.png', serveStatic({ path: './public/favicon.png' }));
+app.route("/rpc", rpcApp);
+app.route("/caldav", caldavApp);
+app.get("/*", clientEntry);
+// Start server
+const port = process.env.PORT ? parseInt(process.env.PORT) : 3000;
+const host = process.env.HOST || "0.0.0.0";
+console.log(`🚀 Server starting on ${host}:${port}`);
+// Start the server
+serve({
+ fetch: app.fetch,
+ port,
+ hostname: host,
+});
+export default app;
diff --git a/server-dist/lib/auth.js b/server-dist/lib/auth.js
new file mode 100644
index 0000000..b5f1f71
--- /dev/null
+++ b/server-dist/lib/auth.js
@@ -0,0 +1,13 @@
+import { createKV } from "./create-kv.js";
+export const sessionsKV = createKV("sessions");
+export const usersKV = createKV("users");
+export 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");
+}
diff --git a/server-dist/lib/create-kv.js b/server-dist/lib/create-kv.js
new file mode 100644
index 0000000..6c7235b
--- /dev/null
+++ b/server-dist/lib/create-kv.js
@@ -0,0 +1,33 @@
+import { createStorage } from "unstorage";
+import fsDriver from "unstorage/drivers/fs";
+const STORAGE_PATH = "./.storage"; // It is .gitignored
+export function createKV(name) {
+ const storage = createStorage({
+ driver: fsDriver({ base: `${STORAGE_PATH}/${name}` }),
+ });
+ // Async generator to play work well with oRPC live queries
+ async function* subscribe() {
+ let resolve;
+ let promise = new Promise((r) => (resolve = r));
+ const unwatch = await storage.watch((event, key) => {
+ resolve({ event, key });
+ promise = new Promise((r) => (resolve = r));
+ });
+ try {
+ while (true)
+ yield await promise;
+ }
+ finally {
+ await unwatch();
+ }
+ }
+ return {
+ ...storage,
+ getAllItems: async () => {
+ const keys = await storage.getKeys();
+ const values = await storage.getItems(keys);
+ return values.map(({ value }) => value);
+ },
+ subscribe,
+ };
+}
diff --git a/server-dist/lib/email-templates.js b/server-dist/lib/email-templates.js
new file mode 100644
index 0000000..89eb897
--- /dev/null
+++ b/server-dist/lib/email-templates.js
@@ -0,0 +1,258 @@
+import { readFile } from "node:fs/promises";
+import { fileURLToPath } from "node:url";
+import { dirname, resolve } from "node:path";
+// 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}`;
+}
+let cachedLogoDataUrl = null;
+async function getLogoDataUrl() {
+ if (cachedLogoDataUrl)
+ return cachedLogoDataUrl;
+ try {
+ const __filename = fileURLToPath(import.meta.url);
+ const __dirname = dirname(__filename);
+ const logoPath = resolve(__dirname, "../../../assets/stargilnails_logo_transparent.png");
+ const buf = await readFile(logoPath);
+ const base64 = buf.toString("base64");
+ cachedLogoDataUrl = `data:image/png;base64,${base64}`;
+ return cachedLogoDataUrl;
+ }
+ catch {
+ return null;
+ }
+}
+async function renderBrandedEmail(title, bodyHtml) {
+ const logo = await getLogoDataUrl();
+ const domain = process.env.DOMAIN || 'localhost:5173';
+ const protocol = domain.includes('localhost') ? 'http' : 'https';
+ const homepageUrl = `${protocol}://${domain}`;
+ return `
+
+
+
+
+ ${logo ? ` ` : `💅 `}
+ ${title}
+ |
+
+
+
+
+ ${bodyHtml}
+
+
+
+
+ © ${new Date().getFullYear()} Stargirlnails Kiel • Professional Nail Care
+
+ |
+
+
+
`;
+}
+export async function renderBookingPendingHTML(params) {
+ const { name, date, time, statusUrl } = params;
+ const formattedDate = formatDateGerman(date);
+ const domain = process.env.DOMAIN || 'localhost:5173';
+ const protocol = domain.includes('localhost') ? 'http' : 'https';
+ const legalUrl = `${protocol}://${domain}/legal`;
+ const inner = `
+ Hallo ${name},
+ wir haben deine Anfrage für ${formattedDate} um ${time} erhalten.
+ Wir bestätigen deinen Termin in Kürze. Du erhältst eine weitere E-Mail, sobald der Termin bestätigt ist.
+ ${statusUrl ? `
+
+
⏳ Termin-Status ansehen:
+
Du kannst den aktuellen Status deiner Buchung jederzeit einsehen:
+
Status ansehen
+
+ ` : ''}
+
+ Liebe Grüße,
Stargirlnails Kiel
+ `;
+ return renderBrandedEmail("Deine Terminanfrage ist eingegangen", inner);
+}
+export async function renderBookingConfirmedHTML(params) {
+ const { name, date, time, cancellationUrl, reviewUrl } = params;
+ const formattedDate = formatDateGerman(date);
+ const domain = process.env.DOMAIN || 'localhost:5173';
+ const protocol = domain.includes('localhost') ? 'http' : 'https';
+ const legalUrl = `${protocol}://${domain}/legal`;
+ const inner = `
+ Hallo ${name},
+ wir haben deinen Termin am ${formattedDate} um ${time} bestätigt.
+ Wir freuen uns auf dich!
+
+
📋 Wichtiger Hinweis:
+
Die Allgemeinen Geschäftsbedingungen (AGB) findest du im Anhang dieser E-Mail. Bitte lies sie vor deinem Termin durch.
+
+ ${cancellationUrl ? `
+
+ ` : ''}
+ ${reviewUrl ? `
+
+
⭐ Bewertung abgeben:
+
Nach deinem Termin würden wir uns über deine Bewertung freuen!
+
Bewertung schreiben
+
Du kannst deine Bewertung nach dem Termin über diesen Link abgeben.
+
+ ` : ''}
+
+ Liebe Grüße,
Stargirlnails Kiel
+ `;
+ return renderBrandedEmail("Termin bestätigt", inner);
+}
+export async function renderBookingCancelledHTML(params) {
+ const { name, date, time } = params;
+ const formattedDate = formatDateGerman(date);
+ const domain = process.env.DOMAIN || 'localhost:5173';
+ const protocol = domain.includes('localhost') ? 'http' : 'https';
+ const legalUrl = `${protocol}://${domain}/legal`;
+ const inner = `
+ Hallo ${name},
+ dein Termin am ${formattedDate} um ${time} wurde abgesagt.
+ Bitte buche einen neuen Termin. Bei Fragen helfen wir dir gerne weiter.
+
+ Liebe Grüße,
Stargirlnails Kiel
+ `;
+ return renderBrandedEmail("Termin abgesagt", inner);
+}
+export async function renderAdminBookingNotificationHTML(params) {
+ const { name, date, time, treatment, phone, notes, hasInspirationPhoto } = params;
+ const formattedDate = formatDateGerman(date);
+ const inner = `
+ Hallo Admin,
+ eine neue Buchungsanfrage ist eingegangen:
+
+
📅 Buchungsdetails:
+
+ - Name: ${name}
+ - Telefon: ${phone}
+ - Behandlung: ${treatment}
+ - Datum: ${formattedDate}
+ - Uhrzeit: ${time}
+ ${notes ? `- Notizen: ${notes}
` : ''}
+ - Inspiration-Foto: ${hasInspirationPhoto ? '✅ Im Anhang verfügbar' : '❌ Kein Foto hochgeladen'}
+
+
+ Bitte logge dich in das Admin-Panel ein, um die Buchung zu bestätigen oder abzulehnen.
+ Liebe Grüße,
Stargirlnails System
+ `;
+ return renderBrandedEmail("Neue Buchungsanfrage - Admin-Benachrichtigung", inner);
+}
+export async function renderBookingRescheduleProposalHTML(params) {
+ const formattedOriginalDate = formatDateGerman(params.originalDate);
+ const formattedProposedDate = formatDateGerman(params.proposedDate);
+ const expiryDate = new Date(params.expiresAt);
+ const formattedExpiry = `${expiryDate.toLocaleDateString('de-DE')} ${expiryDate.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })}`;
+ const inner = `
+ Hallo ${params.name},
+ wir müssen deinen Termin leider verschieben. Hier ist unser Vorschlag:
+
+
📅 Übersicht
+
+
+ Alter Termin |
+ ${formattedOriginalDate} um ${params.originalTime} Uhr |
+
+
+ Neuer Vorschlag |
+ ${formattedProposedDate} um ${params.proposedTime} Uhr |
+
+
+ Behandlung |
+ ${params.treatmentName} |
+
+
+
+
+ ⏰ Bitte antworte bis ${formattedExpiry}.
+
+
+
+ Wenn du den Vorschlag ablehnst, bleibt dein ursprünglicher Termin bestehen und wir kontaktieren dich für eine alternative Lösung.
+
+ Falls du einen komplett neuen Termin buchen möchtest, kannst du deinen aktuellen Termin stornieren und einen neuen Termin auf unserer Website buchen.
+ Liebe Grüße,
Stargirlnails Kiel
+ `;
+ return renderBrandedEmail("Terminänderung vorgeschlagen", inner);
+}
+export async function renderAdminRescheduleDeclinedHTML(params) {
+ const inner = `
+ Hallo Admin,
+ der Kunde ${params.customerName} hat den Terminänderungsvorschlag abgelehnt.
+
+
+ - Kunde: ${params.customerName}
+ ${params.customerEmail ? `- E-Mail: ${params.customerEmail}
` : ''}
+ ${params.customerPhone ? `- Telefon: ${params.customerPhone}
` : ''}
+ - Behandlung: ${params.treatmentName}
+ - Ursprünglicher Termin: ${formatDateGerman(params.originalDate)} um ${params.originalTime} Uhr (bleibt bestehen)
+ - Abgelehnter Vorschlag: ${formatDateGerman(params.proposedDate)} um ${params.proposedTime} Uhr
+
+
+ Bitte kontaktiere den Kunden, um eine alternative Lösung zu finden.
+ `;
+ return renderBrandedEmail("Kunde hat Terminänderung abgelehnt", inner);
+}
+export async function renderAdminRescheduleAcceptedHTML(params) {
+ const inner = `
+ Hallo Admin,
+ der Kunde ${params.customerName} hat den Terminänderungsvorschlag akzeptiert.
+
+
+ - Kunde: ${params.customerName}
+ - Behandlung: ${params.treatmentName}
+ - Alter Termin: ${formatDateGerman(params.originalDate)} um ${params.originalTime} Uhr
+ - Neuer Termin: ${formatDateGerman(params.newDate)} um ${params.newTime} Uhr ✅
+
+
+ Der Termin wurde automatisch aktualisiert.
+ `;
+ return renderBrandedEmail("Kunde hat Terminänderung akzeptiert", inner);
+}
+export async function renderAdminRescheduleExpiredHTML(params) {
+ const inner = `
+ Hallo Admin,
+ ${params.expiredProposals.length} Terminänderungsvorschlag${params.expiredProposals.length > 1 ? 'e' : ''} ${params.expiredProposals.length > 1 ? 'sind' : 'ist'} abgelaufen und wurde${params.expiredProposals.length > 1 ? 'n' : ''} automatisch entfernt.
+
+
⚠️ Abgelaufene Vorschläge:
+ ${params.expiredProposals.map(proposal => `
+
+
+ - Kunde: ${proposal.customerName}
+ ${proposal.customerEmail ? `- E-Mail: ${proposal.customerEmail}
` : ''}
+ ${proposal.customerPhone ? `- Telefon: ${proposal.customerPhone}
` : ''}
+ - Behandlung: ${proposal.treatmentName}
+ - Ursprünglicher Termin: ${formatDateGerman(proposal.originalDate)} um ${proposal.originalTime} Uhr
+ - Vorgeschlagener Termin: ${formatDateGerman(proposal.proposedDate)} um ${proposal.proposedTime} Uhr
+ - Abgelaufen am: ${new Date(proposal.expiredAt).toLocaleString('de-DE')}
+
+
+ `).join('')}
+
+ Bitte kontaktiere die Kunden, um eine alternative Lösung zu finden.
+ Die ursprünglichen Termine bleiben bestehen.
+ `;
+ return renderBrandedEmail("Abgelaufene Terminänderungsvorschläge", inner);
+}
diff --git a/server-dist/lib/email-validator.js b/server-dist/lib/email-validator.js
new file mode 100644
index 0000000..363d868
--- /dev/null
+++ b/server-dist/lib/email-validator.js
@@ -0,0 +1,88 @@
+// Email validation using Rapid Email Validator API
+// API: https://rapid-email-verifier.fly.dev/
+// Privacy-focused, no data storage, completely free
+/**
+ * Validate email address using Rapid Email Validator API
+ * Returns true if email is valid, false otherwise
+ */
+export async function validateEmail(email) {
+ try {
+ // Call Rapid Email Validator API
+ const response = await fetch(`https://rapid-email-verifier.fly.dev/api/validate?email=${encodeURIComponent(email)}`, {
+ method: 'GET',
+ headers: {
+ 'Accept': 'application/json',
+ },
+ });
+ if (!response.ok) {
+ console.error(`Email validation API error: ${response.status}`);
+ // If API is down, reject the email with error message
+ return {
+ valid: false,
+ reason: 'E-Mail-Validierung ist derzeit nicht verfügbar. Bitte überprüfe deine E-Mail-Adresse und versuche es erneut.'
+ };
+ }
+ const data = await response.json();
+ // Check if email is disposable/temporary
+ if (data.validations.is_disposable) {
+ return {
+ valid: false,
+ reason: 'Temporäre oder Wegwerf-E-Mail-Adressen sind nicht erlaubt. Bitte verwende eine echte E-Mail-Adresse.',
+ };
+ }
+ // Check if MX records exist (deliverable)
+ if (!data.validations.mx_records) {
+ return {
+ valid: false,
+ reason: 'Diese E-Mail-Adresse kann keine E-Mails empfangen. Bitte überprüfe deine E-Mail-Adresse.',
+ };
+ }
+ // Check if domain exists
+ if (!data.validations.domain_exists) {
+ return {
+ valid: false,
+ reason: 'Die E-Mail-Domain existiert nicht. Bitte überprüfe deine E-Mail-Adresse.',
+ };
+ }
+ // Check if email syntax is valid
+ if (!data.validations.syntax) {
+ return {
+ valid: false,
+ reason: 'Ungültige E-Mail-Adresse. Bitte überprüfe die Schreibweise.',
+ };
+ }
+ // Email is valid
+ return { valid: true };
+ }
+ catch (error) {
+ console.error('Email validation error:', error);
+ // If validation fails, reject the email with error message
+ return {
+ valid: false,
+ reason: 'E-Mail-Validierung ist derzeit nicht verfügbar. Bitte überprüfe deine E-Mail-Adresse und versuche es erneut.'
+ };
+ }
+}
+/**
+ * Batch validate multiple emails
+ * @param emails Array of email addresses to validate
+ * @returns Array of validation results
+ */
+export async function validateEmailBatch(emails) {
+ const results = new Map();
+ // Validate up to 100 emails at once (API limit)
+ const batchSize = 100;
+ for (let i = 0; i < emails.length; i += batchSize) {
+ const batch = emails.slice(i, i + batchSize);
+ // Call each validation in parallel for better performance
+ const validations = await Promise.all(batch.map(async (email) => {
+ const result = await validateEmail(email);
+ return { email, result };
+ }));
+ // Store results
+ validations.forEach(({ email, result }) => {
+ results.set(email, result);
+ });
+ }
+ return results;
+}
diff --git a/server-dist/lib/email.js b/server-dist/lib/email.js
new file mode 100644
index 0000000..6d0a258
--- /dev/null
+++ b/server-dist/lib/email.js
@@ -0,0 +1,186 @@
+import { readFile } from "node:fs/promises";
+import { fileURLToPath } from "node:url";
+import { dirname, resolve } from "node:path";
+const RESEND_API_KEY = process.env.RESEND_API_KEY;
+const DEFAULT_FROM = process.env.EMAIL_FROM || "Stargirlnails ";
+// Helper function to format dates for ICS files (YYYYMMDDTHHMMSS)
+function formatDateForICS(date, time) {
+ // date is in YYYY-MM-DD format, time is in HH:MM format
+ const [year, month, day] = date.split('-');
+ const [hours, minutes] = time.split(':');
+ return `${year}${month}${day}T${hours}${minutes}00`;
+}
+// Helper function to create ICS (iCalendar) file content
+function createICSFile(params) {
+ const { date, time, durationMinutes, customerName, treatmentName } = params;
+ // Calculate start and end times in Europe/Berlin timezone
+ const dtStart = formatDateForICS(date, time);
+ // Calculate end time
+ const [hours, minutes] = time.split(':').map(Number);
+ const startDate = new Date(`${date}T${time}:00`);
+ const endDate = new Date(startDate.getTime() + durationMinutes * 60000);
+ const endHours = String(endDate.getHours()).padStart(2, '0');
+ const endMinutes = String(endDate.getMinutes()).padStart(2, '0');
+ const dtEnd = formatDateForICS(date, `${endHours}:${endMinutes}`);
+ // Create unique ID for this event
+ const uid = `booking-${Date.now()}-${Math.random().toString(36).substr(2, 9)}@stargirlnails.de`;
+ // Current timestamp for DTSTAMP
+ const now = new Date();
+ const dtstamp = now.toISOString().replace(/[-:]/g, '').split('.')[0] + 'Z';
+ // ICS content
+ const icsContent = [
+ 'BEGIN:VCALENDAR',
+ 'VERSION:2.0',
+ 'PRODID:-//Stargirlnails Kiel//Booking System//DE',
+ 'CALSCALE:GREGORIAN',
+ 'METHOD:REQUEST',
+ 'BEGIN:VEVENT',
+ `UID:${uid}`,
+ `DTSTAMP:${dtstamp}`,
+ `DTSTART;TZID=Europe/Berlin:${dtStart}`,
+ `DTEND;TZID=Europe/Berlin:${dtEnd}`,
+ `SUMMARY:${treatmentName} - Stargirlnails Kiel`,
+ `DESCRIPTION:Termin für ${treatmentName} bei Stargirlnails Kiel`,
+ 'LOCATION:Stargirlnails Kiel',
+ `ORGANIZER;CN=Stargirlnails Kiel:mailto:${process.env.EMAIL_FROM?.match(/<(.+)>/)?.[1] || 'no-reply@stargirlnails.de'}`,
+ `ATTENDEE;CN=${customerName};RSVP=TRUE:mailto:${customerName}`,
+ 'STATUS:CONFIRMED',
+ 'SEQUENCE:0',
+ 'BEGIN:VALARM',
+ 'TRIGGER:-PT24H',
+ 'ACTION:DISPLAY',
+ 'DESCRIPTION:Erinnerung: Termin morgen bei Stargirlnails Kiel',
+ 'END:VALARM',
+ 'END:VEVENT',
+ 'BEGIN:VTIMEZONE',
+ 'TZID:Europe/Berlin',
+ 'BEGIN:DAYLIGHT',
+ 'TZOFFSETFROM:+0100',
+ 'TZOFFSETTO:+0200',
+ 'TZNAME:CEST',
+ 'DTSTART:19700329T020000',
+ 'RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU',
+ 'END:DAYLIGHT',
+ 'BEGIN:STANDARD',
+ 'TZOFFSETFROM:+0200',
+ 'TZOFFSETTO:+0100',
+ 'TZNAME:CET',
+ 'DTSTART:19701025T030000',
+ 'RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU',
+ 'END:STANDARD',
+ 'END:VTIMEZONE',
+ 'END:VCALENDAR'
+ ].join('\r\n');
+ return icsContent;
+}
+// Cache for AGB PDF to avoid reading it multiple times
+let cachedAGBPDF = null;
+async function getAGBPDFBase64() {
+ if (cachedAGBPDF)
+ return cachedAGBPDF;
+ try {
+ const __filename = fileURLToPath(import.meta.url);
+ const __dirname = dirname(__filename);
+ const agbPath = resolve(__dirname, "../../../AGB.pdf");
+ const buf = await readFile(agbPath);
+ cachedAGBPDF = buf.toString('base64');
+ return cachedAGBPDF;
+ }
+ catch (error) {
+ console.warn("Could not read AGB.pdf:", error);
+ return null;
+ }
+}
+export async function sendEmail(params) {
+ if (!RESEND_API_KEY) {
+ // In development or if not configured, skip sending but don't fail the flow
+ console.warn("Resend API key not configured. Skipping email send.");
+ return { success: false };
+ }
+ const response = await fetch("https://api.resend.com/emails", {
+ method: "POST",
+ headers: {
+ "Authorization": `Bearer ${RESEND_API_KEY}`,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ from: params.from || DEFAULT_FROM,
+ to: Array.isArray(params.to) ? params.to : [params.to],
+ subject: params.subject,
+ text: params.text,
+ html: params.html,
+ cc: params.cc ? (Array.isArray(params.cc) ? params.cc : [params.cc]) : undefined,
+ bcc: params.bcc ? (Array.isArray(params.bcc) ? params.bcc : [params.bcc]) : undefined,
+ attachments: params.attachments,
+ }),
+ });
+ if (!response.ok) {
+ const body = await response.text().catch(() => "");
+ console.error("Resend send error:", response.status, body);
+ return { success: false };
+ }
+ return { success: true };
+}
+export async function sendEmailWithAGB(params) {
+ const agbBase64 = await getAGBPDFBase64();
+ if (agbBase64) {
+ params.attachments = [
+ ...(params.attachments || []),
+ {
+ filename: "AGB_Stargirlnails_Kiel.pdf",
+ content: agbBase64,
+ type: "application/pdf"
+ }
+ ];
+ }
+ return sendEmail(params);
+}
+export async function sendEmailWithAGBAndCalendar(params, calendarParams) {
+ const agbBase64 = await getAGBPDFBase64();
+ // Create ICS file content
+ const icsContent = createICSFile(calendarParams);
+ const icsBase64 = Buffer.from(icsContent, 'utf-8').toString('base64');
+ // Attach both AGB and ICS file
+ params.attachments = [...(params.attachments || [])];
+ if (agbBase64) {
+ params.attachments.push({
+ filename: "AGB_Stargirlnails_Kiel.pdf",
+ content: agbBase64,
+ type: "application/pdf"
+ });
+ }
+ params.attachments.push({
+ filename: "Termin_Stargirlnails.ics",
+ content: icsBase64,
+ type: "text/calendar"
+ });
+ return sendEmail(params);
+}
+export async function sendEmailWithInspirationPhoto(params, photoData, customerName) {
+ if (!photoData) {
+ return sendEmail(params);
+ }
+ // Extract file extension from base64 data URL
+ const match = photoData.match(/data:image\/([^;]+);base64,(.+)/);
+ if (!match) {
+ console.warn("Invalid photo data format");
+ return sendEmail(params);
+ }
+ const [, extension, base64Content] = match;
+ const filename = `inspiration_${customerName.replace(/[^a-zA-Z0-9]/g, '_')}_${Date.now()}.${extension}`;
+ // Check if attachment is too large (max 1MB base64 content)
+ if (base64Content.length > 1024 * 1024) {
+ console.warn(`Photo attachment too large: ${base64Content.length} chars, skipping attachment`);
+ return sendEmail(params);
+ }
+ // console.log(`Sending email with photo attachment: ${filename}, size: ${base64Content.length} chars`);
+ params.attachments = [
+ ...(params.attachments || []),
+ {
+ filename,
+ content: base64Content,
+ type: `image/${extension}`
+ }
+ ];
+ return sendEmail(params);
+}
diff --git a/server-dist/lib/legal-config.js b/server-dist/lib/legal-config.js
new file mode 100644
index 0000000..e3477ef
--- /dev/null
+++ b/server-dist/lib/legal-config.js
@@ -0,0 +1,39 @@
+// Default configuration - should be overridden by environment variables
+export const defaultLegalConfig = {
+ companyName: process.env.COMPANY_NAME || "Stargirlnails Kiel",
+ ownerName: process.env.OWNER_NAME || "Inhaber Name",
+ address: {
+ street: process.env.ADDRESS_STREET || "Liebigstr. 15",
+ city: process.env.ADDRESS_CITY || "Kiel",
+ postalCode: process.env.ADDRESS_POSTAL_CODE || "24145",
+ country: process.env.ADDRESS_COUNTRY || "Deutschland",
+ latitude: process.env.ADDRESS_LATITUDE ? parseFloat(process.env.ADDRESS_LATITUDE) : 54.3233,
+ longitude: process.env.ADDRESS_LONGITUDE ? parseFloat(process.env.ADDRESS_LONGITUDE) : 10.1228,
+ },
+ contact: {
+ phone: process.env.CONTACT_PHONE || "+49 431 123456",
+ email: process.env.CONTACT_EMAIL || "info@stargirlnails.de",
+ website: process.env.DOMAIN || "stargirlnails.de",
+ },
+ businessDetails: {
+ taxId: process.env.TAX_ID || "",
+ vatId: process.env.VAT_ID || "",
+ commercialRegister: process.env.COMMERCIAL_REGISTER || "",
+ responsibleForContent: process.env.RESPONSIBLE_FOR_CONTENT || "Inhaber Name",
+ },
+ dataProtection: {
+ responsiblePerson: process.env.DATA_PROTECTION_RESPONSIBLE || "Inhaber Name",
+ email: process.env.DATA_PROTECTION_EMAIL || "datenschutz@stargirlnails.de",
+ purpose: process.env.DATA_PROTECTION_PURPOSE || "Terminbuchung und Kundenbetreuung",
+ legalBasis: process.env.DATA_PROTECTION_LEGAL_BASIS || "Art. 6 Abs. 1 lit. b DSGVO (Vertragserfüllung) und Art. 6 Abs. 1 lit. f DSGVO (berechtigtes Interesse)",
+ dataRetention: process.env.DATA_PROTECTION_RETENTION || "Buchungsdaten werden 3 Jahre nach Vertragsende gespeichert",
+ rights: process.env.DATA_PROTECTION_RIGHTS || "Auskunft, Berichtigung, Löschung, Einschränkung, Widerspruch, Beschwerde bei der Aufsichtsbehörde",
+ cookies: process.env.DATA_PROTECTION_COOKIES || "Wir verwenden technisch notwendige Cookies für die Funktionalität der Website",
+ thirdPartyServices: process.env.THIRD_PARTY_SERVICES ? process.env.THIRD_PARTY_SERVICES.split(',') : ["Resend (E-Mail-Versand)"],
+ dataSecurity: process.env.DATA_PROTECTION_SECURITY || "SSL-Verschlüsselung, sichere Server, regelmäßige Updates",
+ contactDataProtection: process.env.DATA_PROTECTION_CONTACT || "Bei Fragen zum Datenschutz wenden Sie sich an: datenschutz@stargirlnails.de",
+ },
+};
+export function getLegalConfig() {
+ return defaultLegalConfig;
+}
diff --git a/server-dist/lib/openai.js b/server-dist/lib/openai.js
new file mode 100644
index 0000000..1e47bd5
--- /dev/null
+++ b/server-dist/lib/openai.js
@@ -0,0 +1,14 @@
+import { jsonrepair } from "jsonrepair";
+import { z } from "zod";
+import { makeParseableResponseFormat } from "openai/lib/parser";
+export function zodResponseFormat(zodObject, name, props) {
+ return makeParseableResponseFormat({
+ type: "json_schema",
+ json_schema: {
+ ...props,
+ name,
+ strict: true,
+ schema: z.toJSONSchema(zodObject, { target: "draft-7" }),
+ },
+ }, (content) => zodObject.parse(JSON.parse(jsonrepair(content))));
+}
diff --git a/server-dist/lib/rate-limiter.js b/server-dist/lib/rate-limiter.js
new file mode 100644
index 0000000..5ae6958
--- /dev/null
+++ b/server-dist/lib/rate-limiter.js
@@ -0,0 +1,117 @@
+// Simple in-memory rate limiter for IP and email-based requests
+// For production with multiple instances, consider using Redis
+const rateLimitStore = new Map();
+// Cleanup old entries every 10 minutes to prevent memory leaks
+setInterval(() => {
+ const now = Date.now();
+ for (const [key, entry] of rateLimitStore.entries()) {
+ if (entry.resetAt < now) {
+ rateLimitStore.delete(key);
+ }
+ }
+}, 10 * 60 * 1000);
+/**
+ * Check if a request is allowed based on rate limiting
+ * @param key - Unique identifier (IP, email, or combination)
+ * @param config - Rate limit configuration
+ * @returns RateLimitResult with allow status and metadata
+ */
+export function checkRateLimit(key, config) {
+ const now = Date.now();
+ const entry = rateLimitStore.get(key);
+ // No existing entry or window expired - allow and create new entry
+ if (!entry || entry.resetAt < now) {
+ rateLimitStore.set(key, {
+ count: 1,
+ resetAt: now + config.windowMs,
+ });
+ return {
+ allowed: true,
+ remaining: config.maxRequests - 1,
+ resetAt: now + config.windowMs,
+ };
+ }
+ // Existing entry within window
+ if (entry.count >= config.maxRequests) {
+ // Rate limit exceeded
+ const retryAfterSeconds = Math.ceil((entry.resetAt - now) / 1000);
+ return {
+ allowed: false,
+ remaining: 0,
+ resetAt: entry.resetAt,
+ retryAfterSeconds,
+ };
+ }
+ // Increment count and allow
+ entry.count++;
+ rateLimitStore.set(key, entry);
+ return {
+ allowed: true,
+ remaining: config.maxRequests - entry.count,
+ resetAt: entry.resetAt,
+ };
+}
+/**
+ * Check rate limit for booking creation
+ * Applies multiple checks: per IP and per email
+ */
+export function checkBookingRateLimit(params) {
+ const { ip, email } = params;
+ // Config: max 3 bookings per email per hour
+ const emailConfig = {
+ maxRequests: 3,
+ windowMs: 60 * 60 * 1000, // 1 hour
+ };
+ // Config: max 5 bookings per IP per 10 minutes
+ const ipConfig = {
+ maxRequests: 5,
+ windowMs: 10 * 60 * 1000, // 10 minutes
+ };
+ // Check email rate limit
+ const emailKey = `booking:email:${email.toLowerCase()}`;
+ const emailResult = checkRateLimit(emailKey, emailConfig);
+ if (!emailResult.allowed) {
+ return {
+ ...emailResult,
+ allowed: false,
+ };
+ }
+ // Check IP rate limit (if IP is available)
+ if (ip) {
+ const ipKey = `booking:ip:${ip}`;
+ const ipResult = checkRateLimit(ipKey, ipConfig);
+ if (!ipResult.allowed) {
+ return {
+ ...ipResult,
+ allowed: false,
+ };
+ }
+ }
+ // Both checks passed
+ return {
+ allowed: true,
+ remaining: Math.min(emailResult.remaining, ip ? Infinity : emailResult.remaining),
+ resetAt: emailResult.resetAt,
+ };
+}
+/**
+ * Get client IP from various headers (for proxy/load balancer support)
+ */
+export function getClientIP(headers) {
+ // Check common proxy headers
+ const forwardedFor = headers['x-forwarded-for'];
+ if (forwardedFor) {
+ // x-forwarded-for can contain multiple IPs, take the first one
+ return forwardedFor.split(',')[0].trim();
+ }
+ const realIP = headers['x-real-ip'];
+ if (realIP) {
+ return realIP;
+ }
+ const cfConnectingIP = headers['cf-connecting-ip']; // Cloudflare
+ if (cfConnectingIP) {
+ return cfConnectingIP;
+ }
+ // No IP found
+ return undefined;
+}
diff --git a/server-dist/routes/caldav.js b/server-dist/routes/caldav.js
new file mode 100644
index 0000000..eeba9c1
--- /dev/null
+++ b/server-dist/routes/caldav.js
@@ -0,0 +1,176 @@
+import { Hono } from "hono";
+import { createKV } from "../lib/create-kv.js";
+// KV-Stores
+const bookingsKV = createKV("bookings");
+const treatmentsKV = createKV("treatments");
+const sessionsKV = createKV("sessions");
+export const caldavApp = new Hono();
+// Helper-Funktionen für ICS-Format
+function formatDateTime(dateStr, timeStr) {
+ // Konvertiere YYYY-MM-DD HH:MM zu UTC-Format für ICS
+ const [year, month, day] = dateStr.split('-').map(Number);
+ const [hours, minutes] = timeStr.split(':').map(Number);
+ const date = new Date(year, month - 1, day, hours, minutes);
+ return date.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '');
+}
+function generateICSContent(bookings, treatments) {
+ const now = new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '');
+ let ics = `BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//Stargirlnails//Booking Calendar//DE
+CALSCALE:GREGORIAN
+METHOD:PUBLISH
+X-WR-CALNAME:Stargirlnails Termine
+X-WR-CALDESC:Terminkalender für Stargirlnails
+X-WR-TIMEZONE:Europe/Berlin
+`;
+ // Nur bestätigte und ausstehende Termine in den Kalender aufnehmen
+ const activeBookings = bookings.filter(b => b.status === 'confirmed' || b.status === 'pending');
+ for (const booking of activeBookings) {
+ const treatment = treatments.find(t => t.id === booking.treatmentId);
+ const treatmentName = treatment?.name || 'Unbekannte Behandlung';
+ const duration = booking.bookedDurationMinutes || treatment?.duration || 60;
+ const startTime = formatDateTime(booking.appointmentDate, booking.appointmentTime);
+ const endTime = formatDateTime(booking.appointmentDate, `${String(Math.floor((parseInt(booking.appointmentTime.split(':')[0]) * 60 + parseInt(booking.appointmentTime.split(':')[1]) + duration) / 60)).padStart(2, '0')}:${String((parseInt(booking.appointmentTime.split(':')[0]) * 60 + parseInt(booking.appointmentTime.split(':')[1]) + duration) % 60).padStart(2, '0')}`);
+ // UID für jeden Termin (eindeutig)
+ const uid = `booking-${booking.id}@stargirlnails.de`;
+ // Status für ICS
+ const status = booking.status === 'confirmed' ? 'CONFIRMED' : 'TENTATIVE';
+ ics += `BEGIN:VEVENT
+UID:${uid}
+DTSTAMP:${now}
+DTSTART:${startTime}
+DTEND:${endTime}
+SUMMARY:${treatmentName} - ${booking.customerName}
+DESCRIPTION:Behandlung: ${treatmentName}\\nKunde: ${booking.customerName}${booking.customerPhone ? `\\nTelefon: ${booking.customerPhone}` : ''}${booking.notes ? `\\nNotizen: ${booking.notes}` : ''}
+STATUS:${status}
+TRANSP:OPAQUE
+END:VEVENT
+`;
+ }
+ ics += `END:VCALENDAR`;
+ return ics;
+}
+// CalDAV Discovery (PROPFIND auf Root)
+caldavApp.all("/", async (c) => {
+ if (c.req.method !== 'PROPFIND') {
+ return c.text('Method Not Allowed', 405);
+ }
+ const response = `
+
+
+ /caldav/
+
+
+ Stargirlnails Terminkalender
+ Termine für Stargirlnails
+
+
+
+ Europe/Berlin
+
+ HTTP/1.1 200 OK
+
+
+`;
+ return c.text(response, 207, {
+ "Content-Type": "application/xml; charset=utf-8",
+ "DAV": "1, 3, calendar-access, calendar-schedule",
+ });
+});
+// Calendar Collection PROPFIND
+caldavApp.all("/calendar/", async (c) => {
+ if (c.req.method !== 'PROPFIND') {
+ return c.text('Method Not Allowed', 405);
+ }
+ const response = `
+
+
+ /caldav/calendar/
+
+
+ Stargirlnails Termine
+ Alle Termine von Stargirlnails
+
+
+
+ Europe/Berlin
+ ${Date.now()}
+ ${Date.now()}
+
+ HTTP/1.1 200 OK
+
+
+`;
+ return c.text(response, 207, {
+ "Content-Type": "application/xml; charset=utf-8",
+ });
+});
+// Calendar Events PROPFIND
+caldavApp.all("/calendar/events.ics", async (c) => {
+ if (c.req.method !== 'PROPFIND') {
+ return c.text('Method Not Allowed', 405);
+ }
+ const response = `
+
+
+ /caldav/calendar/events.ics
+
+
+ text/calendar; charset=utf-8
+ "${Date.now()}"
+ Stargirlnails Termine
+ BEGIN:VCALENDAR\\nVERSION:2.0\\nEND:VCALENDAR
+
+ HTTP/1.1 200 OK
+
+
+`;
+ return c.text(response, 207, {
+ "Content-Type": "application/xml; charset=utf-8",
+ });
+});
+// GET Calendar Data (ICS-Datei)
+caldavApp.get("/calendar/events.ics", async (c) => {
+ try {
+ // Authentifizierung über Token im Query-Parameter
+ const token = c.req.query('token');
+ if (!token) {
+ return c.text('Unauthorized - Token required', 401);
+ }
+ // Token validieren
+ const tokenData = await sessionsKV.getItem(token);
+ if (!tokenData) {
+ return c.text('Unauthorized - Invalid token', 401);
+ }
+ // Prüfe, ob es ein CalDAV-Token ist (durch Ablaufzeit und fehlende type-Eigenschaft erkennbar)
+ // CalDAV-Tokens haben eine kürzere Ablaufzeit (24h) als normale Sessions
+ const tokenAge = Date.now() - new Date(tokenData.createdAt).getTime();
+ if (tokenAge > 24 * 60 * 60 * 1000) { // 24 Stunden
+ return c.text('Unauthorized - Token expired', 401);
+ }
+ // Token-Ablaufzeit prüfen
+ if (new Date(tokenData.expiresAt) < new Date()) {
+ return c.text('Unauthorized - Token expired', 401);
+ }
+ const bookings = await bookingsKV.getAllItems();
+ const treatments = await treatmentsKV.getAllItems();
+ const icsContent = generateICSContent(bookings, treatments);
+ return c.text(icsContent, 200, {
+ "Content-Type": "text/calendar; charset=utf-8",
+ "Content-Disposition": "inline; filename=\"stargirlnails-termine.ics\"",
+ "Cache-Control": "no-cache, no-store, must-revalidate",
+ "Pragma": "no-cache",
+ "Expires": "0",
+ });
+ }
+ catch (error) {
+ console.error("CalDAV GET error:", error);
+ return c.text('Internal Server Error', 500);
+ }
+});
+// Fallback für andere CalDAV-Requests
+caldavApp.all("*", async (c) => {
+ console.log(`CalDAV: Unhandled ${c.req.method} request to ${c.req.url}`);
+ return c.text('Not Found', 404);
+});
diff --git a/server-dist/routes/client-entry.js b/server-dist/routes/client-entry.js
new file mode 100644
index 0000000..3f68228
--- /dev/null
+++ b/server-dist/routes/client-entry.js
@@ -0,0 +1,28 @@
+import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "hono/jsx/jsx-runtime";
+import { readFileSync } from "fs";
+import { join } from "path";
+export function clientEntry(c) {
+ let jsFile = "/src/client/main.tsx";
+ let cssFiles = null;
+ if (process.env.NODE_ENV === 'production') {
+ try {
+ // Read Vite manifest to get the correct file names
+ const manifestPath = join(process.cwd(), 'dist', '.vite', 'manifest.json');
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf-8'));
+ const entry = manifest['index.html'];
+ if (entry) {
+ jsFile = `/${entry.file}`;
+ if (entry.css) {
+ cssFiles = entry.css.map((css) => `/${css}`);
+ }
+ }
+ }
+ catch (error) {
+ console.warn('Could not read Vite manifest, using fallback:', error);
+ // Fallback to a generic path
+ jsFile = "/assets/index-Ccx6A0bN.js";
+ cssFiles = ["/assets/index-RdX4PbOO.css"];
+ }
+ }
+ return c.html(_jsxs("html", { lang: "en", children: [_jsxs("head", { children: [_jsx("meta", { charSet: "utf-8" }), _jsx("meta", { content: "width=device-width, initial-scale=1", name: "viewport" }), _jsx("title", { children: "Stargirlnails Kiel" }), _jsx("link", { rel: "icon", type: "image/png", href: "/favicon.png" }), cssFiles && cssFiles.map((css) => (_jsx("link", { rel: "stylesheet", href: css }, css))), process.env.NODE_ENV === 'production' ? (_jsx("script", { src: jsFile, type: "module" })) : (_jsxs(_Fragment, { children: [_jsx("script", { src: "/@vite/client", type: "module" }), _jsx("script", { src: jsFile, type: "module" })] }))] }), _jsx("body", { children: _jsx("div", { id: "root" }) })] }));
+}
diff --git a/server-dist/routes/rpc.js b/server-dist/routes/rpc.js
new file mode 100644
index 0000000..1d8a37d
--- /dev/null
+++ b/server-dist/routes/rpc.js
@@ -0,0 +1,21 @@
+import { RPCHandler } from "@orpc/server/fetch";
+import { router } from "../rpc/index.js";
+import { Hono } from "hono";
+export const rpcApp = new Hono();
+const handler = new RPCHandler(router);
+rpcApp.all("/*", async (c) => {
+ try {
+ const { matched, response } = await handler.handle(c.req.raw, {
+ prefix: "/rpc",
+ });
+ if (matched) {
+ return c.newResponse(response.body, response);
+ }
+ return c.json({ error: "Not found" }, 404);
+ }
+ catch (error) {
+ console.error("RPC Handler error:", error);
+ // Let oRPC handle errors properly
+ throw error;
+ }
+});
diff --git a/server-dist/rpc/auth.js b/server-dist/rpc/auth.js
new file mode 100644
index 0000000..f8cbf13
--- /dev/null
+++ b/server-dist/rpc/auth.js
@@ -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,
+};
diff --git a/server-dist/rpc/bookings.js b/server-dist/rpc/bookings.js
new file mode 100644
index 0000000..2d97b41
--- /dev/null
+++ b/server-dist/rpc/bookings.js
@@ -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."
+ }
+ };
+ }),
+};
diff --git a/server-dist/rpc/cancellation.js b/server-dist/rpc/cancellation.js
new file mode 100644
index 0000000..5c68def
--- /dev/null
+++ b/server-dist/rpc/cancellation.js
@@ -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 };
+ }),
+};
diff --git a/server-dist/rpc/demo/ai.js b/server-dist/rpc/demo/ai.js
new file mode 100644
index 0000000..7776dd6
--- /dev/null
+++ b/server-dist/rpc/demo/ai.js
@@ -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,
+};
diff --git a/server-dist/rpc/demo/index.js b/server-dist/rpc/demo/index.js
new file mode 100644
index 0000000..3da3489
--- /dev/null
+++ b/server-dist/rpc/demo/index.js
@@ -0,0 +1,4 @@
+import { router as storageRouter } from "./storage.js";
+export const demo = {
+ storage: storageRouter,
+};
diff --git a/server-dist/rpc/demo/storage.js b/server-dist/rpc/demo/storage.js
new file mode 100644
index 0000000..252545f
--- /dev/null
+++ b/server-dist/rpc/demo/storage.js
@@ -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,
+};
diff --git a/server-dist/rpc/gallery.js b/server-dist/rpc/gallery.js
new file mode 100644
index 0000000..102f72e
--- /dev/null
+++ b/server-dist/rpc/gallery.js
@@ -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,
+};
diff --git a/server-dist/rpc/index.js b/server-dist/rpc/index.js
new file mode 100644
index 0000000..6eded94
--- /dev/null
+++ b/server-dist/rpc/index.js
@@ -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,
+};
diff --git a/server-dist/rpc/legal.js b/server-dist/rpc/legal.js
new file mode 100644
index 0000000..fb59011
--- /dev/null
+++ b/server-dist/rpc/legal.js
@@ -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;
+ }
+ }),
+};
diff --git a/server-dist/rpc/recurring-availability.js b/server-dist/rpc/recurring-availability.js
new file mode 100644
index 0000000..d260c45
--- /dev/null
+++ b/server-dist/rpc/recurring-availability.js
@@ -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,
+};
diff --git a/server-dist/rpc/reviews.js b/server-dist/rpc/reviews.js
new file mode 100644
index 0000000..3b1a97a
--- /dev/null
+++ b/server-dist/rpc/reviews.js
@@ -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,
+};
diff --git a/server-dist/rpc/treatments.js b/server-dist/rpc/treatments.js
new file mode 100644
index 0000000..291dae3
--- /dev/null
+++ b/server-dist/rpc/treatments.js
@@ -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,
+};
diff --git a/src/client/components/admin-calendar.tsx b/src/client/components/admin-calendar.tsx
index a5a85ab..e6bc88d 100644
--- a/src/client/components/admin-calendar.tsx
+++ b/src/client/components/admin-calendar.tsx
@@ -9,6 +9,10 @@ export function AdminCalendar() {
const [sendDeleteEmail, setSendDeleteEmail] = useState(false);
const [deleteActionType, setDeleteActionType] = useState<'delete' | 'cancel'>('delete');
+ // CalDAV state
+ const [caldavData, setCaldavData] = useState(null);
+ const [showCaldavInstructions, setShowCaldavInstructions] = useState(false);
+
// Manual booking modal state
const [showCreateModal, setShowCreateModal] = useState(false);
const [createFormData, setCreateFormData] = useState({
@@ -77,6 +81,11 @@ export function AdminCalendar() {
queryClient.bookings.proposeReschedule.mutationOptions()
);
+ // CalDAV token generation mutation
+ const { mutate: generateCalDAVToken, isPending: isGeneratingToken } = useMutation(
+ queryClient.bookings.generateCalDAVToken.mutationOptions()
+ );
+
const getTreatmentName = (treatmentId: string) => {
return treatments?.find(t => t.id === treatmentId)?.name || "Unbekannte Behandlung";
};
@@ -275,6 +284,31 @@ export function AdminCalendar() {
});
};
+ const handleGenerateCalDAVToken = () => {
+ const sessionId = localStorage.getItem('sessionId');
+ if (!sessionId) return;
+
+ generateCalDAVToken({
+ sessionId
+ }, {
+ onSuccess: (data) => {
+ setCaldavData(data);
+ setShowCaldavInstructions(true);
+ },
+ onError: (error: any) => {
+ console.error('CalDAV Token Generation Error:', error);
+ }
+ });
+ };
+
+ const copyToClipboard = (text: string) => {
+ navigator.clipboard.writeText(text).then(() => {
+ // Optional: Show success message
+ }).catch(err => {
+ console.error('Failed to copy text: ', err);
+ });
+ };
+
return (
Kalender - Bevorstehende Buchungen
@@ -307,6 +341,62 @@ export function AdminCalendar() {
+ {/* CalDAV Integration */}
+
+
+
+
Kalender-Abonnement
+
Abonniere deinen Terminkalender in deiner Kalender-App
+
+
+
+
+ {caldavData && (
+
+
+
+
+
+
+
+
+ Gültig bis: {new Date(caldavData.expiresAt).toLocaleString('de-DE')}
+
+
+
+
+
+ So abonnierst du den Kalender:
+
+
+ {caldavData.instructions.steps.map((step: string, index: number) => (
+ - {step}
+ ))}
+
+
+ Hinweis: {caldavData.instructions.note}
+
+
+
+ )}
+
+
{/* Calendar */}
{/* Calendar Header */}
diff --git a/src/server/index.ts b/src/server/index.ts
index 159ff77..6e253d5 100644
--- a/src/server/index.ts
+++ b/src/server/index.ts
@@ -3,6 +3,7 @@ import { serve } from '@hono/node-server';
import { serveStatic } from '@hono/node-server/serve-static';
import { rpcApp } from "./routes/rpc.js";
+import { caldavApp } from "./routes/caldav.js";
import { clientEntry } from "./routes/client-entry.js";
const app = new Hono();
@@ -63,6 +64,7 @@ if (process.env.NODE_ENV === 'production') {
app.use('/favicon.png', serveStatic({ path: './public/favicon.png' }));
app.route("/rpc", rpcApp);
+app.route("/caldav", caldavApp);
app.get("/*", clientEntry);
// Start server
diff --git a/src/server/routes/caldav.ts b/src/server/routes/caldav.ts
new file mode 100644
index 0000000..3e6d73b
--- /dev/null
+++ b/src/server/routes/caldav.ts
@@ -0,0 +1,233 @@
+import { Hono } from "hono";
+import { createKV } from "../lib/create-kv.js";
+import { assertOwner } from "../lib/auth.js";
+
+// Types für Buchungen (vereinfacht für CalDAV)
+type Booking = {
+ id: string;
+ treatmentId: string;
+ customerName: string;
+ customerEmail?: string;
+ customerPhone?: string;
+ appointmentDate: string; // YYYY-MM-DD
+ appointmentTime: string; // HH:MM
+ status: "pending" | "confirmed" | "cancelled" | "completed";
+ notes?: string;
+ bookedDurationMinutes?: number;
+ createdAt: string;
+};
+
+type Treatment = {
+ id: string;
+ name: string;
+ description: string;
+ price: number;
+ duration: number;
+ category: string;
+ createdAt: string;
+};
+
+// KV-Stores
+const bookingsKV = createKV
("bookings");
+const treatmentsKV = createKV("treatments");
+const sessionsKV = createKV("sessions");
+
+export const caldavApp = new Hono();
+
+// Helper-Funktionen für ICS-Format
+function formatDateTime(dateStr: string, timeStr: string): string {
+ // Konvertiere YYYY-MM-DD HH:MM zu UTC-Format für ICS
+ const [year, month, day] = dateStr.split('-').map(Number);
+ const [hours, minutes] = timeStr.split(':').map(Number);
+
+ const date = new Date(year, month - 1, day, hours, minutes);
+ return date.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '');
+}
+
+function generateICSContent(bookings: Booking[], treatments: Treatment[]): string {
+ const now = new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '');
+
+ let ics = `BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//Stargirlnails//Booking Calendar//DE
+CALSCALE:GREGORIAN
+METHOD:PUBLISH
+X-WR-CALNAME:Stargirlnails Termine
+X-WR-CALDESC:Terminkalender für Stargirlnails
+X-WR-TIMEZONE:Europe/Berlin
+`;
+
+ // Nur bestätigte und ausstehende Termine in den Kalender aufnehmen
+ const activeBookings = bookings.filter(b =>
+ b.status === 'confirmed' || b.status === 'pending'
+ );
+
+ for (const booking of activeBookings) {
+ const treatment = treatments.find(t => t.id === booking.treatmentId);
+ const treatmentName = treatment?.name || 'Unbekannte Behandlung';
+ const duration = booking.bookedDurationMinutes || treatment?.duration || 60;
+
+ const startTime = formatDateTime(booking.appointmentDate, booking.appointmentTime);
+ const endTime = formatDateTime(booking.appointmentDate,
+ `${String(Math.floor((parseInt(booking.appointmentTime.split(':')[0]) * 60 + parseInt(booking.appointmentTime.split(':')[1]) + duration) / 60)).padStart(2, '0')}:${String((parseInt(booking.appointmentTime.split(':')[0]) * 60 + parseInt(booking.appointmentTime.split(':')[1]) + duration) % 60).padStart(2, '0')}`
+ );
+
+ // UID für jeden Termin (eindeutig)
+ const uid = `booking-${booking.id}@stargirlnails.de`;
+
+ // Status für ICS
+ const status = booking.status === 'confirmed' ? 'CONFIRMED' : 'TENTATIVE';
+
+ ics += `BEGIN:VEVENT
+UID:${uid}
+DTSTAMP:${now}
+DTSTART:${startTime}
+DTEND:${endTime}
+SUMMARY:${treatmentName} - ${booking.customerName}
+DESCRIPTION:Behandlung: ${treatmentName}\\nKunde: ${booking.customerName}${booking.customerPhone ? `\\nTelefon: ${booking.customerPhone}` : ''}${booking.notes ? `\\nNotizen: ${booking.notes}` : ''}
+STATUS:${status}
+TRANSP:OPAQUE
+END:VEVENT
+`;
+ }
+
+ ics += `END:VCALENDAR`;
+
+ return ics;
+}
+
+// CalDAV Discovery (PROPFIND auf Root)
+caldavApp.all("/", async (c) => {
+ if (c.req.method !== 'PROPFIND') {
+ return c.text('Method Not Allowed', 405);
+ }
+ const response = `
+
+
+ /caldav/
+
+
+ Stargirlnails Terminkalender
+ Termine für Stargirlnails
+
+
+
+ Europe/Berlin
+
+ HTTP/1.1 200 OK
+
+
+`;
+
+ return c.text(response, 207, {
+ "Content-Type": "application/xml; charset=utf-8",
+ "DAV": "1, 3, calendar-access, calendar-schedule",
+ });
+});
+
+// Calendar Collection PROPFIND
+caldavApp.all("/calendar/", async (c) => {
+ if (c.req.method !== 'PROPFIND') {
+ return c.text('Method Not Allowed', 405);
+ }
+ const response = `
+
+
+ /caldav/calendar/
+
+
+ Stargirlnails Termine
+ Alle Termine von Stargirlnails
+
+
+
+ Europe/Berlin
+ ${Date.now()}
+ ${Date.now()}
+
+ HTTP/1.1 200 OK
+
+
+`;
+
+ return c.text(response, 207, {
+ "Content-Type": "application/xml; charset=utf-8",
+ });
+});
+
+// Calendar Events PROPFIND
+caldavApp.all("/calendar/events.ics", async (c) => {
+ if (c.req.method !== 'PROPFIND') {
+ return c.text('Method Not Allowed', 405);
+ }
+ const response = `
+
+
+ /caldav/calendar/events.ics
+
+
+ text/calendar; charset=utf-8
+ "${Date.now()}"
+ Stargirlnails Termine
+ BEGIN:VCALENDAR\\nVERSION:2.0\\nEND:VCALENDAR
+
+ HTTP/1.1 200 OK
+
+
+`;
+
+ return c.text(response, 207, {
+ "Content-Type": "application/xml; charset=utf-8",
+ });
+});
+
+// GET Calendar Data (ICS-Datei)
+caldavApp.get("/calendar/events.ics", async (c) => {
+ try {
+ // Authentifizierung über Token im Query-Parameter
+ const token = c.req.query('token');
+ if (!token) {
+ return c.text('Unauthorized - Token required', 401);
+ }
+
+ // Token validieren
+ const tokenData = await sessionsKV.getItem(token);
+ if (!tokenData) {
+ return c.text('Unauthorized - Invalid token', 401);
+ }
+
+ // Prüfe, ob es ein CalDAV-Token ist (durch Ablaufzeit und fehlende type-Eigenschaft erkennbar)
+ // CalDAV-Tokens haben eine kürzere Ablaufzeit (24h) als normale Sessions
+ const tokenAge = Date.now() - new Date(tokenData.createdAt).getTime();
+ if (tokenAge > 24 * 60 * 60 * 1000) { // 24 Stunden
+ return c.text('Unauthorized - Token expired', 401);
+ }
+
+ // Token-Ablaufzeit prüfen
+ if (new Date(tokenData.expiresAt) < new Date()) {
+ return c.text('Unauthorized - Token expired', 401);
+ }
+
+ const bookings = await bookingsKV.getAllItems();
+ const treatments = await treatmentsKV.getAllItems();
+
+ const icsContent = generateICSContent(bookings, treatments);
+
+ return c.text(icsContent, 200, {
+ "Content-Type": "text/calendar; charset=utf-8",
+ "Content-Disposition": "inline; filename=\"stargirlnails-termine.ics\"",
+ "Cache-Control": "no-cache, no-store, must-revalidate",
+ "Pragma": "no-cache",
+ "Expires": "0",
+ });
+ } catch (error) {
+ console.error("CalDAV GET error:", error);
+ return c.text('Internal Server Error', 500);
+ }
+});
+
+// Fallback für andere CalDAV-Requests
+caldavApp.all("*", async (c) => {
+ console.log(`CalDAV: Unhandled ${c.req.method} request to ${c.req.url}`);
+ return c.text('Not Found', 404);
+});
diff --git a/src/server/rpc/bookings.ts b/src/server/rpc/bookings.ts
index 9b4dd04..efe2d61 100644
--- a/src/server/rpc/bookings.ts
+++ b/src/server/rpc/bookings.ts
@@ -862,4 +862,53 @@ export const router = {
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."
+ }
+ };
+ }),
};
\ No newline at end of file