feat: CalDAV-Integration für Admin-Kalender

- Neue CalDAV-Route mit PROPFIND und GET-Endpoints
- ICS-Format-Generator für Buchungsdaten
- Token-basierte Authentifizierung für CalDAV-Zugriff
- Admin-Interface mit CalDAV-Link-Generator
- Schritt-für-Schritt-Anleitung für Kalender-Apps
- 24h-Token-Ablaufzeit für Sicherheit
- Unterstützung für Outlook, Google Calendar, Apple Calendar, Thunderbird

Fixes: Admin kann jetzt Terminkalender in externen Apps abonnieren
This commit is contained in:
2025-10-06 12:41:50 +02:00
parent 244eeee142
commit fbfdceeee6
28 changed files with 3584 additions and 0 deletions

71
server-dist/index.js Normal file
View File

@@ -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;

13
server-dist/lib/auth.js Normal file
View File

@@ -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");
}

View File

@@ -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,
};
}

View File

@@ -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 `
<div style="font-family: Arial, sans-serif; color: #0f172a; background:#fdf2f8; padding:24px;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="max-width:640px; margin:0 auto; background:#ffffff; border-radius:12px; overflow:hidden; box-shadow:0 1px 3px rgba(0,0,0,0.06)">
<tr>
<td style="padding:24px 24px 0 24px; text-align:center;">
${logo ? `<img src="${logo}" alt="Stargirlnails" style="width:120px; height:auto; display:inline-block;" />` : `<div style=\"font-size:24px\">💅</div>`}
<h1 style="margin:16px 0 0 0; font-size:22px; color:#db2777;">${title}</h1>
</td>
</tr>
<tr>
<td style="padding:16px 24px 24px 24px;">
<div style="font-size:16px; line-height:1.6; color:#334155;">
${bodyHtml}
</div>
<hr style="border:none; border-top:1px solid #f1f5f9; margin:24px 0" />
<div style="text-align:center; margin-bottom:16px;">
<a href="${homepageUrl}" style="display: inline-block; background-color: #db2777; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: 600; font-size: 14px;">Zur Website</a>
</div>
<div style="font-size:12px; color:#64748b; text-align:center;">
&copy; ${new Date().getFullYear()} Stargirlnails Kiel • Professional Nail Care
</div>
</td>
</tr>
</table>
</div>`;
}
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 = `
<p>Hallo ${name},</p>
<p>wir haben deine Anfrage für <strong>${formattedDate}</strong> um <strong>${time}</strong> erhalten.</p>
<p>Wir bestätigen deinen Termin in Kürze. Du erhältst eine weitere E-Mail, sobald der Termin bestätigt ist.</p>
${statusUrl ? `
<div style="background-color: #fef9f5; border-left: 4px solid #f59e0b; padding: 16px; margin: 20px 0; border-radius: 4px;">
<p style="margin: 0; font-weight: 600; color: #f59e0b;">⏳ Termin-Status ansehen:</p>
<p style="margin: 8px 0 12px 0; color: #475569;">Du kannst den aktuellen Status deiner Buchung jederzeit einsehen:</p>
<a href="${statusUrl}" style="display: inline-block; background-color: #f59e0b; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: 600;">Status ansehen</a>
</div>
` : ''}
<div style="background-color: #f8fafc; border-left: 4px solid #3b82f6; padding: 16px; margin: 20px 0; border-radius: 4px;">
<p style="margin: 0; font-weight: 600; color: #3b82f6;">📋 Rechtliche Informationen:</p>
<p style="margin: 8px 0 12px 0; color: #475569;">Weitere Informationen findest du in unserem <a href="${legalUrl}" style="color: #3b82f6; text-decoration: underline;">Impressum und Datenschutz</a>.</p>
</div>
<p>Liebe Grüße,<br/>Stargirlnails Kiel</p>
`;
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 = `
<p>Hallo ${name},</p>
<p>wir haben deinen Termin am <strong>${formattedDate}</strong> um <strong>${time}</strong> bestätigt.</p>
<p>Wir freuen uns auf dich!</p>
<div style="background-color: #f8fafc; border-left: 4px solid #db2777; padding: 16px; margin: 20px 0; border-radius: 4px;">
<p style="margin: 0; font-weight: 600; color: #db2777;">📋 Wichtiger Hinweis:</p>
<p style="margin: 8px 0 0 0; color: #475569;">Die Allgemeinen Geschäftsbedingungen (AGB) findest du im Anhang dieser E-Mail. Bitte lies sie vor deinem Termin durch.</p>
</div>
${cancellationUrl ? `
<div style="background-color: #fef9f5; border-left: 4px solid #db2777; padding: 16px; margin: 20px 0; border-radius: 4px;">
<p style="margin: 0; font-weight: 600; color: #db2777;">📅 Termin verwalten:</p>
<p style="margin: 8px 0 12px 0; color: #475569;">Du kannst deinen Termin-Status einsehen und bei Bedarf stornieren:</p>
<a href="${cancellationUrl}" style="display: inline-block; background-color: #db2777; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: 600;">Termin ansehen & verwalten</a>
</div>
` : ''}
${reviewUrl ? `
<div style="background-color: #eff6ff; border-left: 4px solid #3b82f6; padding: 16px; margin: 20px 0; border-radius: 4px;">
<p style="margin: 0; font-weight: 600; color: #3b82f6;">⭐ Bewertung abgeben:</p>
<p style="margin: 8px 0 12px 0; color: #475569;">Nach deinem Termin würden wir uns über deine Bewertung freuen!</p>
<a href="${reviewUrl}" style="display: inline-block; background-color: #3b82f6; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: 600;">Bewertung schreiben</a>
<p style="margin: 12px 0 0 0; color: #64748b; font-size: 13px;">Du kannst deine Bewertung nach dem Termin über diesen Link abgeben.</p>
</div>
` : ''}
<div style="background-color: #f8fafc; border-left: 4px solid #3b82f6; padding: 16px; margin: 20px 0; border-radius: 4px;">
<p style="margin: 0; font-weight: 600; color: #3b82f6;">📋 Rechtliche Informationen:</p>
<p style="margin: 8px 0 12px 0; color: #475569;">Weitere Informationen findest du in unserem <a href="${legalUrl}" style="color: #3b82f6; text-decoration: underline;">Impressum und Datenschutz</a>.</p>
</div>
<p>Liebe Grüße,<br/>Stargirlnails Kiel</p>
`;
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 = `
<p>Hallo ${name},</p>
<p>dein Termin am <strong>${formattedDate}</strong> um <strong>${time}</strong> wurde abgesagt.</p>
<p>Bitte buche einen neuen Termin. Bei Fragen helfen wir dir gerne weiter.</p>
<div style="background-color: #f8fafc; border-left: 4px solid #3b82f6; padding: 16px; margin: 20px 0; border-radius: 4px;">
<p style="margin: 0; font-weight: 600; color: #3b82f6;">📋 Rechtliche Informationen:</p>
<p style="margin: 8px 0 12px 0; color: #475569;">Weitere Informationen findest du in unserem <a href="${legalUrl}" style="color: #3b82f6; text-decoration: underline;">Impressum und Datenschutz</a>.</p>
</div>
<p>Liebe Grüße,<br/>Stargirlnails Kiel</p>
`;
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 = `
<p>Hallo Admin,</p>
<p>eine neue Buchungsanfrage ist eingegangen:</p>
<div style="background-color: #f8fafc; border-left: 4px solid #db2777; padding: 16px; margin: 20px 0; border-radius: 4px;">
<p style="margin: 0; font-weight: 600; color: #db2777;">📅 Buchungsdetails:</p>
<ul style="margin: 8px 0 0 0; color: #475569; list-style: none; padding: 0;">
<li><strong>Name:</strong> ${name}</li>
<li><strong>Telefon:</strong> ${phone}</li>
<li><strong>Behandlung:</strong> ${treatment}</li>
<li><strong>Datum:</strong> ${formattedDate}</li>
<li><strong>Uhrzeit:</strong> ${time}</li>
${notes ? `<li><strong>Notizen:</strong> ${notes}</li>` : ''}
<li><strong>Inspiration-Foto:</strong> ${hasInspirationPhoto ? '✅ Im Anhang verfügbar' : '❌ Kein Foto hochgeladen'}</li>
</ul>
</div>
<p>Bitte logge dich in das Admin-Panel ein, um die Buchung zu bestätigen oder abzulehnen.</p>
<p>Liebe Grüße,<br/>Stargirlnails System</p>
`;
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 = `
<p>Hallo ${params.name},</p>
<p>wir müssen deinen Termin leider verschieben. Hier ist unser Vorschlag:</p>
<div style="background-color: #f8fafc; border-left: 4px solid #f59e0b; padding: 16px; margin: 20px 0; border-radius: 4px;">
<p style="margin: 0; font-weight: 600; color: #92400e;">📅 Übersicht</p>
<table role="presentation" cellspacing="0" cellpadding="0" style="width:100%; margin-top:8px; font-size:14px; color:#475569;">
<tr>
<td style="padding:6px 0; width:45%"><strong>Alter Termin</strong></td>
<td style="padding:6px 0;">${formattedOriginalDate} um ${params.originalTime} Uhr</td>
</tr>
<tr>
<td style="padding:6px 0; width:45%"><strong>Neuer Vorschlag</strong></td>
<td style="padding:6px 0; color:#b45309;"><strong>${formattedProposedDate} um ${params.proposedTime} Uhr</strong></td>
</tr>
<tr>
<td style="padding:6px 0; width:45%"><strong>Behandlung</strong></td>
<td style="padding:6px 0;">${params.treatmentName}</td>
</tr>
</table>
</div>
<div style="background-color: #fffbeb; border-left: 4px solid #f59e0b; padding: 12px; margin: 16px 0; border-radius: 4px; color:#92400e;">
⏰ Bitte antworte bis ${formattedExpiry}.
</div>
<div style="text-align:center; margin: 20px 0;">
<a href="${params.acceptUrl}" style="display:inline-block; background-color:#16a34a; color:#ffffff; padding:12px 18px; border-radius:8px; text-decoration:none; font-weight:700; margin-right:8px;">Neuen Termin akzeptieren</a>
<a href="${params.declineUrl}" style="display:inline-block; background-color:#dc2626; color:#ffffff; padding:12px 18px; border-radius:8px; text-decoration:none; font-weight:700;">Termin ablehnen</a>
</div>
<div style="background-color: #f8fafc; border-left: 4px solid #10b981; padding: 12px; margin: 16px 0; border-radius: 4px; color:#065f46;">
Wenn du den Vorschlag ablehnst, bleibt dein ursprünglicher Termin bestehen und wir kontaktieren dich für eine alternative Lösung.
</div>
<p>Falls du einen komplett neuen Termin buchen möchtest, kannst du deinen aktuellen Termin stornieren und einen neuen Termin auf unserer Website buchen.</p>
<p>Liebe Grüße,<br/>Stargirlnails Kiel</p>
`;
return renderBrandedEmail("Terminänderung vorgeschlagen", inner);
}
export async function renderAdminRescheduleDeclinedHTML(params) {
const inner = `
<p>Hallo Admin,</p>
<p>der Kunde <strong>${params.customerName}</strong> hat den Terminänderungsvorschlag abgelehnt.</p>
<div style="background-color:#f8fafc; border-left:4px solid #ef4444; padding:16px; margin:16px 0; border-radius:4px;">
<ul style="margin:0; padding:0; list-style:none; color:#475569; font-size:14px;">
<li><strong>Kunde:</strong> ${params.customerName}</li>
${params.customerEmail ? `<li><strong>E-Mail:</strong> ${params.customerEmail}</li>` : ''}
${params.customerPhone ? `<li><strong>Telefon:</strong> ${params.customerPhone}</li>` : ''}
<li><strong>Behandlung:</strong> ${params.treatmentName}</li>
<li><strong>Ursprünglicher Termin:</strong> ${formatDateGerman(params.originalDate)} um ${params.originalTime} Uhr (bleibt bestehen)</li>
<li><strong>Abgelehnter Vorschlag:</strong> ${formatDateGerman(params.proposedDate)} um ${params.proposedTime} Uhr</li>
</ul>
</div>
<p>Bitte kontaktiere den Kunden, um eine alternative Lösung zu finden.</p>
`;
return renderBrandedEmail("Kunde hat Terminänderung abgelehnt", inner);
}
export async function renderAdminRescheduleAcceptedHTML(params) {
const inner = `
<p>Hallo Admin,</p>
<p>der Kunde <strong>${params.customerName}</strong> hat den Terminänderungsvorschlag akzeptiert.</p>
<div style="background-color:#ecfeff; border-left:4px solid #10b981; padding:16px; margin:16px 0; border-radius:4px;">
<ul style="margin:0; padding:0; list-style:none; color:#475569; font-size:14px;">
<li><strong>Kunde:</strong> ${params.customerName}</li>
<li><strong>Behandlung:</strong> ${params.treatmentName}</li>
<li><strong>Alter Termin:</strong> ${formatDateGerman(params.originalDate)} um ${params.originalTime} Uhr</li>
<li><strong>Neuer Termin:</strong> ${formatDateGerman(params.newDate)} um ${params.newTime} Uhr ✅</li>
</ul>
</div>
<p>Der Termin wurde automatisch aktualisiert.</p>
`;
return renderBrandedEmail("Kunde hat Terminänderung akzeptiert", inner);
}
export async function renderAdminRescheduleExpiredHTML(params) {
const inner = `
<p>Hallo Admin,</p>
<p><strong>${params.expiredProposals.length} Terminänderungsvorschlag${params.expiredProposals.length > 1 ? 'e' : ''} ${params.expiredProposals.length > 1 ? 'sind' : 'ist'} abgelaufen</strong> und wurde${params.expiredProposals.length > 1 ? 'n' : ''} automatisch entfernt.</p>
<div style="background-color:#fef2f2; border-left:4px solid #ef4444; padding:16px; margin:16px 0; border-radius:4px;">
<p style="margin:0 0 12px 0; font-weight:600; color:#dc2626;">⚠️ Abgelaufene Vorschläge:</p>
${params.expiredProposals.map(proposal => `
<div style="background-color:#ffffff; border:1px solid #fecaca; border-radius:4px; padding:12px; margin:8px 0;">
<ul style="margin:0; padding:0; list-style:none; color:#475569; font-size:13px;">
<li><strong>Kunde:</strong> ${proposal.customerName}</li>
${proposal.customerEmail ? `<li><strong>E-Mail:</strong> ${proposal.customerEmail}</li>` : ''}
${proposal.customerPhone ? `<li><strong>Telefon:</strong> ${proposal.customerPhone}</li>` : ''}
<li><strong>Behandlung:</strong> ${proposal.treatmentName}</li>
<li><strong>Ursprünglicher Termin:</strong> ${formatDateGerman(proposal.originalDate)} um ${proposal.originalTime} Uhr</li>
<li><strong>Vorgeschlagener Termin:</strong> ${formatDateGerman(proposal.proposedDate)} um ${proposal.proposedTime} Uhr</li>
<li><strong>Abgelaufen am:</strong> ${new Date(proposal.expiredAt).toLocaleString('de-DE')}</li>
</ul>
</div>
`).join('')}
</div>
<p style="color:#dc2626; font-weight:600;">Bitte kontaktiere die Kunden, um eine alternative Lösung zu finden.</p>
<p>Die ursprünglichen Termine bleiben bestehen.</p>
`;
return renderBrandedEmail("Abgelaufene Terminänderungsvorschläge", inner);
}

View File

@@ -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;
}

186
server-dist/lib/email.js Normal file
View File

@@ -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 <no-reply@stargirlnails.de>";
// 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);
}

View File

@@ -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;
}

14
server-dist/lib/openai.js Normal file
View File

@@ -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))));
}

View File

@@ -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;
}

View File

@@ -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 = `<?xml version="1.0" encoding="utf-8"?>
<D:multistatus xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
<D:response>
<D:href>/caldav/</D:href>
<D:propstat>
<D:prop>
<D:displayname>Stargirlnails Terminkalender</D:displayname>
<C:calendar-description>Termine für Stargirlnails</C:calendar-description>
<C:supported-calendar-component-set>
<C:comp name="VEVENT"/>
</C:supported-calendar-component-set>
<C:calendar-timezone>Europe/Berlin</C:calendar-timezone>
</D:prop>
<D:status>HTTP/1.1 200 OK</D:status>
</D:propstat>
</D:response>
</D:multistatus>`;
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 = `<?xml version="1.0" encoding="utf-8"?>
<D:multistatus xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav" xmlns:CS="http://calendarserver.org/ns/">
<D:response>
<D:href>/caldav/calendar/</D:href>
<D:propstat>
<D:prop>
<D:displayname>Stargirlnails Termine</D:displayname>
<C:calendar-description>Alle Termine von Stargirlnails</C:calendar-description>
<C:supported-calendar-component-set>
<C:comp name="VEVENT"/>
</C:supported-calendar-component-set>
<C:calendar-timezone>Europe/Berlin</C:calendar-timezone>
<CS:getctag>${Date.now()}</CS:getctag>
<D:sync-token>${Date.now()}</D:sync-token>
</D:prop>
<D:status>HTTP/1.1 200 OK</D:status>
</D:propstat>
</D:response>
</D:multistatus>`;
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 = `<?xml version="1.0" encoding="utf-8"?>
<D:multistatus xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav" xmlns:CS="http://calendarserver.org/ns/">
<D:response>
<D:href>/caldav/calendar/events.ics</D:href>
<D:propstat>
<D:prop>
<D:getcontenttype>text/calendar; charset=utf-8</D:getcontenttype>
<D:getetag>"${Date.now()}"</D:getetag>
<D:displayname>Stargirlnails Termine</D:displayname>
<C:calendar-data>BEGIN:VCALENDAR\\nVERSION:2.0\\nEND:VCALENDAR</C:calendar-data>
</D:prop>
<D:status>HTTP/1.1 200 OK</D:status>
</D:propstat>
</D:response>
</D:multistatus>`;
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);
});

View File

@@ -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" }) })] }));
}

21
server-dist/routes/rpc.js Normal file
View File

@@ -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;
}
});

148
server-dist/rpc/auth.js Normal file
View File

@@ -0,0 +1,148 @@
import { os } from "@orpc/server";
import { z } from "zod";
import { randomUUID } from "crypto";
import { createKV } from "../lib/create-kv.js";
import { config } from "dotenv";
// Load environment variables from .env file
config();
const UserSchema = z.object({
id: z.string(),
username: z.string().min(3, "Benutzername muss mindestens 3 Zeichen lang sein"),
email: z.string().email("Ungültige E-Mail-Adresse"),
passwordHash: z.string(),
role: z.enum(["customer", "owner"]),
createdAt: z.string(),
});
const SessionSchema = z.object({
id: z.string(),
userId: z.string(),
expiresAt: z.string(),
createdAt: z.string(),
});
const usersKV = createKV("users");
const sessionsKV = createKV("sessions");
// Simple password hashing (in production, use bcrypt or similar)
const hashPassword = (password) => {
return Buffer.from(password).toString('base64');
};
const verifyPassword = (password, hash) => {
return hashPassword(password) === hash;
};
// Export hashPassword for external use (e.g., generating hashes for .env)
export const generatePasswordHash = hashPassword;
// Initialize default owner account
const initializeOwner = async () => {
const existingUsers = await usersKV.getAllItems();
if (existingUsers.length === 0) {
const ownerId = randomUUID();
// Get admin credentials from environment variables
const adminUsername = process.env.ADMIN_USERNAME || "owner";
const adminPasswordHash = process.env.ADMIN_PASSWORD_HASH || hashPassword("admin123");
const adminEmail = process.env.ADMIN_EMAIL || "owner@stargirlnails.de";
const owner = {
id: ownerId,
username: adminUsername,
email: adminEmail,
passwordHash: adminPasswordHash,
role: "owner",
createdAt: new Date().toISOString(),
};
await usersKV.setItem(ownerId, owner);
console.log(`✅ Admin account created: username="${adminUsername}", email="${adminEmail}"`);
}
};
// Initialize on module load
initializeOwner();
const login = os
.input(z.object({
username: z.string(),
password: z.string(),
}))
.handler(async ({ input }) => {
const users = await usersKV.getAllItems();
const user = users.find(u => u.username === input.username);
if (!user || !verifyPassword(input.password, user.passwordHash)) {
throw new Error("Invalid credentials");
}
// Create session
const sessionId = randomUUID();
const expiresAt = new Date();
expiresAt.setHours(expiresAt.getHours() + 24); // 24 hours
const session = {
id: sessionId,
userId: user.id,
expiresAt: expiresAt.toISOString(),
createdAt: new Date().toISOString(),
};
await sessionsKV.setItem(sessionId, session);
return {
sessionId,
user: {
id: user.id,
username: user.username,
email: user.email,
role: user.role,
},
};
});
const logout = os
.input(z.string()) // sessionId
.handler(async ({ input }) => {
await sessionsKV.removeItem(input);
return { success: true };
});
const verifySession = os
.input(z.string()) // sessionId
.handler(async ({ input }) => {
const session = await sessionsKV.getItem(input);
if (!session) {
throw new Error("Invalid session");
}
if (new Date(session.expiresAt) < new Date()) {
await sessionsKV.removeItem(input);
throw new Error("Session expired");
}
const user = await usersKV.getItem(session.userId);
if (!user) {
throw new Error("User not found");
}
return {
user: {
id: user.id,
username: user.username,
email: user.email,
role: user.role,
},
};
});
const changePassword = os
.input(z.object({
sessionId: z.string(),
currentPassword: z.string(),
newPassword: z.string(),
}))
.handler(async ({ input }) => {
const session = await sessionsKV.getItem(input.sessionId);
if (!session) {
throw new Error("Invalid session");
}
const user = await usersKV.getItem(session.userId);
if (!user) {
throw new Error("User not found");
}
if (!verifyPassword(input.currentPassword, user.passwordHash)) {
throw new Error("Current password is incorrect");
}
const updatedUser = {
...user,
passwordHash: hashPassword(input.newPassword),
};
await usersKV.setItem(user.id, updatedUser);
return { success: true };
});
export const router = {
login,
logout,
verifySession,
changePassword,
};

748
server-dist/rpc/bookings.js Normal file
View File

@@ -0,0 +1,748 @@
import { call, os } from "@orpc/server";
import { z } from "zod";
import { randomUUID } from "crypto";
import { createKV } from "../lib/create-kv.js";
import { sendEmail, sendEmailWithAGBAndCalendar, sendEmailWithInspirationPhoto } from "../lib/email.js";
import { renderBookingPendingHTML, renderBookingConfirmedHTML, renderBookingCancelledHTML, renderAdminBookingNotificationHTML, renderBookingRescheduleProposalHTML, renderAdminRescheduleAcceptedHTML, renderAdminRescheduleDeclinedHTML } from "../lib/email-templates.js";
import { createORPCClient } from "@orpc/client";
import { RPCLink } from "@orpc/client/fetch";
import { checkBookingRateLimit } from "../lib/rate-limiter.js";
import { validateEmail } from "../lib/email-validator.js";
// Create a server-side client to call other RPC endpoints
const serverPort = process.env.PORT ? parseInt(process.env.PORT) : 3000;
const link = new RPCLink({ url: `http://localhost:${serverPort}/rpc` });
// Typisierung über any, um Build-Inkompatibilität mit NestedClient zu vermeiden (nur für interne Server-Calls)
const queryClient = createORPCClient(link);
// Helper function to convert date from yyyy-mm-dd to dd.mm.yyyy
function formatDateGerman(dateString) {
const [year, month, day] = dateString.split('-');
return `${day}.${month}.${year}`;
}
// Helper function to generate URLs from DOMAIN environment variable
function generateUrl(path = '') {
const domain = process.env.DOMAIN || 'localhost:5173';
const protocol = domain.includes('localhost') ? 'http' : 'https';
return `${protocol}://${domain}${path}`;
}
// Helper function to parse time string to minutes since midnight
function parseTime(timeStr) {
const [hours, minutes] = timeStr.split(':').map(Number);
return hours * 60 + minutes;
}
// Helper function to check if date is in time-off period
function isDateInTimeOffPeriod(date, periods) {
return periods.some(period => date >= period.startDate && date <= period.endDate);
}
// Helper function to validate booking time against recurring rules
async function validateBookingAgainstRules(date, time, treatmentDuration) {
// Parse date to get day of week
const [year, month, day] = date.split('-').map(Number);
const localDate = new Date(year, month - 1, day);
const dayOfWeek = localDate.getDay();
// Check time-off periods
const timeOffPeriods = await timeOffPeriodsKV.getAllItems();
if (isDateInTimeOffPeriod(date, timeOffPeriods)) {
throw new Error("Dieser Tag ist nicht verfügbar (Urlaubszeit).");
}
// Find matching recurring rules
const allRules = await recurringRulesKV.getAllItems();
const matchingRules = allRules.filter(rule => rule.isActive === true && rule.dayOfWeek === dayOfWeek);
if (matchingRules.length === 0) {
throw new Error("Für diesen Wochentag sind keine Termine verfügbar.");
}
// Check if booking time falls within any rule's time span
const bookingStartMinutes = parseTime(time);
const bookingEndMinutes = bookingStartMinutes + treatmentDuration;
const isWithinRules = matchingRules.some(rule => {
const ruleStartMinutes = parseTime(rule.startTime);
const ruleEndMinutes = parseTime(rule.endTime);
// Booking must start at or after rule start and end at or before rule end
return bookingStartMinutes >= ruleStartMinutes && bookingEndMinutes <= ruleEndMinutes;
});
if (!isWithinRules) {
throw new Error("Die gewählte Uhrzeit liegt außerhalb der verfügbaren Zeiten.");
}
}
// Helper function to check for booking conflicts
async function checkBookingConflicts(date, time, treatmentDuration, excludeBookingId) {
const allBookings = await kv.getAllItems();
const dateBookings = allBookings.filter(booking => booking.appointmentDate === date &&
['pending', 'confirmed', 'completed'].includes(booking.status) &&
booking.id !== excludeBookingId);
const bookingStartMinutes = parseTime(time);
const bookingEndMinutes = bookingStartMinutes + treatmentDuration;
// Cache treatment durations by ID to avoid N+1 lookups
const uniqueTreatmentIds = [...new Set(dateBookings.map(booking => booking.treatmentId))];
const treatmentDurationMap = new Map();
for (const treatmentId of uniqueTreatmentIds) {
const treatment = await treatmentsKV.getItem(treatmentId);
treatmentDurationMap.set(treatmentId, treatment?.duration || 60);
}
// Check for overlaps with existing bookings
for (const existingBooking of dateBookings) {
// Use cached duration or fallback to bookedDurationMinutes if available
let existingDuration = treatmentDurationMap.get(existingBooking.treatmentId) || 60;
if (existingBooking.bookedDurationMinutes) {
existingDuration = existingBooking.bookedDurationMinutes;
}
const existingStartMinutes = parseTime(existingBooking.appointmentTime);
const existingEndMinutes = existingStartMinutes + existingDuration;
// Check overlap: bookingStart < existingEnd && bookingEnd > existingStart
if (bookingStartMinutes < existingEndMinutes && bookingEndMinutes > existingStartMinutes) {
throw new Error("Dieser Zeitslot ist bereits gebucht. Bitte wähle eine andere Zeit.");
}
}
}
const CreateBookingInputSchema = z.object({
treatmentId: z.string(),
customerName: z.string().min(2, "Name muss mindestens 2 Zeichen lang sein"),
customerEmail: z.string().email("Ungültige E-Mail-Adresse"),
customerPhone: z.string().min(5, "Telefonnummer muss mindestens 5 Zeichen lang sein").optional(),
appointmentDate: z.string(), // ISO date string
appointmentTime: z.string(), // HH:MM format
notes: z.string().optional(),
inspirationPhoto: z.string().optional(), // Base64 encoded image data
});
const BookingSchema = z.object({
id: z.string(),
treatmentId: z.string(),
customerName: z.string().min(2, "Name muss mindestens 2 Zeichen lang sein"),
customerEmail: z.string().email("Ungültige E-Mail-Adresse").optional(),
customerPhone: z.string().min(5, "Telefonnummer muss mindestens 5 Zeichen lang sein").optional(),
appointmentDate: z.string(), // ISO date string
appointmentTime: z.string(), // HH:MM format
status: z.enum(["pending", "confirmed", "cancelled", "completed"]),
notes: z.string().optional(),
inspirationPhoto: z.string().optional(), // Base64 encoded image data
bookedDurationMinutes: z.number().optional(), // Snapshot of treatment duration at booking time
createdAt: z.string(),
// DEPRECATED: slotId is no longer used for validation, kept for backward compatibility
slotId: z.string().optional(),
});
const kv = createKV("bookings");
const recurringRulesKV = createKV("recurringRules");
const timeOffPeriodsKV = createKV("timeOffPeriods");
const treatmentsKV = createKV("treatments");
const create = os
.input(CreateBookingInputSchema)
.handler(async ({ input }) => {
// console.log("Booking create called with input:", {
// ...input,
// inspirationPhoto: input.inspirationPhoto ? `[${input.inspirationPhoto.length} chars]` : null
// });
try {
// Rate limiting check (ohne IP, falls Context-Header im Build nicht verfügbar sind)
const rateLimitResult = checkBookingRateLimit({
ip: undefined,
email: input.customerEmail,
});
if (!rateLimitResult.allowed) {
const retryMinutes = rateLimitResult.retryAfterSeconds
? Math.ceil(rateLimitResult.retryAfterSeconds / 60)
: 10;
throw new Error(`Zu viele Buchungsanfragen. Bitte versuche es in ${retryMinutes} Minute${retryMinutes > 1 ? 'n' : ''} erneut.`);
}
// Email validation before slot reservation
console.log(`Validating email: ${input.customerEmail}`);
const emailValidation = await validateEmail(input.customerEmail);
console.log(`Email validation result:`, emailValidation);
if (!emailValidation.valid) {
console.log(`Email validation failed: ${emailValidation.reason}`);
throw new Error(emailValidation.reason || "Ungültige E-Mail-Adresse");
}
// Validate appointment time is on 15-minute grid
const appointmentMinutes = parseTime(input.appointmentTime);
if (appointmentMinutes % 15 !== 0) {
throw new Error("Termine müssen auf 15-Minuten-Raster ausgerichtet sein (z.B. 09:00, 09:15, 09:30, 09:45).");
}
// Validate that the booking is not in the past
const today = new Date().toISOString().split("T")[0]; // YYYY-MM-DD
if (input.appointmentDate < today) {
throw new Error("Buchungen für vergangene Termine sind nicht möglich.");
}
// For today's bookings, check if the time is not in the past
if (input.appointmentDate === today) {
const now = new Date();
const currentTime = `${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}`;
if (input.appointmentTime <= currentTime) {
throw new Error("Buchungen für vergangene Uhrzeiten sind nicht möglich.");
}
}
// Prevent double booking: same customer email with pending/confirmed on same date
// Skip duplicate check when DISABLE_DUPLICATE_CHECK is set
if (!process.env.DISABLE_DUPLICATE_CHECK) {
const existing = await kv.getAllItems();
const hasConflict = existing.some(b => (b.customerEmail && b.customerEmail.toLowerCase() === input.customerEmail.toLowerCase()) &&
b.appointmentDate === input.appointmentDate &&
(b.status === "pending" || b.status === "confirmed"));
if (hasConflict) {
throw new Error("Du hast bereits eine Buchung für dieses Datum. Bitte wähle einen anderen Tag oder storniere zuerst.");
}
}
// Get treatment duration for validation
const treatment = await treatmentsKV.getItem(input.treatmentId);
if (!treatment) {
throw new Error("Behandlung nicht gefunden.");
}
// Validate booking time against recurring rules
await validateBookingAgainstRules(input.appointmentDate, input.appointmentTime, treatment.duration);
// Check for booking conflicts
await checkBookingConflicts(input.appointmentDate, input.appointmentTime, treatment.duration);
const id = randomUUID();
const booking = {
id,
...input,
bookedDurationMinutes: treatment.duration, // Snapshot treatment duration
status: "pending",
createdAt: new Date().toISOString()
};
// Save the booking
await kv.setItem(id, booking);
// Notify customer: request received (pending)
void (async () => {
// Create booking access token for status viewing
const bookingAccessToken = await queryClient.cancellation.createToken({ bookingId: id });
const bookingUrl = generateUrl(`/booking/${bookingAccessToken.token}`);
const formattedDate = formatDateGerman(input.appointmentDate);
const homepageUrl = generateUrl();
const html = await renderBookingPendingHTML({
name: input.customerName,
date: input.appointmentDate,
time: input.appointmentTime,
statusUrl: bookingUrl
});
await sendEmail({
to: input.customerEmail,
subject: "Deine Terminanfrage ist eingegangen",
text: `Hallo ${input.customerName},\n\nwir haben deine Anfrage für ${formattedDate} um ${input.appointmentTime} erhalten. Wir bestätigen deinen Termin in Kürze. Du erhältst eine weitere E-Mail, sobald der Termin bestätigt ist.\n\nTermin-Status ansehen: ${bookingUrl}\n\nRechtliche Informationen: ${generateUrl('/legal')}\nZur Website: ${homepageUrl}\n\nLiebe Grüße\nStargirlnails Kiel`,
html,
}).catch(() => { });
})();
// Notify admin: new booking request (with photo if available)
void (async () => {
if (!process.env.ADMIN_EMAIL)
return;
// Get treatment name from KV
const allTreatments = await treatmentsKV.getAllItems();
const treatment = allTreatments.find(t => t.id === input.treatmentId);
const treatmentName = treatment?.name || "Unbekannte Behandlung";
const adminHtml = await renderAdminBookingNotificationHTML({
name: input.customerName,
date: input.appointmentDate,
time: input.appointmentTime,
treatment: treatmentName,
phone: input.customerPhone || "Nicht angegeben",
notes: input.notes,
hasInspirationPhoto: !!input.inspirationPhoto
});
const homepageUrl = generateUrl();
const adminText = `Neue Buchungsanfrage eingegangen:\n\n` +
`Name: ${input.customerName}\n` +
`Telefon: ${input.customerPhone || "Nicht angegeben"}\n` +
`Behandlung: ${treatmentName}\n` +
`Datum: ${formatDateGerman(input.appointmentDate)}\n` +
`Uhrzeit: ${input.appointmentTime}\n` +
`${input.notes ? `Notizen: ${input.notes}\n` : ''}` +
`Inspiration-Foto: ${input.inspirationPhoto ? 'Im Anhang verfügbar' : 'Kein Foto hochgeladen'}\n\n` +
`Zur Website: ${homepageUrl}\n\n` +
`Bitte logge dich in das Admin-Panel ein, um die Buchung zu bearbeiten.`;
if (input.inspirationPhoto) {
await sendEmailWithInspirationPhoto({
to: process.env.ADMIN_EMAIL,
subject: `Neue Buchungsanfrage - ${input.customerName}`,
text: adminText,
html: adminHtml,
}, input.inspirationPhoto, input.customerName).catch(() => { });
}
else {
await sendEmail({
to: process.env.ADMIN_EMAIL,
subject: `Neue Buchungsanfrage - ${input.customerName}`,
text: adminText,
html: adminHtml,
}).catch(() => { });
}
})();
return booking;
}
catch (error) {
console.error("Booking creation error:", error);
// Re-throw the error for oRPC to handle
throw error;
}
});
const sessionsKV = createKV("sessions");
const usersKV = createKV("users");
async function assertOwner(sessionId) {
const session = await sessionsKV.getItem(sessionId);
if (!session)
throw new Error("Invalid session");
if (new Date(session.expiresAt) < new Date())
throw new Error("Session expired");
const user = await usersKV.getItem(session.userId);
if (!user || user.role !== "owner")
throw new Error("Forbidden");
}
const updateStatus = os
.input(z.object({
sessionId: z.string(),
id: z.string(),
status: z.enum(["pending", "confirmed", "cancelled", "completed"])
}))
.handler(async ({ input }) => {
await assertOwner(input.sessionId);
const booking = await kv.getItem(input.id);
if (!booking)
throw new Error("Booking not found");
const previousStatus = booking.status;
const updatedBooking = { ...booking, status: input.status };
await kv.setItem(input.id, updatedBooking);
// Note: Slot state management removed - bookings now validated against recurring rules
// Email notifications on status changes
try {
if (input.status === "confirmed") {
// Create booking access token for this booking (status + cancellation)
const bookingAccessToken = await queryClient.cancellation.createToken({ bookingId: booking.id });
const formattedDate = formatDateGerman(booking.appointmentDate);
const bookingUrl = generateUrl(`/booking/${bookingAccessToken.token}`);
const homepageUrl = generateUrl();
const html = await renderBookingConfirmedHTML({
name: booking.customerName,
date: booking.appointmentDate,
time: booking.appointmentTime,
cancellationUrl: bookingUrl, // Now points to booking status page
reviewUrl: generateUrl(`/review/${bookingAccessToken.token}`)
});
// Get treatment information for ICS file
const allTreatments = await treatmentsKV.getAllItems();
const treatment = allTreatments.find(t => t.id === booking.treatmentId);
const treatmentName = treatment?.name || "Behandlung";
// Use bookedDurationMinutes if available, otherwise fallback to treatment duration
const treatmentDuration = booking.bookedDurationMinutes || treatment?.duration || 60;
if (booking.customerEmail) {
await sendEmailWithAGBAndCalendar({
to: booking.customerEmail,
subject: "Dein Termin wurde bestätigt - AGB im Anhang",
text: `Hallo ${booking.customerName},\n\nwir haben deinen Termin am ${formattedDate} um ${booking.appointmentTime} bestätigt.\n\nWichtiger Hinweis: Die Allgemeinen Geschäftsbedingungen (AGB) findest du im Anhang dieser E-Mail. Bitte lies sie vor deinem Termin durch.\n\nTermin-Status ansehen und verwalten: ${bookingUrl}\nFalls du den Termin stornieren möchtest, kannst du das über den obigen Link tun.\n\nRechtliche Informationen: ${generateUrl('/legal')}\nZur Website: ${homepageUrl}\n\nBis bald!\nStargirlnails Kiel`,
html,
bcc: process.env.ADMIN_EMAIL ? [process.env.ADMIN_EMAIL] : undefined,
}, {
date: booking.appointmentDate,
time: booking.appointmentTime,
durationMinutes: treatmentDuration,
customerName: booking.customerName,
treatmentName: treatmentName
});
}
}
else if (input.status === "cancelled") {
const formattedDate = formatDateGerman(booking.appointmentDate);
const homepageUrl = generateUrl();
const html = await renderBookingCancelledHTML({ name: booking.customerName, date: booking.appointmentDate, time: booking.appointmentTime });
if (booking.customerEmail) {
await sendEmail({
to: booking.customerEmail,
subject: "Dein Termin wurde abgesagt",
text: `Hallo ${booking.customerName},\n\nleider wurde dein Termin am ${formattedDate} um ${booking.appointmentTime} abgesagt. Bitte buche einen neuen Termin.\n\nRechtliche Informationen: ${generateUrl('/legal')}\nZur Website: ${homepageUrl}\n\nLiebe Grüße\nStargirlnails Kiel`,
html,
bcc: process.env.ADMIN_EMAIL ? [process.env.ADMIN_EMAIL] : undefined,
});
}
}
}
catch (e) {
console.error("Email send failed:", e);
}
return updatedBooking;
});
const remove = os
.input(z.object({
sessionId: z.string(),
id: z.string(),
sendEmail: z.boolean().optional().default(false)
}))
.handler(async ({ input }) => {
await assertOwner(input.sessionId);
const booking = await kv.getItem(input.id);
if (!booking)
throw new Error("Booking not found");
// Guard against deletion of past bookings or completed bookings
const today = new Date().toISOString().split("T")[0];
const isPastDate = booking.appointmentDate < today;
const isCompleted = booking.status === 'completed';
if (isPastDate || isCompleted) {
// For past/completed bookings, disable email sending to avoid confusing customers
if (input.sendEmail) {
console.log(`Email sending disabled for past/completed booking ${input.id}`);
}
input.sendEmail = false;
}
const wasAlreadyCancelled = booking.status === 'cancelled';
const updatedBooking = { ...booking, status: "cancelled" };
await kv.setItem(input.id, updatedBooking);
if (input.sendEmail && !wasAlreadyCancelled && booking.customerEmail) {
try {
const formattedDate = formatDateGerman(booking.appointmentDate);
const homepageUrl = generateUrl();
const html = await renderBookingCancelledHTML({ name: booking.customerName, date: booking.appointmentDate, time: booking.appointmentTime });
await sendEmail({
to: booking.customerEmail,
subject: "Dein Termin wurde abgesagt",
text: `Hallo ${booking.customerName},\n\nleider wurde dein Termin am ${formattedDate} um ${booking.appointmentTime} abgesagt. Bitte buche einen neuen Termin.\n\nRechtliche Informationen: ${generateUrl('/legal')}\nZur Website: ${homepageUrl}\n\nLiebe Grüße\nStargirlnails Kiel`,
html,
bcc: process.env.ADMIN_EMAIL ? [process.env.ADMIN_EMAIL] : undefined,
});
}
catch (e) {
console.error("Email send failed:", e);
}
}
return updatedBooking;
});
// Admin-only manual booking creation (immediately confirmed)
const createManual = os
.input(z.object({
sessionId: z.string(),
treatmentId: z.string(),
customerName: z.string().min(2, "Name muss mindestens 2 Zeichen lang sein"),
customerEmail: z.string().email("Ungültige E-Mail-Adresse").optional(),
customerPhone: z.string().min(5, "Telefonnummer muss mindestens 5 Zeichen lang sein").optional(),
appointmentDate: z.string(),
appointmentTime: z.string(),
notes: z.string().optional(),
}))
.handler(async ({ input }) => {
// Admin authentication
await assertOwner(input.sessionId);
// Validate appointment time is on 15-minute grid
const appointmentMinutes = parseTime(input.appointmentTime);
if (appointmentMinutes % 15 !== 0) {
throw new Error("Termine müssen auf 15-Minuten-Raster ausgerichtet sein (z.B. 09:00, 09:15, 09:30, 09:45).");
}
// Validate that the booking is not in the past
const today = new Date().toISOString().split("T")[0];
if (input.appointmentDate < today) {
throw new Error("Buchungen für vergangene Termine sind nicht möglich.");
}
// For today's bookings, check if the time is not in the past
if (input.appointmentDate === today) {
const now = new Date();
const currentTime = `${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}`;
if (input.appointmentTime <= currentTime) {
throw new Error("Buchungen für vergangene Uhrzeiten sind nicht möglich.");
}
}
// Get treatment duration for validation
const treatment = await treatmentsKV.getItem(input.treatmentId);
if (!treatment) {
throw new Error("Behandlung nicht gefunden.");
}
// Validate booking time against recurring rules
await validateBookingAgainstRules(input.appointmentDate, input.appointmentTime, treatment.duration);
// Check for booking conflicts
await checkBookingConflicts(input.appointmentDate, input.appointmentTime, treatment.duration);
const id = randomUUID();
const booking = {
id,
treatmentId: input.treatmentId,
customerName: input.customerName,
customerEmail: input.customerEmail,
customerPhone: input.customerPhone,
appointmentDate: input.appointmentDate,
appointmentTime: input.appointmentTime,
notes: input.notes,
bookedDurationMinutes: treatment.duration,
status: "confirmed",
createdAt: new Date().toISOString()
};
// Save the booking
await kv.setItem(id, booking);
// Create booking access token for status viewing and cancellation (always create token)
const bookingAccessToken = await queryClient.cancellation.createToken({ bookingId: id });
// Send confirmation email if email is provided
if (input.customerEmail) {
void (async () => {
try {
const bookingUrl = generateUrl(`/booking/${bookingAccessToken.token}`);
const formattedDate = formatDateGerman(input.appointmentDate);
const homepageUrl = generateUrl();
const html = await renderBookingConfirmedHTML({
name: input.customerName,
date: input.appointmentDate,
time: input.appointmentTime,
cancellationUrl: bookingUrl,
reviewUrl: generateUrl(`/review/${bookingAccessToken.token}`)
});
await sendEmailWithAGBAndCalendar({
to: input.customerEmail,
subject: "Dein Termin wurde bestätigt - AGB im Anhang",
text: `Hallo ${input.customerName},\n\nwir haben deinen Termin am ${formattedDate} um ${input.appointmentTime} bestätigt.\n\nWichtiger Hinweis: Die Allgemeinen Geschäftsbedingungen (AGB) findest du im Anhang dieser E-Mail. Bitte lies sie vor deinem Termin durch.\n\nTermin-Status ansehen und verwalten: ${bookingUrl}\nFalls du den Termin stornieren möchtest, kannst du das über den obigen Link tun.\n\nRechtliche Informationen: ${generateUrl('/legal')}\nZur Website: ${homepageUrl}\n\nBis bald!\nStargirlnails Kiel`,
html,
}, {
date: input.appointmentDate,
time: input.appointmentTime,
durationMinutes: treatment.duration,
customerName: input.customerName,
treatmentName: treatment.name
});
}
catch (e) {
console.error("Email send failed for manual booking:", e);
}
})();
}
// Optionally return the token in the RPC response for UI to copy/share (admin usage only)
return {
...booking,
bookingAccessToken: bookingAccessToken.token
};
});
const list = os.handler(async () => {
return kv.getAllItems();
});
const get = os.input(z.string()).handler(async ({ input }) => {
return kv.getItem(input);
});
const getByDate = os
.input(z.string()) // YYYY-MM-DD format
.handler(async ({ input }) => {
const allBookings = await kv.getAllItems();
return allBookings.filter(booking => booking.appointmentDate === input);
});
const live = {
list: os.handler(async function* ({ signal }) {
yield call(list, {}, { signal });
for await (const _ of kv.subscribe()) {
yield call(list, {}, { signal });
}
}),
byDate: os
.input(z.string())
.handler(async function* ({ input, signal }) {
yield call(getByDate, input, { signal });
for await (const _ of kv.subscribe()) {
yield call(getByDate, input, { signal });
}
}),
};
export const router = {
create,
createManual,
updateStatus,
remove,
list,
get,
getByDate,
live,
// Admin proposes a reschedule for a confirmed booking
proposeReschedule: os
.input(z.object({
sessionId: z.string(),
bookingId: z.string(),
proposedDate: z.string(),
proposedTime: z.string(),
}))
.handler(async ({ input }) => {
await assertOwner(input.sessionId);
const booking = await kv.getItem(input.bookingId);
if (!booking)
throw new Error("Booking not found");
if (booking.status !== "confirmed")
throw new Error("Nur bestätigte Termine können umgebucht werden.");
const treatment = await treatmentsKV.getItem(booking.treatmentId);
if (!treatment)
throw new Error("Behandlung nicht gefunden.");
// Validate grid and not in past
const appointmentMinutes = parseTime(input.proposedTime);
if (appointmentMinutes % 15 !== 0) {
throw new Error("Termine müssen auf 15-Minuten-Raster ausgerichtet sein (z.B. 09:00, 09:15, 09:30, 09:45).");
}
const today = new Date().toISOString().split("T")[0];
if (input.proposedDate < today) {
throw new Error("Buchungen für vergangene Termine sind nicht möglich.");
}
if (input.proposedDate === today) {
const now = new Date();
const currentTime = `${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}`;
if (input.proposedTime <= currentTime) {
throw new Error("Buchungen für vergangene Uhrzeiten sind nicht möglich.");
}
}
await validateBookingAgainstRules(input.proposedDate, input.proposedTime, booking.bookedDurationMinutes || treatment.duration);
await checkBookingConflicts(input.proposedDate, input.proposedTime, booking.bookedDurationMinutes || treatment.duration, booking.id);
// Invalidate and create new reschedule token via cancellation router
const res = await queryClient.cancellation.createRescheduleToken({
bookingId: booking.id,
proposedDate: input.proposedDate,
proposedTime: input.proposedTime,
});
const acceptUrl = generateUrl(`/booking/${res.token}?action=accept`);
const declineUrl = generateUrl(`/booking/${res.token}?action=decline`);
// Send proposal email to customer
if (booking.customerEmail) {
const html = await renderBookingRescheduleProposalHTML({
name: booking.customerName,
originalDate: booking.appointmentDate,
originalTime: booking.appointmentTime,
proposedDate: input.proposedDate,
proposedTime: input.proposedTime,
treatmentName: (await treatmentsKV.getItem(booking.treatmentId))?.name || "Behandlung",
acceptUrl,
declineUrl,
expiresAt: res.expiresAt,
});
await sendEmail({
to: booking.customerEmail,
subject: "Vorschlag zur Terminänderung",
text: `Hallo ${booking.customerName}, wir schlagen vor, deinen Termin von ${formatDateGerman(booking.appointmentDate)} ${booking.appointmentTime} auf ${formatDateGerman(input.proposedDate)} ${input.proposedTime} zu verschieben. Akzeptieren: ${acceptUrl} | Ablehnen: ${declineUrl}`,
html,
bcc: process.env.ADMIN_EMAIL ? [process.env.ADMIN_EMAIL] : undefined,
}).catch(() => { });
}
return { success: true, token: res.token };
}),
// Customer accepts reschedule via token
acceptReschedule: os
.input(z.object({ token: z.string() }))
.handler(async ({ input }) => {
const proposal = await queryClient.cancellation.getRescheduleProposal({ token: input.token });
const booking = await kv.getItem(proposal.booking.id);
if (!booking)
throw new Error("Booking not found");
if (booking.status !== "confirmed")
throw new Error("Buchung ist nicht mehr in bestätigtem Zustand.");
const treatment = await treatmentsKV.getItem(booking.treatmentId);
const duration = booking.bookedDurationMinutes || treatment?.duration || 60;
// Re-validate slot to ensure still available
await validateBookingAgainstRules(proposal.proposed.date, proposal.proposed.time, duration);
await checkBookingConflicts(proposal.proposed.date, proposal.proposed.time, duration, booking.id);
const updated = { ...booking, appointmentDate: proposal.proposed.date, appointmentTime: proposal.proposed.time };
await kv.setItem(updated.id, updated);
// Remove token
await queryClient.cancellation.removeRescheduleToken({ token: input.token });
// Send confirmation to customer (no BCC to avoid duplicate admin emails)
if (updated.customerEmail) {
const bookingAccessToken = await queryClient.cancellation.createToken({ bookingId: updated.id });
const html = await renderBookingConfirmedHTML({
name: updated.customerName,
date: updated.appointmentDate,
time: updated.appointmentTime,
cancellationUrl: generateUrl(`/booking/${bookingAccessToken.token}`),
reviewUrl: generateUrl(`/review/${bookingAccessToken.token}`),
});
await sendEmailWithAGBAndCalendar({
to: updated.customerEmail,
subject: "Terminänderung bestätigt",
text: `Hallo ${updated.customerName}, dein neuer Termin ist am ${formatDateGerman(updated.appointmentDate)} um ${updated.appointmentTime}.`,
html,
}, {
date: updated.appointmentDate,
time: updated.appointmentTime,
durationMinutes: duration,
customerName: updated.customerName,
treatmentName: (await treatmentsKV.getItem(updated.treatmentId))?.name || "Behandlung",
}).catch(() => { });
}
if (process.env.ADMIN_EMAIL) {
const adminHtml = await renderAdminRescheduleAcceptedHTML({
customerName: updated.customerName,
originalDate: proposal.original.date,
originalTime: proposal.original.time,
newDate: updated.appointmentDate,
newTime: updated.appointmentTime,
treatmentName: (await treatmentsKV.getItem(updated.treatmentId))?.name || "Behandlung",
});
await sendEmail({
to: process.env.ADMIN_EMAIL,
subject: `Reschedule akzeptiert - ${updated.customerName}`,
text: `Reschedule akzeptiert: ${updated.customerName} von ${formatDateGerman(proposal.original.date)} ${proposal.original.time} auf ${formatDateGerman(updated.appointmentDate)} ${updated.appointmentTime}.`,
html: adminHtml,
}).catch(() => { });
}
return { success: true, message: `Termin auf ${formatDateGerman(updated.appointmentDate)} um ${updated.appointmentTime} aktualisiert.` };
}),
// Customer declines reschedule via token
declineReschedule: os
.input(z.object({ token: z.string() }))
.handler(async ({ input }) => {
const proposal = await queryClient.cancellation.getRescheduleProposal({ token: input.token });
const booking = await kv.getItem(proposal.booking.id);
if (!booking)
throw new Error("Booking not found");
// Remove token
await queryClient.cancellation.removeRescheduleToken({ token: input.token });
// Notify customer that original stays
if (booking.customerEmail) {
const bookingAccessToken = await queryClient.cancellation.createToken({ bookingId: booking.id });
await sendEmail({
to: booking.customerEmail,
subject: "Terminänderung abgelehnt",
text: `Du hast den Vorschlag zur Terminänderung abgelehnt. Dein ursprünglicher Termin am ${formatDateGerman(booking.appointmentDate)} um ${booking.appointmentTime} bleibt bestehen.`,
html: await renderBookingConfirmedHTML({ name: booking.customerName, date: booking.appointmentDate, time: booking.appointmentTime, cancellationUrl: generateUrl(`/booking/${bookingAccessToken.token}`), reviewUrl: generateUrl(`/review/${bookingAccessToken.token}`) }),
}).catch(() => { });
}
// Notify admin
if (process.env.ADMIN_EMAIL) {
const html = await renderAdminRescheduleDeclinedHTML({
customerName: booking.customerName,
originalDate: proposal.original.date,
originalTime: proposal.original.time,
proposedDate: proposal.proposed.date,
proposedTime: proposal.proposed.time,
treatmentName: (await treatmentsKV.getItem(booking.treatmentId))?.name || "Behandlung",
customerEmail: booking.customerEmail,
customerPhone: booking.customerPhone,
});
await sendEmail({
to: process.env.ADMIN_EMAIL,
subject: `Reschedule abgelehnt - ${booking.customerName}`,
text: `Abgelehnt: ${booking.customerName}. Ursprünglich: ${formatDateGerman(proposal.original.date)} ${proposal.original.time}. Vorschlag: ${formatDateGerman(proposal.proposed.date)} ${proposal.proposed.time}.`,
html,
}).catch(() => { });
}
return { success: true, message: "Du hast den Vorschlag abgelehnt. Dein ursprünglicher Termin bleibt bestehen." };
}),
// CalDAV Token für Admin generieren
generateCalDAVToken: os
.input(z.object({ sessionId: z.string() }))
.handler(async ({ input }) => {
await assertOwner(input.sessionId);
// Generiere einen sicheren Token für CalDAV-Zugriff
const token = randomUUID();
// Hole Session-Daten für Token-Erstellung
const session = await sessionsKV.getItem(input.sessionId);
if (!session)
throw new Error("Session nicht gefunden");
// Speichere Token mit Ablaufzeit (24 Stunden)
const tokenData = {
id: token,
sessionId: input.sessionId,
userId: session.userId, // Benötigt für Session-Typ
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), // 24 Stunden
createdAt: new Date().toISOString(),
};
// Verwende den sessionsKV Store für Token-Speicherung
await sessionsKV.setItem(token, tokenData);
const domain = process.env.DOMAIN || 'localhost:3000';
const protocol = domain.includes('localhost') ? 'http' : 'https';
const caldavUrl = `${protocol}://${domain}/caldav/calendar/events.ics?token=${token}`;
return {
token,
caldavUrl,
expiresAt: tokenData.expiresAt,
instructions: {
title: "CalDAV-Kalender abonnieren",
steps: [
"Kopiere die CalDAV-URL unten",
"Füge sie in deiner Kalender-App als Abonnement hinzu:",
"- Outlook: Datei → Konto hinzufügen → Internetkalender",
"- Google Calendar: Andere Kalender hinzufügen → Von URL",
"- Apple Calendar: Abonnement → Neue Abonnements",
"- Thunderbird: Kalender hinzufügen → Im Netzwerk",
"Der Kalender wird automatisch aktualisiert"
],
note: "Dieser Token ist 24 Stunden gültig. Bei Bedarf kannst du einen neuen Token generieren."
}
};
}),
};

