chore(docker): .dockerignore angepasst; lokale Build-Schritte in Rebuild-Skripten; Doku/README zu production vs production-prebuilt aktualisiert

This commit is contained in:
2025-10-06 18:59:17 +02:00
parent 7a84130aec
commit 1124b1f40b
24 changed files with 1149 additions and 270 deletions

View File

@@ -1,13 +1,19 @@
import { call, os } from "@orpc/server";
import { z } from "zod";
import { randomUUID } from "crypto";
import { createKV } from "../lib/create-kv.js";
import { sanitizeText, sanitizeHtml, sanitizePhone } from "../lib/sanitize.js";
import { sendEmail, sendEmailWithAGBAndCalendar, sendEmailWithInspirationPhoto } from "../lib/email.js";
import { renderBookingPendingHTML, renderBookingConfirmedHTML, renderBookingCancelledHTML, renderAdminBookingNotificationHTML, renderBookingRescheduleProposalHTML, renderAdminRescheduleAcceptedHTML, renderAdminRescheduleDeclinedHTML } from "../lib/email-templates.js";
import { os as baseOs, call as baseCall } from "@orpc/server";
const osAny = baseOs;
const os = (osAny.withContext ? osAny.withContext() : (osAny.context ? osAny.context() : baseOs));
const call = baseCall;
import { createORPCClient } from "@orpc/client";
import { RPCLink } from "@orpc/client/fetch";
import { checkBookingRateLimit } from "../lib/rate-limiter.js";
import { checkBookingRateLimit, enforceAdminRateLimit } from "../lib/rate-limiter.js";
import { validateEmail } from "../lib/email-validator.js";
import { assertOwner, getSessionFromCookies } from "../lib/auth.js";
// Using centrally typed os and call from rpc/index
// 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` });
@@ -188,10 +194,21 @@ const create = os
await validateBookingAgainstRules(input.appointmentDate, input.appointmentTime, treatment.duration);
// Check for booking conflicts
await checkBookingConflicts(input.appointmentDate, input.appointmentTime, treatment.duration);
// Sanitize user-provided fields before storage
const sanitizedName = sanitizeText(input.customerName);
const sanitizedPhone = input.customerPhone ? sanitizePhone(input.customerPhone) : undefined;
const sanitizedNotes = input.notes ? sanitizeHtml(input.notes) : undefined;
const id = randomUUID();
const booking = {
id,
...input,
treatmentId: input.treatmentId,
customerName: sanitizedName,
customerEmail: input.customerEmail,
customerPhone: sanitizedPhone,
appointmentDate: input.appointmentDate,
appointmentTime: input.appointmentTime,
notes: sanitizedNotes,
inspirationPhoto: input.inspirationPhoto,
bookedDurationMinutes: treatment.duration, // Snapshot treatment duration
status: "pending",
createdAt: new Date().toISOString()
@@ -206,7 +223,7 @@ const create = os
const formattedDate = formatDateGerman(input.appointmentDate);
const homepageUrl = generateUrl();
const html = await renderBookingPendingHTML({
name: input.customerName,
name: sanitizedName,
date: input.appointmentDate,
time: input.appointmentTime,
statusUrl: bookingUrl
@@ -214,7 +231,7 @@ const create = os
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`,
text: `Hallo ${sanitizedName},\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(() => { });
})();
@@ -227,37 +244,37 @@ const create = os
const treatment = allTreatments.find(t => t.id === input.treatmentId);
const treatmentName = treatment?.name || "Unbekannte Behandlung";
const adminHtml = await renderAdminBookingNotificationHTML({
name: input.customerName,
name: sanitizedName,
date: input.appointmentDate,
time: input.appointmentTime,
treatment: treatmentName,
phone: input.customerPhone || "Nicht angegeben",
notes: input.notes,
phone: sanitizedPhone || "Nicht angegeben",
notes: sanitizedNotes,
hasInspirationPhoto: !!input.inspirationPhoto
});
const homepageUrl = generateUrl();
const adminText = `Neue Buchungsanfrage eingegangen:\n\n` +
`Name: ${input.customerName}\n` +
`Telefon: ${input.customerPhone || "Nicht angegeben"}\n` +
`Name: ${sanitizedName}\n` +
`Telefon: ${sanitizedPhone || "Nicht angegeben"}\n` +
`Behandlung: ${treatmentName}\n` +
`Datum: ${formatDateGerman(input.appointmentDate)}\n` +
`Uhrzeit: ${input.appointmentTime}\n` +
`${input.notes ? `Notizen: ${input.notes}\n` : ''}` +
`${sanitizedNotes ? `Notizen: ${sanitizedNotes}\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}`,
subject: `Neue Buchungsanfrage - ${sanitizedName}`,
text: adminText,
html: adminHtml,
}, input.inspirationPhoto, input.customerName).catch(() => { });
}, input.inspirationPhoto, sanitizedName).catch(() => { });
}
else {
await sendEmail({
to: process.env.ADMIN_EMAIL,
subject: `Neue Buchungsanfrage - ${input.customerName}`,
subject: `Neue Buchungsanfrage - ${sanitizedName}`,
text: adminText,
html: adminHtml,
}).catch(() => { });
@@ -271,26 +288,16 @@ const create = os
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");
}
// Owner check reuse (simple inline version)
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);
.handler(async ({ input, context }) => {
await assertOwner(context);
// Admin Rate Limiting nach erfolgreicher Owner-Prüfung
await enforceAdminRateLimit(context);
const booking = await kv.getItem(input.id);
if (!booking)
throw new Error("Booking not found");
@@ -323,7 +330,7 @@ const updateStatus = os
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`,
text: `Hallo ${sanitizeText(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,
}, {
@@ -343,7 +350,7 @@ const updateStatus = os
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`,
text: `Hallo ${sanitizeText(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,
});
@@ -357,12 +364,13 @@ const updateStatus = os
});
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);
.handler(async ({ input, context }) => {
await assertOwner(context);
// Admin Rate Limiting nach erfolgreicher Owner-Prüfung
await enforceAdminRateLimit(context);
const booking = await kv.getItem(input.id);
if (!booking)
throw new Error("Booking not found");
@@ -388,7 +396,7 @@ const remove = os
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`,
text: `Hallo ${sanitizeText(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,
});
@@ -402,7 +410,6 @@ const remove = os
// 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(),
@@ -411,9 +418,11 @@ const createManual = os
appointmentTime: z.string(),
notes: z.string().optional(),
}))
.handler(async ({ input }) => {
.handler(async ({ input, context }) => {
// Admin authentication
await assertOwner(input.sessionId);
await assertOwner(context);
// Admin Rate Limiting nach erfolgreicher Owner-Prüfung
await enforceAdminRateLimit(context);
// Validate appointment time is on 15-minute grid
const appointmentMinutes = parseTime(input.appointmentTime);
if (appointmentMinutes % 15 !== 0) {
@@ -441,16 +450,20 @@ const createManual = os
await validateBookingAgainstRules(input.appointmentDate, input.appointmentTime, treatment.duration);
// Check for booking conflicts
await checkBookingConflicts(input.appointmentDate, input.appointmentTime, treatment.duration);
// Sanitize user-provided fields before storage (admin manual booking)
const sanitizedName = sanitizeText(input.customerName);
const sanitizedPhone = input.customerPhone ? sanitizePhone(input.customerPhone) : undefined;
const sanitizedNotes = input.notes ? sanitizeHtml(input.notes) : undefined;
const id = randomUUID();
const booking = {
id,
treatmentId: input.treatmentId,
customerName: input.customerName,
customerName: sanitizedName,
customerEmail: input.customerEmail,
customerPhone: input.customerPhone,
customerPhone: sanitizedPhone,
appointmentDate: input.appointmentDate,
appointmentTime: input.appointmentTime,
notes: input.notes,
notes: sanitizedNotes,
bookedDurationMinutes: treatment.duration,
status: "confirmed",
createdAt: new Date().toISOString()
@@ -467,7 +480,7 @@ const createManual = os
const formattedDate = formatDateGerman(input.appointmentDate);
const homepageUrl = generateUrl();
const html = await renderBookingConfirmedHTML({
name: input.customerName,
name: sanitizedName,
date: input.appointmentDate,
time: input.appointmentTime,
cancellationUrl: bookingUrl,
@@ -476,13 +489,13 @@ const createManual = os
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`,
text: `Hallo ${sanitizedName},\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,
customerName: sanitizedName,
treatmentName: treatment.name
});
}
@@ -537,13 +550,12 @@ export const router = {
// 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);
.handler(async ({ input, context }) => {
await assertOwner(context);
const booking = await kv.getItem(input.bookingId);
if (!booking)
throw new Error("Booking not found");
@@ -704,28 +716,28 @@ export const router = {
}),
// CalDAV Token für Admin generieren
generateCalDAVToken: os
.input(z.object({ sessionId: z.string() }))
.handler(async ({ input }) => {
await assertOwner(input.sessionId);
.input(z.object({}))
.handler(async ({ input, context }) => {
await assertOwner(context);
// 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);
// Hole Session-Daten aus Cookies
const session = await getSessionFromCookies(context);
if (!session)
throw new Error("Session nicht gefunden");
throw new Error("Invalid session");
// Speichere Token mit Ablaufzeit (24 Stunden)
const tokenData = {
id: token,
sessionId: input.sessionId,
userId: session.userId, // Benötigt für Session-Typ
userId: session.userId,
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);
// Dedizierten KV-Store für CalDAV-Token verwenden
const caldavTokensKV = createKV("caldavTokens");
await caldavTokensKV.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}`;
const caldavUrl = `${protocol}://${domain}/caldav/calendar/events.ics`;
return {
token,
caldavUrl,
@@ -733,15 +745,44 @@ export const router = {
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"
"⚠️ WICHTIG: Der Token darf NICHT in der URL stehen, sondern im Authorization-Header!",
"",
"📋 Dein CalDAV-Token (kopieren):",
token,
"",
"🔗 CalDAV-URL (ohne Token):",
caldavUrl,
"",
"📱 Einrichtung nach Kalender-App:",
"",
"🍎 Apple Calendar (macOS/iOS):",
"- Leider keine native Unterstützung für Authorization-Header",
"- Alternative: Verwende eine CalDAV-Bridge oder importiere die ICS-Datei manuell",
"",
"📧 Outlook:",
"- Datei → Kontoeinstellungen → Internetkalender",
"- URL eingeben (ohne Token)",
"- Erweiterte Einstellungen → Benutzerdefinierte Header hinzufügen:",
" Authorization: Bearer <DEIN_TOKEN>",
"",
"🌐 Google Calendar:",
"- Andere Kalender → Von URL hinzufügen",
"- Hinweis: Google Calendar unterstützt keine Authorization-Header",
"- Alternative: Verwende Google Apps Script oder importiere manuell",
"",
"🦅 Thunderbird:",
"- Kalender → Neuer Kalender → Im Netzwerk",
"- Format: CalDAV",
"- URL eingeben",
"- Anmeldung: Basic Auth mit Token als Benutzername (Passwort leer/optional)",
"",
"💻 cURL-Beispiel zum Testen:",
`# Bearer\ncurl -H "Authorization: Bearer ${token}" ${caldavUrl}\n\n# Basic (Token als Benutzername, Passwort leer)\ncurl -H "Authorization: Basic $(printf \"%s\" \"${token}:\" | base64)" ${caldavUrl}`,
"",
"⏰ Token-Gültigkeit: 24 Stunden",
"🔄 Bei Bedarf kannst du jederzeit einen neuen Token generieren."
],
note: "Dieser Token ist 24 Stunden gültig. Bei Bedarf kannst du einen neuen Token generieren."
note: "Aus Sicherheitsgründen wird der Token NICHT in der URL übertragen. Verwende den Authorization-Header: Bearer oder Basic (Token als Benutzername)."
}
};
}),