From fbfdceeee6b805f9bbab54d3529c42222dffde0f Mon Sep 17 00:00:00 2001 From: elpatron Date: Mon, 6 Oct 2025 12:41:50 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20CalDAV-Integration=20f=C3=BCr=20Admin-K?= =?UTF-8?q?alender?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Neue CalDAV-Route mit PROPFIND und GET-Endpoints - ICS-Format-Generator für Buchungsdaten - Token-basierte Authentifizierung für CalDAV-Zugriff - Admin-Interface mit CalDAV-Link-Generator - Schritt-für-Schritt-Anleitung für Kalender-Apps - 24h-Token-Ablaufzeit für Sicherheit - Unterstützung für Outlook, Google Calendar, Apple Calendar, Thunderbird Fixes: Admin kann jetzt Terminkalender in externen Apps abonnieren --- server-dist/index.js | 71 ++ server-dist/lib/auth.js | 13 + server-dist/lib/create-kv.js | 33 + server-dist/lib/email-templates.js | 258 ++++++++ server-dist/lib/email-validator.js | 88 +++ server-dist/lib/email.js | 186 ++++++ server-dist/lib/legal-config.js | 39 ++ server-dist/lib/openai.js | 14 + server-dist/lib/rate-limiter.js | 117 ++++ server-dist/routes/caldav.js | 176 +++++ server-dist/routes/client-entry.js | 28 + server-dist/routes/rpc.js | 21 + server-dist/rpc/auth.js | 148 +++++ server-dist/rpc/bookings.js | 748 ++++++++++++++++++++++ server-dist/rpc/cancellation.js | 310 +++++++++ server-dist/rpc/demo/ai.js | 79 +++ server-dist/rpc/demo/index.js | 4 + server-dist/rpc/demo/storage.js | 42 ++ server-dist/rpc/gallery.js | 131 ++++ server-dist/rpc/index.js | 20 + server-dist/rpc/legal.js | 16 + server-dist/rpc/recurring-availability.js | 396 ++++++++++++ server-dist/rpc/reviews.js | 220 +++++++ server-dist/rpc/treatments.js | 52 ++ src/client/components/admin-calendar.tsx | 90 +++ src/server/index.ts | 2 + src/server/routes/caldav.ts | 233 +++++++ src/server/rpc/bookings.ts | 49 ++ 28 files changed, 3584 insertions(+) create mode 100644 server-dist/index.js create mode 100644 server-dist/lib/auth.js create mode 100644 server-dist/lib/create-kv.js create mode 100644 server-dist/lib/email-templates.js create mode 100644 server-dist/lib/email-validator.js create mode 100644 server-dist/lib/email.js create mode 100644 server-dist/lib/legal-config.js create mode 100644 server-dist/lib/openai.js create mode 100644 server-dist/lib/rate-limiter.js create mode 100644 server-dist/routes/caldav.js create mode 100644 server-dist/routes/client-entry.js create mode 100644 server-dist/routes/rpc.js create mode 100644 server-dist/rpc/auth.js create mode 100644 server-dist/rpc/bookings.js create mode 100644 server-dist/rpc/cancellation.js create mode 100644 server-dist/rpc/demo/ai.js create mode 100644 server-dist/rpc/demo/index.js create mode 100644 server-dist/rpc/demo/storage.js create mode 100644 server-dist/rpc/gallery.js create mode 100644 server-dist/rpc/index.js create mode 100644 server-dist/rpc/legal.js create mode 100644 server-dist/rpc/recurring-availability.js create mode 100644 server-dist/rpc/reviews.js create mode 100644 server-dist/rpc/treatments.js create mode 100644 src/server/routes/caldav.ts 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 ? `Stargirlnails` : `
💅
`} +

${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 +
+ ` : ''} +
+

📋 Rechtliche Informationen:

+

Weitere Informationen findest du in unserem Impressum und Datenschutz.

+
+

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 ? ` +
+

📅 Termin verwalten:

+

Du kannst deinen Termin-Status einsehen und bei Bedarf stornieren:

+ Termin ansehen & verwalten +
+ ` : ''} + ${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.

+
+ ` : ''} +
+

📋 Rechtliche Informationen:

+

Weitere Informationen findest du in unserem Impressum und Datenschutz.

+
+

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.

+
+

📋 Rechtliche Informationen:

+

Weitere Informationen findest du in unserem Impressum und Datenschutz.

+
+

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:

+ +
+

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}. +
+
+ Neuen Termin akzeptieren + Termin ablehnen +
+
+ 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.

+
+ +
+

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.

+
+ +
+

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