View File

@@ -0,0 +1,310 @@
import { os } from "@orpc/server";
import { z } from "zod";
import { createKV } from "../lib/create-kv.js";
import { createKV as createAvailabilityKV } from "../lib/create-kv.js";
import { randomUUID } from "crypto";
// Schema for booking access token (used for both status viewing and cancellation)
const BookingAccessTokenSchema = z.object({
id: z.string(),
bookingId: z.string(),
token: z.string(),
expiresAt: z.string(),
createdAt: z.string(),
purpose: z.enum(["booking_access", "reschedule_proposal"]), // Extended for reschedule proposals
// Optional metadata for reschedule proposals
proposedDate: z.string().optional(),
proposedTime: z.string().optional(),
originalDate: z.string().optional(),
originalTime: z.string().optional(),
});
const cancellationKV = createKV("cancellation_tokens");
const bookingsKV = createKV("bookings");
const availabilityKV = createAvailabilityKV("availability");
// Helper function to convert date from yyyy-mm-dd to dd.mm.yyyy
function formatDateGerman(dateString) {
const [year, month, day] = dateString.split('-');
return `${day}.${month}.${year}`;
}
// Helper to invalidate all reschedule tokens for a specific booking
async function invalidateRescheduleTokensForBooking(bookingId) {
const tokens = await cancellationKV.getAllItems();
const related = tokens.filter(t => t.bookingId === bookingId && t.purpose === "reschedule_proposal");
for (const tok of related) {
await cancellationKV.removeItem(tok.id);
}
}
// Create cancellation token for a booking
const createToken = os
.input(z.object({ bookingId: z.string() }))
.handler(async ({ input }) => {
const booking = await bookingsKV.getItem(input.bookingId);
if (!booking) {
throw new Error("Booking not found");
}
if (booking.status === "cancelled") {
throw new Error("Booking is already cancelled");
}
// Create token that expires in 30 days
const expiresAt = new Date();
expiresAt.setDate(expiresAt.getDate() + 30);
const token = randomUUID();
const cancellationToken = {
id: randomUUID(),
bookingId: input.bookingId,
token,
expiresAt: expiresAt.toISOString(),
createdAt: new Date().toISOString(),
purpose: "booking_access",
};
await cancellationKV.setItem(cancellationToken.id, cancellationToken);
return { token, expiresAt: expiresAt.toISOString() };
});
// Get booking details by token
const getBookingByToken = os
.input(z.object({ token: z.string() }))
.handler(async ({ input }) => {
const tokens = await cancellationKV.getAllItems();
const validToken = tokens.find(t => t.token === input.token &&
new Date(t.expiresAt) > new Date() &&
t.purpose === 'booking_access');
if (!validToken) {
throw new Error("Invalid or expired cancellation token");
}
const booking = await bookingsKV.getItem(validToken.bookingId);
if (!booking) {
throw new Error("Booking not found");
}
// Get treatment details
const treatmentsKV = createKV("treatments");
const treatment = await treatmentsKV.getItem(booking.treatmentId);
// Calculate if cancellation is still possible
const minStornoTimespan = parseInt(process.env.MIN_STORNO_TIMESPAN || "24");
const appointmentDateTime = new Date(`${booking.appointmentDate}T${booking.appointmentTime}:00`);
const now = new Date();
const timeDifferenceHours = (appointmentDateTime.getTime() - now.getTime()) / (1000 * 60 * 60);
const canCancel = timeDifferenceHours >= minStornoTimespan &&
booking.status !== "cancelled" &&
booking.status !== "completed";
return {
id: booking.id,
customerName: booking.customerName,
customerEmail: booking.customerEmail,
customerPhone: booking.customerPhone,
appointmentDate: booking.appointmentDate,
appointmentTime: booking.appointmentTime,
treatmentId: booking.treatmentId,
treatmentName: treatment?.name || "Unbekannte Behandlung",
treatmentDuration: treatment?.duration || 60,
treatmentPrice: treatment?.price || 0,
status: booking.status,
notes: booking.notes,
formattedDate: formatDateGerman(booking.appointmentDate),
createdAt: booking.createdAt,
canCancel,
hoursUntilAppointment: Math.max(0, Math.round(timeDifferenceHours)),
};
});
// Cancel booking by token
const cancelByToken = os
.input(z.object({ token: z.string() }))
.handler(async ({ input }) => {
const tokens = await cancellationKV.getAllItems();
const validToken = tokens.find(t => t.token === input.token &&
new Date(t.expiresAt) > new Date());
if (!validToken) {
throw new Error("Invalid or expired cancellation token");
}
const booking = await bookingsKV.getItem(validToken.bookingId);
if (!booking) {
throw new Error("Booking not found");
}
// Check if booking is already cancelled
if (booking.status === "cancelled") {
throw new Error("Booking is already cancelled");
}
// Check minimum cancellation timespan from environment variable
const minStornoTimespan = parseInt(process.env.MIN_STORNO_TIMESPAN || "24"); // Default: 24 hours
const appointmentDateTime = new Date(`${booking.appointmentDate}T${booking.appointmentTime}:00`);
const now = new Date();
const timeDifferenceHours = (appointmentDateTime.getTime() - now.getTime()) / (1000 * 60 * 60);
if (timeDifferenceHours < minStornoTimespan) {
throw new Error(`Stornierung ist nur bis ${minStornoTimespan} Stunden vor dem Termin möglich. Der Termin liegt nur noch ${Math.round(timeDifferenceHours)} Stunden in der Zukunft.`);
}
// Check if booking is in the past (additional safety check)
const today = new Date().toISOString().split("T")[0];
if (booking.appointmentDate < today) {
throw new Error("Cannot cancel past bookings");
}
// For today's bookings, check if the time is not in the past
if (booking.appointmentDate === today) {
const currentTime = `${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}`;
if (booking.appointmentTime <= currentTime) {
throw new Error("Cannot cancel bookings that have already started");
}
}
// Update booking status
const updatedBooking = { ...booking, status: "cancelled" };
await bookingsKV.setItem(booking.id, updatedBooking);
// Free the slot if it exists
if (booking.slotId) {
const slot = await availabilityKV.getItem(booking.slotId);
if (slot) {
const updatedSlot = {
...slot,
status: "free",
reservedByBookingId: undefined,
};
await availabilityKV.setItem(slot.id, updatedSlot);
}
}
// Invalidate the token
await cancellationKV.removeItem(validToken.id);
return {
success: true,
message: "Booking cancelled successfully",
formattedDate: formatDateGerman(booking.appointmentDate),
};
});
export const router = {
createToken,
getBookingByToken,
cancelByToken,
// Create a reschedule proposal token (48h expiry)
createRescheduleToken: os
.input(z.object({ bookingId: z.string(), proposedDate: z.string(), proposedTime: z.string() }))
.handler(async ({ input }) => {
const booking = await bookingsKV.getItem(input.bookingId);
if (!booking) {
throw new Error("Booking not found");
}
if (booking.status === "cancelled" || booking.status === "completed") {
throw new Error("Reschedule not allowed for this booking");
}
// Invalidate existing reschedule proposals for this booking
await invalidateRescheduleTokensForBooking(input.bookingId);
// Create token that expires in 48 hours
const expiresAt = new Date();
expiresAt.setHours(expiresAt.getHours() + 48);
const token = randomUUID();
const rescheduleToken = {
id: randomUUID(),
bookingId: input.bookingId,
token,
expiresAt: expiresAt.toISOString(),
createdAt: new Date().toISOString(),
purpose: "reschedule_proposal",
proposedDate: input.proposedDate,
proposedTime: input.proposedTime,
originalDate: booking.appointmentDate,
originalTime: booking.appointmentTime,
};
await cancellationKV.setItem(rescheduleToken.id, rescheduleToken);
return { token, expiresAt: expiresAt.toISOString() };
}),
// Get reschedule proposal details by token
getRescheduleProposal: os
.input(z.object({ token: z.string() }))
.handler(async ({ input }) => {
const tokens = await cancellationKV.getAllItems();
const proposal = tokens.find(t => t.token === input.token && t.purpose === "reschedule_proposal");
if (!proposal) {
throw new Error("Ungültiger Reschedule-Token");
}
const booking = await bookingsKV.getItem(proposal.bookingId);
if (!booking) {
throw new Error("Booking not found");
}
const treatmentsKV = createKV("treatments");
const treatment = await treatmentsKV.getItem(booking.treatmentId);
const now = new Date();
const isExpired = new Date(proposal.expiresAt) <= now;
const hoursUntilExpiry = isExpired ? 0 : Math.max(0, Math.round((new Date(proposal.expiresAt).getTime() - now.getTime()) / (1000 * 60 * 60)));
return {
booking: {
id: booking.id,
customerName: booking.customerName,
customerEmail: booking.customerEmail,
customerPhone: booking.customerPhone,
status: booking.status,
treatmentId: booking.treatmentId,
treatmentName: treatment?.name || "Unbekannte Behandlung",
},
original: {
date: proposal.originalDate || booking.appointmentDate,
time: proposal.originalTime || booking.appointmentTime,
},
proposed: {
date: proposal.proposedDate,
time: proposal.proposedTime,
},
expiresAt: proposal.expiresAt,
hoursUntilExpiry,
isExpired,
canRespond: booking.status === "confirmed" && !isExpired,
};
}),
// Helper endpoint to remove a reschedule token by value (used after accept/decline)
removeRescheduleToken: os
.input(z.object({ token: z.string() }))
.handler(async ({ input }) => {
const tokens = await cancellationKV.getAllItems();
const proposal = tokens.find(t => t.token === input.token && t.purpose === "reschedule_proposal");
if (proposal) {
await cancellationKV.removeItem(proposal.id);
}
return { success: true };
}),
// Clean up expired reschedule proposals and notify admin
sweepExpiredRescheduleProposals: os
.handler(async () => {
const tokens = await cancellationKV.getAllItems();
const now = new Date();
const expiredProposals = tokens.filter(t => t.purpose === "reschedule_proposal" &&
new Date(t.expiresAt) <= now);
if (expiredProposals.length === 0) {
return { success: true, expiredCount: 0 };
}
// Get booking details for each expired proposal
const expiredDetails = [];
for (const proposal of expiredProposals) {
const booking = await bookingsKV.getItem(proposal.bookingId);
if (booking) {
const treatmentsKV = createKV("treatments");
const treatment = await treatmentsKV.getItem(booking.treatmentId);
expiredDetails.push({
customerName: booking.customerName,
originalDate: proposal.originalDate || booking.appointmentDate,
originalTime: proposal.originalTime || booking.appointmentTime,
proposedDate: proposal.proposedDate,
proposedTime: proposal.proposedTime,
treatmentName: treatment?.name || "Unbekannte Behandlung",
customerEmail: booking.customerEmail,
customerPhone: booking.customerPhone,
expiredAt: proposal.expiresAt,
});
}
// Remove the expired token
await cancellationKV.removeItem(proposal.id);
}
// Notify admin if there are expired proposals
if (expiredDetails.length > 0 && process.env.ADMIN_EMAIL) {
try {
const { renderAdminRescheduleExpiredHTML } = await import("../lib/email-templates.js");
const { sendEmail } = await import("../lib/email.js");
const html = await renderAdminRescheduleExpiredHTML({
expiredProposals: expiredDetails,
});
await sendEmail({
to: process.env.ADMIN_EMAIL,
subject: `${expiredDetails.length} abgelaufene Terminänderungsvorschläge`,
text: `Es sind ${expiredDetails.length} Terminänderungsvorschläge abgelaufen. Details in der HTML-Version.`,
html,
});
}
catch (error) {
console.error("Failed to send admin notification for expired proposals:", error);
}
}
return { success: true, expiredCount: expiredDetails.length };
}),
};

