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:
@@ -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 */}
|
||||
|
@@ -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
233
src/server/routes/caldav.ts
Normal 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);
|
||||
});
|
@@ -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."
|
||||
}
|
||||
};
|
||||
}),
|
||||
};
|
Reference in New Issue
Block a user