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:
176
server-dist/routes/caldav.js
Normal file
176
server-dist/routes/caldav.js
Normal 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);
|
||||
});
|
28
server-dist/routes/client-entry.js
Normal file
28
server-dist/routes/client-entry.js
Normal 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
21
server-dist/routes/rpc.js
Normal 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;
|
||||
}
|
||||
});
|
Reference in New Issue
Block a user