View File

@@ -0,0 +1,79 @@
import OpenAI from "openai";
import { os } from "@orpc/server";
import { z } from "zod";
import { zodResponseFormat } from "../../lib/openai";
if (!process.env.OPENAI_BASE_URL) {
throw new Error("OPENAI_BASE_URL is not set");
}
if (!process.env.OPENAI_API_KEY) {
throw new Error("OPENAI_API_KEY is not set");
}
const openai = new OpenAI({
baseURL: process.env.OPENAI_BASE_URL,
apiKey: process.env.OPENAI_API_KEY,
});
if (!process.env.OPENAI_DEFAULT_MODEL) {
throw new Error("OPENAI_DEFAULT_MODEL is not set");
}
const DEFAULT_MODEL = process.env.OPENAI_DEFAULT_MODEL;
const ChatCompletionInputSchema = z.object({
message: z.string(),
systemPrompt: z.string().optional(),
});
const GeneratePersonInputSchema = z.object({
prompt: z.string(),
});
const complete = os
.input(ChatCompletionInputSchema)
.handler(async ({ input }) => {
const { message, systemPrompt } = input;
const completion = await openai.chat.completions.create({
model: DEFAULT_MODEL,
messages: [
...(systemPrompt
? [{ role: "system", content: systemPrompt }]
: []),
{ role: "user", content: message },
],
});
return {
response: completion.choices[0]?.message?.content || "",
};
});
// Object generation schemas only support nullability, not optionality.
// Use .nullable() instead of .optional() for fields that may not have values.
const DemoSchema = z.object({
name: z.string().describe("The name of the person"),
age: z.number().describe("The age of the person"),
occupation: z.string().describe("The occupation of the person"),
bio: z.string().describe("The bio of the person"),
nickname: z
.string()
.nullable()
.describe("The person's nickname, if they have one"),
});
const generate = os
.input(GeneratePersonInputSchema)
.handler(async ({ input }) => {
const completion = await openai.chat.completions.parse({
model: DEFAULT_MODEL,
messages: [
{
role: "user",
content: `Generate a person based on this prompt: ${input.prompt}`,
},
],
response_format: zodResponseFormat(DemoSchema, "person"),
});
const person = completion.choices[0]?.message?.parsed;
if (!person) {
throw new Error("No parsed data received from OpenAI");
}
return {
person,
};
});
export const router = {
complete,
generate,
};

View File

@@ -0,0 +1,4 @@
import { router as storageRouter } from "./storage.js";
export const demo = {
storage: storageRouter,
};

View File

@@ -0,0 +1,42 @@
import { call, os } from "@orpc/server";
import { z } from "zod";
import { randomUUID } from "crypto";
import { createKV } from "../../lib/create-kv.js";
const DemoSchema = z.object({
id: z.string(),
value: z.string(),
});
// createKV provides simple key-value storage with publisher/subscriber support
// perfect for live queries and small amounts of data
const kv = createKV("demo");
// Handler with input validation using .input() and schema
const create = os
.input(DemoSchema.omit({ id: true }))
.handler(async ({ input }) => {
const id = randomUUID();
const item = { id, value: input.value };
await kv.setItem(id, item);
});
const remove = os.input(z.string()).handler(async ({ input }) => {
await kv.removeItem(input);
});
// Handler without input - returns all items
const list = os.handler(async () => {
return kv.getAllItems();
});
// Live data stream using generator function
// Yields initial data, then subscribes to changes for real-time updates
const live = {
list: os.handler(async function* ({ signal }) {
yield call(list, {}, { signal });
for await (const _ of kv.subscribe()) {
yield call(list, {}, { signal });
}
}),
};
export const router = {
create,
remove,
list,
live,
};

131
server-dist/rpc/gallery.js Normal file
View File

@@ -0,0 +1,131 @@
import { call, os } from "@orpc/server";
import { z } from "zod";
import { randomUUID } from "crypto";
import { createKV } from "../lib/create-kv.js";
import { assertOwner } from "../lib/auth.js";
// Schema Definition
const GalleryPhotoSchema = z.object({
id: z.string(),
base64Data: z.string(),
title: z.string().optional().default(""),
order: z.number().int(),
createdAt: z.string(),
cover: z.boolean().optional().default(false),
});
// KV Storage
const galleryPhotosKV = createKV("galleryPhotos");
// Authentication centralized in ../lib/auth.ts
// CRUD Endpoints
const uploadPhoto = os
.input(z.object({
sessionId: z.string(),
base64Data: z
.string()
.regex(/^data:image\/(png|jpe?g|webp|gif);base64,/i, 'Unsupported image format'),
title: z.string().optional().default(""),
}))
.handler(async ({ input }) => {
try {
await assertOwner(input.sessionId);
const id = randomUUID();
const existing = await galleryPhotosKV.getAllItems();
const maxOrder = existing.length > 0 ? Math.max(...existing.map((p) => p.order)) : -1;
const nextOrder = maxOrder + 1;
const photo = {
id,
base64Data: input.base64Data,
title: input.title ?? "",
order: nextOrder,
createdAt: new Date().toISOString(),
cover: false,
};
await galleryPhotosKV.setItem(id, photo);
return photo;
}
catch (err) {
console.error("gallery.uploadPhoto error", err);
throw err;
}
});
const setCoverPhoto = os
.input(z.object({ sessionId: z.string(), id: z.string() }))
.handler(async ({ input }) => {
await assertOwner(input.sessionId);
const all = await galleryPhotosKV.getAllItems();
let updatedCover = null;
for (const p of all) {
const isCover = p.id === input.id;
const next = { ...p, cover: isCover };
await galleryPhotosKV.setItem(p.id, next);
if (isCover)
updatedCover = next;
}
return updatedCover;
});
const deletePhoto = os
.input(z.object({ sessionId: z.string(), id: z.string() }))
.handler(async ({ input }) => {
await assertOwner(input.sessionId);
await galleryPhotosKV.removeItem(input.id);
});
const updatePhotoOrder = os
.input(z.object({
sessionId: z.string(),
photoOrders: z.array(z.object({ id: z.string(), order: z.number().int() })),
}))
.handler(async ({ input }) => {
await assertOwner(input.sessionId);
const updated = [];
for (const { id, order } of input.photoOrders) {
const existing = await galleryPhotosKV.getItem(id);
if (!existing)
continue;
const updatedPhoto = { ...existing, order };
await galleryPhotosKV.setItem(id, updatedPhoto);
updated.push(updatedPhoto);
}
const all = await galleryPhotosKV.getAllItems();
return all.sort((a, b) => (a.order - b.order) || a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id));
});
const listPhotos = os.handler(async () => {
const all = await galleryPhotosKV.getAllItems();
return all.sort((a, b) => (a.order - b.order) || a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id));
});
const adminListPhotos = os
.input(z.object({ sessionId: z.string() }))
.handler(async ({ input }) => {
await assertOwner(input.sessionId);
const all = await galleryPhotosKV.getAllItems();
return all.sort((a, b) => (a.order - b.order) || a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id));
});
// Live Queries
const live = {
listPhotos: os.handler(async function* ({ signal }) {
yield call(listPhotos, {}, { signal });
for await (const _ of galleryPhotosKV.subscribe()) {
yield call(listPhotos, {}, { signal });
}
}),
adminListPhotos: os
.input(z.object({ sessionId: z.string() }))
.handler(async function* ({ input, signal }) {
await assertOwner(input.sessionId);
const all = await galleryPhotosKV.getAllItems();
const sorted = all.sort((a, b) => (a.order - b.order) || a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id));
yield sorted;
for await (const _ of galleryPhotosKV.subscribe()) {
const updated = await galleryPhotosKV.getAllItems();
const sortedUpdated = updated.sort((a, b) => (a.order - b.order) || a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id));
yield sortedUpdated;
}
}),
};
export const router = {
uploadPhoto,
deletePhoto,
updatePhotoOrder,
listPhotos,
adminListPhotos,
setCoverPhoto,
live,
};

20
server-dist/rpc/index.js Normal file
View File

@@ -0,0 +1,20 @@
import { demo } from "./demo/index.js";
import { router as treatments } from "./treatments.js";
import { router as bookings } from "./bookings.js";
import { router as auth } from "./auth.js";
import { router as recurringAvailability } from "./recurring-availability.js";
import { router as cancellation } from "./cancellation.js";
import { router as legal } from "./legal.js";
import { router as gallery } from "./gallery.js";
import { router as reviews } from "./reviews.js";
export const router = {
demo,
treatments,
bookings,
auth,
recurringAvailability,
cancellation,
legal,
gallery,
reviews,
};

16
server-dist/rpc/legal.js Normal file
View File

@@ -0,0 +1,16 @@
import { os } from "@orpc/server";
import { getLegalConfig } from "../lib/legal-config.js";
export const router = {
getConfig: os.handler(async () => {
console.log("Legal getConfig called");
try {
const config = getLegalConfig();
console.log("Legal config:", config);
return config;
}
catch (error) {
console.error("Legal config error:", error);
throw error;
}
}),
};

View File

@@ -0,0 +1,396 @@
import { call, os } from "@orpc/server";
import { z } from "zod";
import { randomUUID } from "crypto";
import { createKV } from "../lib/create-kv.js";
import { assertOwner } from "../lib/auth.js";
// Datenmodelle
const RecurringRuleSchema = z.object({
id: z.string(),
dayOfWeek: z.number().int().min(0).max(6), // 0=Sonntag, 1=Montag, ..., 6=Samstag
startTime: z.string().regex(/^\d{2}:\d{2}$/), // HH:MM Format
endTime: z.string().regex(/^\d{2}:\d{2}$/), // HH:MM Format
isActive: z.boolean(),
createdAt: z.string(),
// LEGACY: slotDurationMinutes - deprecated field for generateSlots only, will be removed
slotDurationMinutes: z.number().int().min(1).optional(),
});
const TimeOffPeriodSchema = z.object({
id: z.string(),
startDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), // YYYY-MM-DD
endDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), // YYYY-MM-DD
reason: z.string(),
createdAt: z.string(),
});
// KV-Stores
const recurringRulesKV = createKV("recurringRules");
const timeOffPeriodsKV = createKV("timeOffPeriods");
// Import bookings and treatments KV stores for getAvailableTimes endpoint
const bookingsKV = createKV("bookings");
const treatmentsKV = createKV("treatments");
// Owner-Authentifizierung zentralisiert in ../lib/auth.ts
// Helper-Funktionen
function parseTime(timeStr) {
const [hours, minutes] = timeStr.split(':').map(Number);
return hours * 60 + minutes; // Minuten seit Mitternacht
}
function formatTime(minutes) {
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
return `${String(hours).padStart(2, '0')}:${String(mins).padStart(2, '0')}`;
}
function addDays(date, days) {
const result = new Date(date);
result.setDate(result.getDate() + days);
return result;
}
function formatDate(date) {
return date.toISOString().split('T')[0];
}
function isDateInTimeOffPeriod(date, periods) {
return periods.some(period => date >= period.startDate && date <= period.endDate);
}
// Helper-Funktion zur Erkennung überlappender Regeln
function detectOverlappingRules(newRule, existingRules) {
const newStart = parseTime(newRule.startTime);
const newEnd = parseTime(newRule.endTime);
return existingRules.filter(rule => {
// Gleicher Wochentag und nicht dieselbe Regel (bei Updates)
if (rule.dayOfWeek !== newRule.dayOfWeek || rule.id === newRule.id) {
return false;
}
const existingStart = parseTime(rule.startTime);
const existingEnd = parseTime(rule.endTime);
// Überlappung wenn: neue Startzeit < bestehende Endzeit UND neue Endzeit > bestehende Startzeit
return newStart < existingEnd && newEnd > existingStart;
});
}
// CRUD-Endpoints für Recurring Rules
const createRule = os
.input(z.object({
sessionId: z.string(),
dayOfWeek: z.number().int().min(0).max(6),
startTime: z.string().regex(/^\d{2}:\d{2}$/),
endTime: z.string().regex(/^\d{2}:\d{2}$/),
}).passthrough())
.handler(async ({ input }) => {
try {
await assertOwner(input.sessionId);
// Validierung: startTime < endTime
const startMinutes = parseTime(input.startTime);
const endMinutes = parseTime(input.endTime);
if (startMinutes >= endMinutes) {
throw new Error("Startzeit muss vor der Endzeit liegen.");
}
// Überlappungsprüfung
const allRules = await recurringRulesKV.getAllItems();
const overlappingRules = detectOverlappingRules(input, allRules);
if (overlappingRules.length > 0) {
const overlappingTimes = overlappingRules.map(rule => `${rule.startTime}-${rule.endTime}`).join(", ");
throw new Error(`Überlappung mit bestehenden Regeln erkannt: ${overlappingTimes}. Bitte Zeitfenster anpassen.`);
}
const id = randomUUID();
const rule = {
id,
dayOfWeek: input.dayOfWeek,
startTime: input.startTime,
endTime: input.endTime,
isActive: true,
createdAt: new Date().toISOString(),
};
await recurringRulesKV.setItem(id, rule);
return rule;
}
catch (err) {
console.error("recurring-availability.createRule error", err);
throw err;
}
});
const updateRule = os
.input(RecurringRuleSchema.extend({ sessionId: z.string() }).passthrough())
.handler(async ({ input }) => {
await assertOwner(input.sessionId);
// Validierung: startTime < endTime
const startMinutes = parseTime(input.startTime);
const endMinutes = parseTime(input.endTime);
if (startMinutes >= endMinutes) {
throw new Error("Startzeit muss vor der Endzeit liegen.");
}
// Überlappungsprüfung
const allRules = await recurringRulesKV.getAllItems();
const overlappingRules = detectOverlappingRules(input, allRules);
if (overlappingRules.length > 0) {
const overlappingTimes = overlappingRules.map(rule => `${rule.startTime}-${rule.endTime}`).join(", ");
throw new Error(`Überlappung mit bestehenden Regeln erkannt: ${overlappingTimes}. Bitte Zeitfenster anpassen.`);
}
const { sessionId, ...rule } = input;
await recurringRulesKV.setItem(rule.id, rule);
return rule;
});
const deleteRule = os
.input(z.object({ sessionId: z.string(), id: z.string() }))
.handler(async ({ input }) => {
await assertOwner(input.sessionId);
await recurringRulesKV.removeItem(input.id);
});
const toggleRuleActive = os
.input(z.object({ sessionId: z.string(), id: z.string() }))
.handler(async ({ input }) => {
await assertOwner(input.sessionId);
const rule = await recurringRulesKV.getItem(input.id);
if (!rule)
throw new Error("Regel nicht gefunden.");
rule.isActive = !rule.isActive;
await recurringRulesKV.setItem(input.id, rule);
return rule;
});
const listRules = os.handler(async () => {
const allRules = await recurringRulesKV.getAllItems();
return allRules.sort((a, b) => {
if (a.dayOfWeek !== b.dayOfWeek)
return a.dayOfWeek - b.dayOfWeek;
return a.startTime.localeCompare(b.startTime);
});
});
const adminListRules = os
.input(z.object({ sessionId: z.string() }))
.handler(async ({ input }) => {
await assertOwner(input.sessionId);
const allRules = await recurringRulesKV.getAllItems();
return allRules.sort((a, b) => {
if (a.dayOfWeek !== b.dayOfWeek)
return a.dayOfWeek - b.dayOfWeek;
return a.startTime.localeCompare(b.startTime);
});
});
// CRUD-Endpoints für Time-Off Periods
const createTimeOff = os
.input(z.object({
sessionId: z.string(),
startDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
endDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
reason: z.string(),
}))
.handler(async ({ input }) => {
try {
await assertOwner(input.sessionId);
// Validierung: startDate <= endDate
if (input.startDate > input.endDate) {
throw new Error("Startdatum muss vor oder am Enddatum liegen.");
}
const id = randomUUID();
const timeOff = {
id,
startDate: input.startDate,
endDate: input.endDate,
reason: input.reason,
createdAt: new Date().toISOString(),
};
await timeOffPeriodsKV.setItem(id, timeOff);
return timeOff;
}
catch (err) {
console.error("recurring-availability.createTimeOff error", err);
throw err;
}
});
const updateTimeOff = os
.input(TimeOffPeriodSchema.extend({ sessionId: z.string() }).passthrough())
.handler(async ({ input }) => {
await assertOwner(input.sessionId);
// Validierung: startDate <= endDate
if (input.startDate > input.endDate) {
throw new Error("Startdatum muss vor oder am Enddatum liegen.");
}
const { sessionId, ...timeOff } = input;
await timeOffPeriodsKV.setItem(timeOff.id, timeOff);
return timeOff;
});
const deleteTimeOff = os
.input(z.object({ sessionId: z.string(), id: z.string() }))
.handler(async ({ input }) => {
await assertOwner(input.sessionId);
await timeOffPeriodsKV.removeItem(input.id);
});
const listTimeOff = os.handler(async () => {
const allTimeOff = await timeOffPeriodsKV.getAllItems();
return allTimeOff.sort((a, b) => a.startDate.localeCompare(b.startDate));
});
const adminListTimeOff = os
.input(z.object({ sessionId: z.string() }))
.handler(async ({ input }) => {
await assertOwner(input.sessionId);
const allTimeOff = await timeOffPeriodsKV.getAllItems();
return allTimeOff.sort((a, b) => a.startDate.localeCompare(b.startDate));
});
// Get Available Times Endpoint
const getAvailableTimes = os
.input(z.object({
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
treatmentId: z.string(),
}))
.handler(async ({ input }) => {
try {
// Validate that the date is not in the past
const today = new Date();
const inputDate = new Date(input.date);
today.setHours(0, 0, 0, 0);
inputDate.setHours(0, 0, 0, 0);
if (inputDate < today) {
return [];
}
// Get treatment duration
const treatment = await treatmentsKV.getItem(input.treatmentId);
if (!treatment) {
throw new Error("Behandlung nicht gefunden.");
}
const treatmentDuration = treatment.duration;
// Parse the date to get day of week
const [year, month, day] = input.date.split('-').map(Number);
const localDate = new Date(year, month - 1, day);
const dayOfWeek = localDate.getDay(); // 0=Sonntag, 1=Montag, ...
// Find matching recurring rules
const allRules = await recurringRulesKV.getAllItems();
const matchingRules = allRules.filter(rule => rule.isActive === true && rule.dayOfWeek === dayOfWeek);
if (matchingRules.length === 0) {
return []; // No rules for this day of week
}
// Check time-off periods
const timeOffPeriods = await timeOffPeriodsKV.getAllItems();
if (isDateInTimeOffPeriod(input.date, timeOffPeriods)) {
return []; // Date is blocked by time-off period
}
// Generate 15-minute intervals with boundary alignment
const availableTimes = [];
// Helper functions for 15-minute boundary alignment
const ceilTo15 = (m) => m % 15 === 0 ? m : m + (15 - (m % 15));
const floorTo15 = (m) => m - (m % 15);
for (const rule of matchingRules) {
const startMinutes = parseTime(rule.startTime);
const endMinutes = parseTime(rule.endTime);
let currentMinutes = ceilTo15(startMinutes);
const endBound = floorTo15(endMinutes);
while (currentMinutes + treatmentDuration <= endBound) {
const timeStr = formatTime(currentMinutes);
availableTimes.push(timeStr);
currentMinutes += 15; // 15-minute intervals
}
}
// Get all bookings for this date and their treatments
const allBookings = await bookingsKV.getAllItems();
const dateBookings = allBookings.filter(booking => booking.appointmentDate === input.date &&
['pending', 'confirmed', 'completed'].includes(booking.status));
// Optimize treatment duration lookup with Map caching
const uniqueTreatmentIds = [...new Set(dateBookings.map(booking => booking.treatmentId))];
const treatmentDurationMap = new Map();
for (const treatmentId of uniqueTreatmentIds) {
const treatment = await treatmentsKV.getItem(treatmentId);
treatmentDurationMap.set(treatmentId, treatment?.duration || 60);
}
// Get treatment durations for all bookings using the cached map
const bookingTreatments = new Map();
for (const booking of dateBookings) {
// Use bookedDurationMinutes if available, otherwise fallback to treatment duration
const duration = booking.bookedDurationMinutes || treatmentDurationMap.get(booking.treatmentId) || 60;
bookingTreatments.set(booking.id, duration);
}
// Filter out booking conflicts
const availableTimesFiltered = availableTimes.filter(slotTime => {
const slotStartMinutes = parseTime(slotTime);
const slotEndMinutes = slotStartMinutes + treatmentDuration;
// Check if this slot overlaps with any existing booking
const hasConflict = dateBookings.some(booking => {
const bookingStartMinutes = parseTime(booking.appointmentTime);
const bookingDuration = bookingTreatments.get(booking.id) || 60;
const bookingEndMinutes = bookingStartMinutes + bookingDuration;
// Check overlap: slotStart < bookingEnd && slotEnd > bookingStart
return slotStartMinutes < bookingEndMinutes && slotEndMinutes > bookingStartMinutes;
});
return !hasConflict;
});
// Filter out past times for today
const now = new Date();
const isToday = inputDate.getTime() === today.getTime();
const finalAvailableTimes = isToday
? availableTimesFiltered.filter(timeStr => {
const slotTime = parseTime(timeStr);
const currentTime = now.getHours() * 60 + now.getMinutes();
return slotTime > currentTime;
})
: availableTimesFiltered;
// Deduplicate and sort chronologically
const unique = Array.from(new Set(finalAvailableTimes));
return unique.sort((a, b) => a.localeCompare(b));
}
catch (err) {
console.error("recurring-availability.getAvailableTimes error", err);
throw err;
}
});
// Live-Queries
const live = {
listRules: os.handler(async function* ({ signal }) {
yield call(listRules, {}, { signal });
for await (const _ of recurringRulesKV.subscribe()) {
yield call(listRules, {}, { signal });
}
}),
listTimeOff: os.handler(async function* ({ signal }) {
yield call(listTimeOff, {}, { signal });
for await (const _ of timeOffPeriodsKV.subscribe()) {
yield call(listTimeOff, {}, { signal });
}
}),
adminListRules: os
.input(z.object({ sessionId: z.string() }))
.handler(async function* ({ input, signal }) {
await assertOwner(input.sessionId);
const allRules = await recurringRulesKV.getAllItems();
const sortedRules = allRules.sort((a, b) => {
if (a.dayOfWeek !== b.dayOfWeek)
return a.dayOfWeek - b.dayOfWeek;
return a.startTime.localeCompare(b.startTime);
});
yield sortedRules;
for await (const _ of recurringRulesKV.subscribe()) {
const updatedRules = await recurringRulesKV.getAllItems();
const sortedUpdatedRules = updatedRules.sort((a, b) => {
if (a.dayOfWeek !== b.dayOfWeek)
return a.dayOfWeek - b.dayOfWeek;
return a.startTime.localeCompare(b.startTime);
});
yield sortedUpdatedRules;
}
}),
adminListTimeOff: os
.input(z.object({ sessionId: z.string() }))
.handler(async function* ({ input, signal }) {
await assertOwner(input.sessionId);
const allTimeOff = await timeOffPeriodsKV.getAllItems();
const sortedTimeOff = allTimeOff.sort((a, b) => a.startDate.localeCompare(b.startDate));
yield sortedTimeOff;
for await (const _ of timeOffPeriodsKV.subscribe()) {
const updatedTimeOff = await timeOffPeriodsKV.getAllItems();
const sortedUpdatedTimeOff = updatedTimeOff.sort((a, b) => a.startDate.localeCompare(b.startDate));
yield sortedUpdatedTimeOff;
}
}),
};
export const router = {
// Recurring Rules
createRule,
updateRule,
deleteRule,
toggleRuleActive,
listRules,
adminListRules,
// Time-Off Periods
createTimeOff,
updateTimeOff,
deleteTimeOff,
listTimeOff,
adminListTimeOff,
// Availability
getAvailableTimes,
// Live queries
live,
};

220
server-dist/rpc/reviews.js Normal file
View File

@@ -0,0 +1,220 @@
import { call, os } from "@orpc/server";
import { z } from "zod";
import { createKV } from "../lib/create-kv.js";
import { assertOwner, sessionsKV } from "../lib/auth.js";
// Schema Definition
const ReviewSchema = z.object({
id: z.string(),
bookingId: z.string(),
customerName: z.string().min(2, "Kundenname muss mindestens 2 Zeichen lang sein"),
customerEmail: z.string().email("Ungültige E-Mail-Adresse").optional(),
rating: z.number().int().min(1).max(5),
comment: z.string().min(10, "Kommentar muss mindestens 10 Zeichen lang sein"),
status: z.enum(["pending", "approved", "rejected"]),
createdAt: z.string(),
reviewedAt: z.string().optional(),
reviewedBy: z.string().optional(),
});
// KV Storage
const reviewsKV = createKV("reviews");
const cancellationKV = createKV("cancellation_tokens");
const bookingsKV = createKV("bookings");
// Helper Function: validateBookingToken
async function validateBookingToken(token) {
const tokens = await cancellationKV.getAllItems();
const validToken = tokens.find(t => t.token === token &&
new Date(t.expiresAt) > new Date() &&
t.purpose === 'booking_access');
if (!validToken) {
throw new Error("Ungültiger oder abgelaufener Buchungs-Token");
}
const booking = await bookingsKV.getItem(validToken.bookingId);
if (!booking) {
throw new Error("Buchung nicht gefunden");
}
// Only allow reviews for completed appointments
if (!(booking.status === "completed")) {
throw new Error("Bewertungen sind nur für abgeschlossene Termine möglich");
}
return booking;
}
// Public Endpoint: submitReview
const submitReview = os
.input(z.object({
bookingToken: z.string(),
rating: z.number().int().min(1).max(5),
comment: z.string().min(10, "Kommentar muss mindestens 10 Zeichen lang sein"),
}))
.handler(async ({ input }) => {
try {
// Validate bookingToken
const booking = await validateBookingToken(input.bookingToken);
// Enforce uniqueness by using booking.id as the KV key
const existing = await reviewsKV.getItem(booking.id);
if (existing) {
throw new Error("Für diese Buchung wurde bereits eine Bewertung abgegeben");
}
// Create review object
const review = {
id: booking.id,
bookingId: booking.id,
customerName: booking.customerName,
customerEmail: booking.customerEmail,
rating: input.rating,
comment: input.comment,
status: "pending",
createdAt: new Date().toISOString(),
};
await reviewsKV.setItem(booking.id, review);
return review;
}
catch (err) {
console.error("reviews.submitReview error", err);
throw err;
}
});
// Admin Endpoint: approveReview
const approveReview = os
.input(z.object({ sessionId: z.string(), id: z.string() }))
.handler(async ({ input }) => {
try {
await assertOwner(input.sessionId);
const review = await reviewsKV.getItem(input.id);
if (!review) {
throw new Error("Bewertung nicht gefunden");
}
const session = await sessionsKV.getItem(input.sessionId).catch(() => undefined);
const updatedReview = {
...review,
status: "approved",
reviewedAt: new Date().toISOString(),
reviewedBy: session?.userId || review.reviewedBy,
};
await reviewsKV.setItem(input.id, updatedReview);
return updatedReview;
}
catch (err) {
console.error("reviews.approveReview error", err);
throw err;
}
});
// Admin Endpoint: rejectReview
const rejectReview = os
.input(z.object({ sessionId: z.string(), id: z.string() }))
.handler(async ({ input }) => {
try {
await assertOwner(input.sessionId);
const review = await reviewsKV.getItem(input.id);
if (!review) {
throw new Error("Bewertung nicht gefunden");
}
const session = await sessionsKV.getItem(input.sessionId).catch(() => undefined);
const updatedReview = {
...review,
status: "rejected",
reviewedAt: new Date().toISOString(),
reviewedBy: session?.userId || review.reviewedBy,
};
await reviewsKV.setItem(input.id, updatedReview);
return updatedReview;
}
catch (err) {
console.error("reviews.rejectReview error", err);
throw err;
}
});
// Admin Endpoint: deleteReview
const deleteReview = os
.input(z.object({ sessionId: z.string(), id: z.string() }))
.handler(async ({ input }) => {
try {
await assertOwner(input.sessionId);
await reviewsKV.removeItem(input.id);
}
catch (err) {
console.error("reviews.deleteReview error", err);
throw err;
}
});
// Public Endpoint: listPublishedReviews
const listPublishedReviews = os.handler(async () => {
try {
const allReviews = await reviewsKV.getAllItems();
const published = allReviews.filter(r => r.status === "approved");
const sorted = published.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
const publicSafe = sorted.map(r => ({
customerName: r.customerName,
rating: r.rating,
comment: r.comment,
status: r.status,
bookingId: r.bookingId,
createdAt: r.createdAt,
}));
return publicSafe;
}
catch (err) {
console.error("reviews.listPublishedReviews error", err);
throw err;
}
});
// Admin Endpoint: adminListReviews
const adminListReviews = os
.input(z.object({
sessionId: z.string(),
statusFilter: z.enum(["all", "pending", "approved", "rejected"]).optional().default("all"),
}))
.handler(async ({ input }) => {
try {
await assertOwner(input.sessionId);
const allReviews = await reviewsKV.getAllItems();
const filtered = input.statusFilter === "all"
? allReviews
: allReviews.filter(r => r.status === input.statusFilter);
const sorted = filtered.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
return sorted;
}
catch (err) {
console.error("reviews.adminListReviews error", err);
throw err;
}
});
// Live Queries
const live = {
listPublishedReviews: os.handler(async function* ({ signal }) {
yield call(listPublishedReviews, {}, { signal });
for await (const _ of reviewsKV.subscribe()) {
yield call(listPublishedReviews, {}, { signal });
}
}),
adminListReviews: os
.input(z.object({
sessionId: z.string(),
statusFilter: z.enum(["all", "pending", "approved", "rejected"]).optional().default("all"),
}))
.handler(async function* ({ input, signal }) {
await assertOwner(input.sessionId);
const allReviews = await reviewsKV.getAllItems();
const filtered = input.statusFilter === "all"
? allReviews
: allReviews.filter(r => r.status === input.statusFilter);
const sorted = filtered.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
yield sorted;
for await (const _ of reviewsKV.subscribe()) {
const updated = await reviewsKV.getAllItems();
const filteredUpdated = input.statusFilter === "all"
? updated
: updated.filter(r => r.status === input.statusFilter);
const sortedUpdated = filteredUpdated.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
yield sortedUpdated;
}
}),
};
export const router = {
submitReview,
approveReview,
rejectReview,
deleteReview,
listPublishedReviews,
adminListReviews,
live,
};

View File

@@ -0,0 +1,52 @@
import { call, os } from "@orpc/server";
import { z } from "zod";
import { randomUUID } from "crypto";
import { createKV } from "../lib/create-kv.js";
const TreatmentSchema = z.object({
id: z.string(),
name: z.string(),
description: z.string(),
duration: z.number(), // duration in minutes
price: z.number(), // price in cents
category: z.string(),
});
const kv = createKV("treatments");
const create = os
.input(TreatmentSchema.omit({ id: true }))
.handler(async ({ input }) => {
const id = randomUUID();
const treatment = { id, ...input };
await kv.setItem(id, treatment);
return treatment;
});
const update = os
.input(TreatmentSchema)
.handler(async ({ input }) => {
await kv.setItem(input.id, input);
return input;
});
const remove = os.input(z.string()).handler(async ({ input }) => {
await kv.removeItem(input);
});
const list = os.handler(async () => {
return kv.getAllItems();
});
const get = os.input(z.string()).handler(async ({ input }) => {
return kv.getItem(input);
});
const live = {
list: os.handler(async function* ({ signal }) {
yield call(list, {}, { signal });
for await (const _ of kv.subscribe()) {
yield call(list, {}, { signal });
}
}),
};
export const router = {
create,
update,
remove,
list,
get,
live,
};

View File

@@ -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<any>(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 (
<div className="max-w-7xl mx-auto">
<h2 className="text-2xl font-bold text-gray-900 mb-6">Kalender - Bevorstehende Buchungen</h2>
@@ -307,6 +341,62 @@ export function AdminCalendar() {
</div>
</div>
{/* CalDAV Integration */}
<div className="bg-white rounded-lg shadow p-6 mb-6">
<div className="flex items-center justify-between mb-4">
<div>
<h3 className="text-lg font-semibold text-gray-900">Kalender-Abonnement</h3>
<p className="text-sm text-gray-600">Abonniere deinen Terminkalender in deiner Kalender-App</p>
</div>
<button
onClick={handleGenerateCalDAVToken}
disabled={isGeneratingToken}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors text-sm font-medium"
>
{isGeneratingToken ? 'Generiere...' : 'CalDAV-Link erstellen'}
</button>
</div>
{caldavData && (
<div className="border-t pt-4">
<div className="bg-gray-50 rounded-lg p-4 mb-4">
<div className="flex items-center justify-between mb-2">
<label className="text-sm font-medium text-gray-700">CalDAV-URL:</label>
<button
onClick={() => copyToClipboard(caldavData.caldavUrl)}
className="text-blue-600 hover:text-blue-800 text-sm"
>
Kopieren
</button>
</div>
<input
type="text"
value={caldavData.caldavUrl}
readOnly
className="w-full p-2 bg-white border border-gray-300 rounded text-sm font-mono"
/>
<div className="text-xs text-gray-500 mt-2">
Gültig bis: {new Date(caldavData.expiresAt).toLocaleString('de-DE')}
</div>
</div>
<div className="text-sm text-gray-600">
<p className="mb-2">
<strong>So abonnierst du den Kalender:</strong>
</p>
<ul className="list-disc list-inside space-y-1 text-sm">
{caldavData.instructions.steps.map((step: string, index: number) => (
<li key={index}>{step}</li>
))}
</ul>
<p className="mt-3 text-amber-700 bg-amber-50 p-2 rounded">
<strong>Hinweis:</strong> {caldavData.instructions.note}
</p>
</div>
</div>
)}
</div>
{/* Calendar */}
<div className="bg-white rounded-lg shadow-lg overflow-hidden">
{/* Calendar Header */}

View File

@@ -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

233
src/server/routes/caldav.ts Normal file
View File

@@ -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<Booking>("bookings");
const treatmentsKV = createKV<Treatment>("treatments");
const sessionsKV = createKV<any>("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 = `<?xml version="1.0" encoding="utf-8"?>
<D:multistatus xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
<D:response>
<D:href>/caldav/</D:href>
<D:propstat>
<D:prop>
<D:displayname>Stargirlnails Terminkalender</D:displayname>
<C:calendar-description>Termine für Stargirlnails</C:calendar-description>
<C:supported-calendar-component-set>
<C:comp name="VEVENT"/>
</C:supported-calendar-component-set>
<C:calendar-timezone>Europe/Berlin</C:calendar-timezone>
</D:prop>
<D:status>HTTP/1.1 200 OK</D:status>
</D:propstat>
</D:response>
</D:multistatus>`;
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 = `<?xml version="1.0" encoding="utf-8"?>
<D:multistatus xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav" xmlns:CS="http://calendarserver.org/ns/">
<D:response>
<D:href>/caldav/calendar/</D:href>
<D:propstat>
<D:prop>
<D:displayname>Stargirlnails Termine</D:displayname>
<C:calendar-description>Alle Termine von Stargirlnails</C:calendar-description>
<C:supported-calendar-component-set>
<C:comp name="VEVENT"/>
</C:supported-calendar-component-set>
<C:calendar-timezone>Europe/Berlin</C:calendar-timezone>
<CS:getctag>${Date.now()}</CS:getctag>
<D:sync-token>${Date.now()}</D:sync-token>
</D:prop>
<D:status>HTTP/1.1 200 OK</D:status>
</D:propstat>
</D:response>
</D:multistatus>`;
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 = `<?xml version="1.0" encoding="utf-8"?>
<D:multistatus xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav" xmlns:CS="http://calendarserver.org/ns/">
<D:response>
<D:href>/caldav/calendar/events.ics</D:href>
<D:propstat>
<D:prop>
<D:getcontenttype>text/calendar; charset=utf-8</D:getcontenttype>
<D:getetag>"${Date.now()}"</D:getetag>
<D:displayname>Stargirlnails Termine</D:displayname>
<C:calendar-data>BEGIN:VCALENDAR\\nVERSION:2.0\\nEND:VCALENDAR</C:calendar-data>
</D:prop>
<D:status>HTTP/1.1 200 OK</D:status>
</D:propstat>
</D:response>
</D:multistatus>`;
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);
});

View File

@@ -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."
}
};
}),
};