Fix reschedule token handling and improve admin notifications

- Fix getBookingByToken to only accept booking_access tokens
- Add sweepExpiredRescheduleProposals with admin notifications
- Return isExpired flag instead of throwing errors for expired proposals
- Fix email template to use actual token expiry time
- Remove duplicate admin emails in acceptReschedule
- Add one-click accept/decline support via URL parameters
This commit is contained in:
2025-10-05 16:11:37 +02:00
parent 97c1d3493f
commit a8cec16d7a
6 changed files with 1433 additions and 36 deletions

View File

@@ -212,8 +212,8 @@ export function AdminBookings() {
<td className="px-6 py-4 whitespace-nowrap">
<div>
<div className="text-sm font-medium text-gray-900">{booking.customerName}</div>
<div className="text-sm text-gray-500">{booking.customerEmail}</div>
<div className="text-sm text-gray-500">{booking.customerPhone}</div>
<div className="text-sm text-gray-500">{booking.customerEmail || '—'}</div>
<div className="text-sm text-gray-500">{booking.customerPhone || '—'}</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">

View File

@@ -5,6 +5,30 @@ import { queryClient } from "@/client/rpc-client";
export function AdminCalendar() {
const [currentMonth, setCurrentMonth] = useState(new Date());
const [selectedDate, setSelectedDate] = useState<string | null>(null);
const [showDeleteConfirm, setShowDeleteConfirm] = useState<string | null>(null);
const [sendDeleteEmail, setSendDeleteEmail] = useState(false);
const [deleteActionType, setDeleteActionType] = useState<'delete' | 'cancel'>('delete');
// Manual booking modal state
const [showCreateModal, setShowCreateModal] = useState(false);
const [createFormData, setCreateFormData] = useState({
customerName: '',
treatmentId: '',
appointmentDate: '',
appointmentTime: '',
customerEmail: '',
customerPhone: '',
notes: ''
});
const [createError, setCreateError] = useState<string>('');
// Reschedule modal state
const [showRescheduleModal, setShowRescheduleModal] = useState<string | null>(null);
const [rescheduleFormData, setRescheduleFormData] = useState({
appointmentDate: '',
appointmentTime: ''
});
const [rescheduleError, setRescheduleError] = useState<string>('');
const { data: bookings } = useQuery(
queryClient.bookings.live.list.experimental_liveOptions()
@@ -14,10 +38,45 @@ export function AdminCalendar() {
queryClient.treatments.live.list.experimental_liveOptions()
);
// Optional query for available times when treatment and date are selected
const { data: availableTimes } = useQuery({
...queryClient.recurringAvailability.getAvailableTimes.queryOptions({
input: {
date: createFormData.appointmentDate,
treatmentId: createFormData.treatmentId
}
}),
enabled: !!createFormData.appointmentDate && !!createFormData.treatmentId
});
// Available times for reschedule modal
const { data: rescheduleAvailableTimes } = useQuery({
...queryClient.recurringAvailability.getAvailableTimes.queryOptions({
input: {
date: rescheduleFormData.appointmentDate,
treatmentId: (showRescheduleModal ? bookings?.find(b => b.id === showRescheduleModal)?.treatmentId : '') || ''
}
}),
enabled: !!showRescheduleModal && !!rescheduleFormData.appointmentDate
});
const { mutate: updateBookingStatus } = useMutation(
queryClient.bookings.updateStatus.mutationOptions()
);
const { mutate: removeBooking } = useMutation(
queryClient.bookings.remove.mutationOptions()
);
const { mutate: createManualBooking } = useMutation(
queryClient.bookings.createManual.mutationOptions()
);
// Propose reschedule mutation
const { mutate: proposeReschedule, isLoading: isProposingReschedule } = useMutation(
queryClient.bookings.proposeReschedule.mutationOptions()
);
const getTreatmentName = (treatmentId: string) => {
return treatments?.find(t => t.id === treatmentId)?.name || "Unbekannte Behandlung";
};
@@ -106,8 +165,116 @@ export function AdminCalendar() {
});
};
const handleDeleteBooking = () => {
const sessionId = localStorage.getItem('sessionId');
if (!sessionId || !showDeleteConfirm) return;
if (deleteActionType === 'cancel') {
// For cancel action, use updateStatus instead of remove
updateBookingStatus({
sessionId,
id: showDeleteConfirm,
status: "cancelled"
}, {
onSuccess: () => {
setShowDeleteConfirm(null);
setSendDeleteEmail(false);
setDeleteActionType('delete');
},
onError: () => {
// no-op; errors can be surfaced via existing patterns/toasts later
}
});
} else {
// For delete action, use remove with email option
removeBooking({
sessionId,
id: showDeleteConfirm,
sendEmail: sendDeleteEmail,
}, {
onSuccess: () => {
setShowDeleteConfirm(null);
setSendDeleteEmail(false);
setDeleteActionType('delete');
},
onError: () => {
// no-op; errors can be surfaced via existing patterns/toasts later
}
});
}
};
const today = new Date().toISOString().split('T')[0];
const handleCreateBooking = () => {
const sessionId = localStorage.getItem('sessionId');
if (!sessionId) return;
createManualBooking({
sessionId,
...createFormData
}, {
onSuccess: () => {
setShowCreateModal(false);
setCreateFormData({
customerName: '',
treatmentId: '',
appointmentDate: '',
appointmentTime: '',
customerEmail: '',
customerPhone: '',
notes: ''
});
setCreateError('');
},
onError: (error: any) => {
setCreateError(error?.message || 'Fehler beim Erstellen der Buchung');
}
});
};
const handleFormChange = (field: string, value: string) => {
setCreateFormData(prev => ({
...prev,
[field]: value,
// Reset time when treatment or date changes
...(field === 'treatmentId' || field === 'appointmentDate' ? { appointmentTime: '' } : {})
}));
setCreateError('');
};
const handleRescheduleFormChange = (field: string, value: string) => {
setRescheduleFormData(prev => ({
...prev,
[field]: value,
...(field === 'appointmentDate' ? { appointmentTime: '' } : {})
}));
setRescheduleError('');
};
const handleRescheduleBooking = () => {
const sessionId = localStorage.getItem('sessionId');
if (!sessionId || !showRescheduleModal) return;
const booking = bookings?.find(b => b.id === showRescheduleModal);
if (!booking) return;
proposeReschedule({
sessionId,
bookingId: booking.id,
proposedDate: rescheduleFormData.appointmentDate,
proposedTime: rescheduleFormData.appointmentTime,
}, {
onSuccess: () => {
setShowRescheduleModal(null);
setRescheduleFormData({ appointmentDate: '', appointmentTime: '' });
setRescheduleError('');
},
onError: (error: any) => {
setRescheduleError(error?.message || 'Fehler beim Senden des Vorschlags');
}
});
};
return (
<div className="max-w-7xl mx-auto">
<h2 className="text-2xl font-bold text-gray-900 mb-6">Kalender - Bevorstehende Buchungen</h2>
@@ -153,9 +320,17 @@ export function AdminCalendar() {
</svg>
</button>
<h3 className="text-xl font-semibold text-gray-900">
{monthNames[month]} {year}
</h3>
<div className="flex items-center space-x-4">
<h3 className="text-xl font-semibold text-gray-900">
{monthNames[month]} {year}
</h3>
<button
onClick={() => setShowCreateModal(true)}
className="px-4 py-2 bg-pink-600 text-white rounded-lg hover:bg-pink-700 transition-colors text-sm font-medium"
>
Termin erstellen
</button>
</div>
<button
onClick={() => navigateMonth('next')}
@@ -267,10 +442,10 @@ export function AdminCalendar() {
<strong>Uhrzeit:</strong> {booking.appointmentTime}
</div>
<div>
<strong>E-Mail:</strong> {booking.customerEmail}
<strong>E-Mail:</strong> {booking.customerEmail || '—'}
</div>
<div>
<strong>Telefon:</strong> {booking.customerPhone}
<strong>Telefon:</strong> {booking.customerPhone || '—'}
</div>
</div>
@@ -293,7 +468,11 @@ export function AdminCalendar() {
Bestätigen
</button>
<button
onClick={() => handleStatusUpdate(booking.id, "cancelled")}
onClick={() => {
setDeleteActionType('cancel');
setShowDeleteConfirm(booking.id);
setSendDeleteEmail(true); // Default to sending email for cancel
}}
className="px-3 py-1 bg-red-600 text-white text-sm rounded hover:bg-red-700 transition-colors"
>
Stornieren
@@ -302,13 +481,51 @@ export function AdminCalendar() {
)}
{booking.status === "confirmed" && (
<button
onClick={() => handleStatusUpdate(booking.id, "completed")}
className="px-3 py-1 bg-blue-600 text-white text-sm rounded hover:bg-blue-700 transition-colors"
>
Als erledigt markieren
</button>
<>
<button
onClick={() => handleStatusUpdate(booking.id, "completed")}
className="px-3 py-1 bg-blue-600 text-white text-sm rounded hover:bg-blue-700 transition-colors"
>
Als erledigt markieren
</button>
<button
onClick={() => {
setShowRescheduleModal(booking.id);
setRescheduleFormData({ appointmentDate: '', appointmentTime: '' });
setRescheduleError('');
}}
className="px-3 py-1 bg-orange-600 text-white text-sm rounded hover:bg-orange-700 transition-colors"
>
Umbuchen
</button>
</>
)}
{(() => {
const isPastDate = booking.appointmentDate < today;
const isCompleted = booking.status === 'completed';
const shouldDisableDelete = isPastDate || isCompleted;
return (
<button
onClick={() => {
if (!shouldDisableDelete) {
setDeleteActionType('delete');
setShowDeleteConfirm(booking.id);
setSendDeleteEmail(isPastDate || isCompleted ? false : false); // Disable email for past/completed
}
}}
disabled={shouldDisableDelete}
className={`px-3 py-1 text-sm rounded transition-colors ${
shouldDisableDelete
? 'bg-gray-400 text-gray-200 cursor-not-allowed'
: 'bg-red-600 text-white hover:bg-red-700'
}`}
title={shouldDisableDelete ? 'Löschen für vergangene/abgeschlossene Termine nicht verfügbar' : ''}
>
Löschen
</button>
);
})()}
</div>
</div>
</div>
@@ -317,6 +534,289 @@ export function AdminCalendar() {
)}
</div>
)}
{showDeleteConfirm !== null && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg shadow-xl p-6 w-full max-w-md">
<h4 className="text-lg font-semibold text-gray-900 mb-2">
{deleteActionType === 'cancel' ? 'Termin stornieren?' : 'Termin löschen?'}
</h4>
<p className="text-sm text-gray-600 mb-4">
{deleteActionType === 'cancel'
? 'Dieser Termin wird als storniert markiert. Der Zeitslot wird wieder freigegeben.'
: 'Dieser Termin wird als storniert markiert. Der Zeitslot wird wieder freigegeben.'
}
</p>
{deleteActionType === 'delete' && (
<label className="flex items-center space-x-2 mb-4">
<input
type="checkbox"
checked={sendDeleteEmail}
onChange={(e) => setSendDeleteEmail(e.target.checked)}
className="h-4 w-4 text-pink-600 border-gray-300 rounded"
/>
<span className="text-sm text-gray-700">Stornierungsmail an Kunde senden</span>
</label>
)}
<div className="flex flex-col sm:flex-row sm:space-x-3 space-y-2 sm:space-y-0">
<button
onClick={() => {
setShowDeleteConfirm(null);
setSendDeleteEmail(false);
setDeleteActionType('delete');
}}
className="px-4 py-2 rounded bg-gray-100 text-gray-700 hover:bg-gray-200 transition-colors w-full"
>
Abbrechen
</button>
<button
onClick={handleDeleteBooking}
className="px-4 py-2 rounded bg-red-600 text-white hover:bg-red-700 transition-colors w-full"
>
{deleteActionType === 'cancel' ? 'Stornieren' : 'Löschen'}
</button>
</div>
</div>
</div>
)}
{/* Create Manual Booking Modal */}
{showCreateModal && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg shadow-xl p-6 w-full max-w-md max-h-[90vh] overflow-y-auto">
<h4 className="text-lg font-semibold text-gray-900 mb-4">Termin erstellen</h4>
{createError && (
<div className="mb-4 bg-red-50 border border-red-200 text-red-700 px-3 py-2 rounded-md text-sm">
{createError}
</div>
)}
<div className="space-y-4">
{/* Customer Name */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Kundenname *
</label>
<input
type="text"
value={createFormData.customerName}
onChange={(e) => handleFormChange('customerName', e.target.value)}
className="w-full p-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-pink-500 focus:border-pink-500"
required
/>
</div>
{/* Treatment */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Behandlung *
</label>
<select
value={createFormData.treatmentId}
onChange={(e) => handleFormChange('treatmentId', e.target.value)}
className="w-full p-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-pink-500 focus:border-pink-500"
required
>
<option value="">Behandlung wählen</option>
{treatments?.map(treatment => (
<option key={treatment.id} value={treatment.id}>
{treatment.name}
</option>
))}
</select>
</div>
{/* Date */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Datum *
</label>
<input
type="date"
value={createFormData.appointmentDate}
onChange={(e) => handleFormChange('appointmentDate', e.target.value)}
min={today}
className="w-full p-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-pink-500 focus:border-pink-500"
required
/>
</div>
{/* Time */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Uhrzeit *
</label>
<select
value={createFormData.appointmentTime}
onChange={(e) => handleFormChange('appointmentTime', e.target.value)}
disabled={!createFormData.treatmentId || !createFormData.appointmentDate}
className="w-full p-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-pink-500 focus:border-pink-500 disabled:bg-gray-100"
required
>
<option value="">Zeit wählen</option>
{availableTimes?.map(time => (
<option key={time} value={time}>
{time}
</option>
))}
</select>
{(!createFormData.treatmentId || !createFormData.appointmentDate) && (
<p className="text-xs text-gray-500 mt-1">
Wähle zuerst Behandlung und Datum
</p>
)}
</div>
{/* Email */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
E-Mail (optional)
</label>
<input
type="email"
value={createFormData.customerEmail}
onChange={(e) => handleFormChange('customerEmail', e.target.value)}
className="w-full p-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-pink-500 focus:border-pink-500"
/>
</div>
{/* Phone */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Telefon (optional)
</label>
<input
type="tel"
value={createFormData.customerPhone}
onChange={(e) => handleFormChange('customerPhone', e.target.value)}
className="w-full p-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-pink-500 focus:border-pink-500"
/>
</div>
{/* Notes */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Notizen (optional)
</label>
<textarea
value={createFormData.notes}
onChange={(e) => handleFormChange('notes', e.target.value)}
rows={3}
className="w-full p-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-pink-500 focus:border-pink-500"
/>
</div>
</div>
<div className="flex space-x-3 mt-6">
<button
onClick={() => {
setShowCreateModal(false);
setCreateFormData({
customerName: '',
treatmentId: '',
appointmentDate: '',
appointmentTime: '',
customerEmail: '',
customerPhone: '',
notes: ''
});
setCreateError('');
}}
className="flex-1 px-4 py-2 bg-gray-100 text-gray-700 rounded-md hover:bg-gray-200 transition-colors"
>
Abbrechen
</button>
<button
onClick={handleCreateBooking}
disabled={!createFormData.customerName || !createFormData.treatmentId || !createFormData.appointmentDate || !createFormData.appointmentTime}
className="flex-1 px-4 py-2 bg-pink-600 text-white rounded-md hover:bg-pink-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Termin erstellen
</button>
</div>
</div>
</div>
)}
{/* Reschedule Modal */}
{showRescheduleModal && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg shadow-xl p-6 w-full max-w-md max-h-[90vh] overflow-y-auto">
<h4 className="text-lg font-semibold text-gray-900 mb-4">Termin umbuchen</h4>
{rescheduleError && (
<div className="mb-4 bg-red-50 border border-red-200 text-red-700 px-3 py-2 rounded-md text-sm">
{rescheduleError}
</div>
)}
{(() => {
const booking = bookings?.find(b => b.id === showRescheduleModal);
const treatmentName = booking ? getTreatmentName(booking.treatmentId) : '';
return booking ? (
<div className="mb-4 text-sm text-gray-700">
<div className="mb-2"><strong>Kunde:</strong> {booking.customerName}</div>
<div className="mb-2"><strong>Aktueller Termin:</strong> {booking.appointmentDate} um {booking.appointmentTime} Uhr</div>
<div className="mb-2"><strong>Behandlung:</strong> {treatmentName}</div>
</div>
) : null;
})()}
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Neues Datum</label>
<input
type="date"
value={rescheduleFormData.appointmentDate}
onChange={(e) => handleRescheduleFormChange('appointmentDate', e.target.value)}
min={today}
className="w-full p-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-orange-500 focus:border-orange-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Neue Uhrzeit</label>
<select
value={rescheduleFormData.appointmentTime}
onChange={(e) => handleRescheduleFormChange('appointmentTime', e.target.value)}
disabled={!rescheduleFormData.appointmentDate}
className="w-full p-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-orange-500 focus:border-orange-500 disabled:bg-gray-100"
>
<option value="">Zeit wählen</option>
{rescheduleAvailableTimes?.map(time => (
<option key={time} value={time}>{time}</option>
))}
</select>
{!rescheduleFormData.appointmentDate && (
<p className="text-xs text-gray-500 mt-1">Wähle zuerst ein Datum</p>
)}
</div>
<div className="bg-amber-50 border border-amber-200 text-amber-800 px-3 py-2 rounded-md text-sm">
Der Kunde erhält eine E-Mail mit dem Vorschlag. Er hat 48 Stunden Zeit zu antworten.
</div>
</div>
<div className="flex space-x-3 mt-6">
<button
onClick={() => {
setShowRescheduleModal(null);
setRescheduleFormData({ appointmentDate: '', appointmentTime: '' });
setRescheduleError('');
}}
className="flex-1 px-4 py-2 bg-gray-100 text-gray-700 rounded-md hover:bg-gray-200 transition-colors"
>
Abbrechen
</button>
<button
onClick={handleRescheduleBooking}
disabled={!rescheduleFormData.appointmentDate || !rescheduleFormData.appointmentTime || isProposingReschedule}
className="flex-1 px-4 py-2 bg-orange-600 text-white rounded-md hover:bg-orange-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{isProposingReschedule ? 'Senden...' : 'Vorschlag senden'}
</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -1,4 +1,4 @@
import React, { useState } from "react";
import React, { useState, useEffect } from "react";
import { useQuery, useMutation } from "@tanstack/react-query";
import { queryClient } from "@/client/rpc-client";
@@ -57,12 +57,27 @@ export default function BookingStatusPage({ token }: BookingStatusPageProps) {
const [showCancelConfirm, setShowCancelConfirm] = useState(false);
const [isCancelling, setIsCancelling] = useState(false);
const [cancellationResult, setCancellationResult] = useState<{ success: boolean; message: string; formattedDate?: string } | null>(null);
const [rescheduleProposal, setRescheduleProposal] = useState<any | null>(null);
const [rescheduleResult, setRescheduleResult] = useState<{ success: boolean; message: string } | null>(null);
const [isAccepting, setIsAccepting] = useState(false);
const [isDeclining, setIsDeclining] = useState(false);
const [showDeclineConfirm, setShowDeclineConfirm] = useState(false);
const [oneClickAction, setOneClickAction] = useState<string | null>(null);
const [oneClickLoading, setOneClickLoading] = useState(false);
// Fetch booking details
const { data: booking, isLoading, error, refetch } = useQuery(
const { data: booking, isLoading, error, refetch, error: bookingError } = useQuery(
queryClient.cancellation.getBookingByToken.queryOptions({ input: { token } })
);
// Try fetching reschedule proposal if booking not found or error
useQuery({
...queryClient.cancellation.getRescheduleProposal.queryOptions({ input: { token } }),
enabled: !!token && (!!bookingError || !booking),
onSuccess: (data: any) => setRescheduleProposal(data),
onError: () => setRescheduleProposal(null),
});
// Cancellation mutation
const cancelMutation = useMutation({
...queryClient.cancellation.cancelByToken.mutationOptions(),
@@ -85,12 +100,88 @@ export default function BookingStatusPage({ token }: BookingStatusPageProps) {
},
});
const acceptMutation = useMutation({
...queryClient.bookings.acceptReschedule.mutationOptions(),
onSuccess: (result: any) => {
setRescheduleResult({ success: true, message: result.message });
setIsAccepting(false);
setShowDeclineConfirm(false);
refetch();
},
onError: (error: any) => {
setRescheduleResult({ success: false, message: error?.message || 'Ein Fehler ist aufgetreten.' });
setIsAccepting(false);
}
});
const declineMutation = useMutation({
...queryClient.bookings.declineReschedule.mutationOptions(),
onSuccess: (result: any) => {
setRescheduleResult({ success: true, message: result.message });
setIsDeclining(false);
setShowDeclineConfirm(false);
},
onError: (error: any) => {
setRescheduleResult({ success: false, message: error?.message || 'Ein Fehler ist aufgetreten.' });
setIsDeclining(false);
}
});
const handleCancel = () => {
setIsCancelling(true);
setCancellationResult(null);
cancelMutation.mutate({ token });
};
// Handle one-click actions from URL parameters
useEffect(() => {
if (rescheduleProposal && !oneClickAction) {
const urlParams = new URLSearchParams(window.location.search);
const action = urlParams.get('action');
if (action === 'accept' || action === 'decline') {
setOneClickAction(action);
}
}
}, [rescheduleProposal, oneClickAction]);
// Auto-execute one-click action
useEffect(() => {
if (oneClickAction && rescheduleProposal && !oneClickLoading && !rescheduleResult) {
setOneClickLoading(true);
if (oneClickAction === 'accept') {
const confirmAccept = window.confirm(
`Möchtest du den neuen Termin am ${rescheduleProposal.proposed.date} um ${rescheduleProposal.proposed.time} Uhr akzeptieren?`
);
if (confirmAccept) {
acceptMutation.mutate({ token });
} else {
setOneClickLoading(false);
setOneClickAction(null);
}
} else if (oneClickAction === 'decline') {
const confirmDecline = window.confirm(
`Möchtest du den Vorschlag ablehnen? Dein ursprünglicher Termin am ${rescheduleProposal.original.date} um ${rescheduleProposal.original.time} Uhr bleibt dann bestehen.`
);
if (confirmDecline) {
declineMutation.mutate({ token });
} else {
setOneClickLoading(false);
setOneClickAction(null);
}
}
}
}, [oneClickAction, rescheduleProposal, oneClickLoading, rescheduleResult, acceptMutation, declineMutation, token]);
// Reset one-click loading when mutations complete
useEffect(() => {
if (rescheduleResult) {
setOneClickLoading(false);
setOneClickAction(null);
}
}, [rescheduleResult]);
if (isLoading) {
return (
<div className="min-h-screen bg-gradient-to-br from-pink-50 to-purple-50 flex items-center justify-center p-4">
@@ -104,7 +195,7 @@ export default function BookingStatusPage({ token }: BookingStatusPageProps) {
);
}
if (error) {
if (error && !rescheduleProposal) {
return (
<div className="min-h-screen bg-gradient-to-br from-pink-50 to-purple-50 flex items-center justify-center p-4">
<div className="bg-white p-8 rounded-lg shadow-lg max-w-md w-full">
@@ -140,7 +231,7 @@ export default function BookingStatusPage({ token }: BookingStatusPageProps) {
);
}
if (!booking) {
if (!booking && !rescheduleProposal) {
return (
<div className="min-h-screen bg-gradient-to-br from-pink-50 to-purple-50 flex items-center justify-center p-4">
<div className="bg-white p-8 rounded-lg shadow-lg max-w-md w-full">
@@ -161,6 +252,141 @@ export default function BookingStatusPage({ token }: BookingStatusPageProps) {
);
}
if (rescheduleProposal) {
const isExpired = rescheduleProposal.isExpired;
const handleAccept = () => {
setIsAccepting(true);
setRescheduleResult(null);
acceptMutation.mutate({ token });
};
const handleDecline = () => {
setIsDeclining(true);
setRescheduleResult(null);
declineMutation.mutate({ token });
};
return (
<div className="min-h-screen bg-gradient-to-br from-pink-50 to-purple-50 py-8 px-4">
<div className="max-w-2xl mx-auto">
<div className="bg-white rounded-lg shadow-lg p-6 mb-6">
<div className="flex items-center justify-between mb-4">
<img src="/assets/stargilnails_logo_transparent_112.png" alt="Stargil Nails Logo" className="w-16 h-16 object-contain" />
<span className={`px-4 py-2 rounded-full text-sm font-semibold bg-orange-100 text-orange-800`}>
Terminänderung vorgeschlagen
</span>
</div>
<h1 className="text-2xl font-bold text-gray-900">Vorschlag zur Terminänderung</h1>
<p className="text-gray-600 mt-1">Bitte bestätige, ob der neue Termin für dich passt.</p>
</div>
{oneClickLoading && (
<div className="mb-6 p-4 rounded-lg bg-blue-50 border border-blue-200">
<div className="flex items-center">
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-blue-500 mr-3"></div>
<p className="text-blue-800">
{oneClickAction === 'accept' ? 'Akzeptiere Termin...' : 'Lehne Termin ab...'}
</p>
</div>
</div>
)}
{rescheduleResult && (
<div className={`mb-6 p-4 rounded-lg ${rescheduleResult.success ? 'bg-green-50 border border-green-200' : 'bg-red-50 border border-red-200'}`}>
<p className={rescheduleResult.success ? 'text-green-800' : 'text-red-800'}>
{rescheduleResult.message}
</p>
</div>
)}
<div className="bg-white rounded-lg shadow-lg p-6 mb-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4">Vergleich</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="border rounded-lg p-4 bg-gray-50">
<div className="text-sm text-gray-500 font-semibold mb-1">Aktueller Termin</div>
<div className="text-gray-900 font-medium">{rescheduleProposal.original.date} um {rescheduleProposal.original.time} Uhr</div>
<div className="text-gray-700 text-sm">{rescheduleProposal.booking.treatmentName}</div>
</div>
<div className="border rounded-lg p-4 bg-orange-50">
<div className="text-sm text-orange-700 font-semibold mb-1">Neuer Vorschlag</div>
<div className="text-gray-900 font-medium">{rescheduleProposal.proposed.date} um {rescheduleProposal.proposed.time} Uhr</div>
<div className="text-gray-700 text-sm">{rescheduleProposal.booking.treatmentName}</div>
</div>
</div>
<div className="mt-4 bg-yellow-50 border border-yellow-200 rounded-lg p-3 text-sm text-yellow-800">
Bitte antworte bis {new Date(rescheduleProposal.expiresAt).toLocaleDateString('de-DE')} {new Date(rescheduleProposal.expiresAt).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })} ({rescheduleProposal.hoursUntilExpiry} Stunden).
</div>
</div>
{!isExpired && !rescheduleResult && !oneClickLoading && (
<div className="bg-white rounded-lg shadow-lg p-6 mb-6">
<div className="flex flex-col sm:flex-row gap-3">
<button
onClick={handleAccept}
disabled={isAccepting}
className="flex-1 bg-green-600 text-white py-3 px-4 rounded-lg font-medium hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{isAccepting ? 'Akzeptiere...' : 'Neuen Termin akzeptieren'}
</button>
<button
onClick={() => setShowDeclineConfirm(true)}
disabled={isDeclining}
className="flex-1 bg-red-600 text-white py-3 px-4 rounded-lg font-medium hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Vorschlag ablehnen
</button>
</div>
<div className="mt-3 text-sm text-gray-600">Wenn du ablehnst, bleibt dein ursprünglicher Termin bestehen.</div>
</div>
)}
{isExpired && (
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4 mb-6">
<p className="text-yellow-800 text-sm">
Diese Terminänderung ist abgelaufen. Dein ursprünglicher Termin bleibt bestehen. Bei Fragen kontaktiere uns bitte.
</p>
</div>
)}
{showDeclineConfirm && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg shadow-xl p-6 w-full max-w-md">
<h4 className="text-lg font-semibold text-gray-900 mb-2">Vorschlag ablehnen?</h4>
<p className="text-sm text-gray-700 mb-4">
Bist du sicher, dass du den neuen Terminvorschlag ablehnen möchtest?<br />
Dein ursprünglicher Termin am {rescheduleProposal.original.date} um {rescheduleProposal.original.time} bleibt dann bestehen.
</p>
<div className="flex gap-3">
<button
onClick={() => setShowDeclineConfirm(false)}
className="flex-1 bg-gray-100 text-gray-700 py-2 rounded-lg hover:bg-gray-200"
>
Abbrechen
</button>
<button
onClick={handleDecline}
disabled={isDeclining}
className="flex-1 bg-red-600 text-white py-2 rounded-lg hover:bg-red-700 disabled:opacity-50"
>
{isDeclining ? 'Lehne ab...' : 'Ja, ablehnen'}
</button>
</div>
</div>
</div>
)}
<div className="text-center">
<a href="/" className="inline-flex items-center text-pink-600 hover:text-pink-700 font-medium">
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
Zurück zur Startseite
</a>
</div>
</div>
</div>
);
}
const statusInfo = getStatusInfo(booking.status);
return (
@@ -285,11 +511,11 @@ export default function BookingStatusPage({ token }: BookingStatusPageProps) {
</div>
<div className="flex justify-between py-2 border-b border-gray-100">
<span className="text-gray-600">E-Mail:</span>
<span className="font-medium text-gray-900">{booking.customerEmail}</span>
<span className="font-medium text-gray-900">{booking.customerEmail || '—'}</span>
</div>
<div className="flex justify-between py-2">
<span className="text-gray-600">Telefon:</span>
<span className="font-medium text-gray-900">{booking.customerPhone}</span>
<span className="font-medium text-gray-900">{booking.customerPhone || '—'}</span>
</div>
</div>
{booking.notes && (

View File

@@ -169,3 +169,145 @@ export async function renderAdminBookingNotificationHTML(params: {
}
export async function renderBookingRescheduleProposalHTML(params: {
name: string;
originalDate: string;
originalTime: string;
proposedDate: string;
proposedTime: string;
treatmentName: string;
acceptUrl: string;
declineUrl: string;
expiresAt: string;
}) {
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: {
customerName: string;
originalDate: string;
originalTime: string;
proposedDate: string;
proposedTime: string;
treatmentName: string;
customerEmail?: string;
customerPhone?: string;
}) {
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: {
customerName: string;
originalDate: string;
originalTime: string;
newDate: string;
newTime: string;
treatmentName: string;
}) {
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: {
expiredProposals: Array<{
customerName: string;
originalDate: string;
originalTime: string;
proposedDate: string;
proposedTime: string;
treatmentName: string;
customerEmail?: string;
customerPhone?: string;
expiredAt: string;
}>;
}) {
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

@@ -3,7 +3,7 @@ import { z } from "zod";
import { randomUUID } from "crypto";
import { createKV } from "../lib/create-kv.js";
import { sendEmail, sendEmailWithAGB, sendEmailWithAGBAndCalendar, sendEmailWithInspirationPhoto } from "../lib/email.js";
import { renderBookingPendingHTML, renderBookingConfirmedHTML, renderBookingCancelledHTML, renderAdminBookingNotificationHTML } from "../lib/email-templates.js";
import { renderBookingPendingHTML, renderBookingConfirmedHTML, renderBookingCancelledHTML, renderAdminBookingNotificationHTML, renderBookingRescheduleProposalHTML, renderAdminRescheduleAcceptedHTML, renderAdminRescheduleDeclinedHTML } from "../lib/email-templates.js";
import { router as rootRouter } from "./index.js";
import { createORPCClient } from "@orpc/client";
import { RPCLink } from "@orpc/client/fetch";
@@ -128,12 +128,23 @@ async function checkBookingConflicts(
}
}
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"),
customerPhone: z.string().min(5, "Telefonnummer muss mindestens 5 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"]),
@@ -195,7 +206,7 @@ type Treatment = {
const treatmentsKV = createKV<Treatment>("treatments");
const create = os
.input(BookingSchema.omit({ id: true, createdAt: true, status: true }))
.input(CreateBookingInputSchema)
.handler(async ({ input }) => {
// console.log("Booking create called with input:", {
// ...input,
@@ -253,7 +264,7 @@ const create = os
if (!process.env.DISABLE_DUPLICATE_CHECK) {
const existing = await kv.getAllItems();
const hasConflict = existing.some(b =>
b.customerEmail.toLowerCase() === input.customerEmail.toLowerCase() &&
(b.customerEmail && b.customerEmail.toLowerCase() === input.customerEmail.toLowerCase()) &&
b.appointmentDate === input.appointmentDate &&
(b.status === "pending" || b.status === "confirmed")
);
@@ -329,7 +340,7 @@ const create = os
date: input.appointmentDate,
time: input.appointmentTime,
treatment: treatmentName,
phone: input.customerPhone,
phone: input.customerPhone || "Nicht angegeben",
notes: input.notes,
hasInspirationPhoto: !!input.inspirationPhoto
});
@@ -338,7 +349,7 @@ const create = os
const adminText = `Neue Buchungsanfrage eingegangen:\n\n` +
`Name: ${input.customerName}\n` +
`Telefon: ${input.customerPhone}\n` +
`Telefon: ${input.customerPhone || "Nicht angegeben"}\n` +
`Behandlung: ${treatmentName}\n` +
`Datum: ${formatDateGerman(input.appointmentDate)}\n` +
`Uhrzeit: ${input.appointmentTime}\n` +
@@ -425,6 +436,7 @@ const updateStatus = os
// 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",
@@ -438,7 +450,57 @@ const updateStatus = os
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" as const };
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 });
@@ -449,16 +511,132 @@ const updateStatus = os
html,
bcc: process.env.ADMIN_EMAIL ? [process.env.ADMIN_EMAIL] : undefined,
});
} catch (e) {
console.error("Email send failed:", e);
}
} catch (e) {
console.error("Email send failed:", e);
}
return updatedBooking;
});
const remove = os.input(z.string()).handler(async ({ input }) => {
await kv.removeItem(input);
});
// 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" as const,
createdAt: new Date().toISOString()
} as Booking;
// 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
});
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,
bcc: process.env.ADMIN_EMAIL ? [process.env.ADMIN_EMAIL] : undefined,
}, {
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();
@@ -495,10 +673,189 @@ const live = {
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 } as typeof booking;
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 html = await renderBookingConfirmedHTML({
name: updated.customerName,
date: updated.appointmentDate,
time: updated.appointmentTime,
cancellationUrl: generateUrl(`/booking/${(await queryClient.cancellation.createToken({ bookingId: updated.id })).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) {
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/${(await queryClient.cancellation.createToken({ bookingId: booking.id })).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." };
}),
};

View File

@@ -11,7 +11,12 @@ const BookingAccessTokenSchema = z.object({
token: z.string(),
expiresAt: z.string(),
createdAt: z.string(),
purpose: z.enum(["booking_access"]), // For future extensibility
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(),
});
type BookingAccessToken = z.output<typeof BookingAccessTokenSchema>;
@@ -25,8 +30,8 @@ type Booking = {
id: string;
treatmentId: string;
customerName: string;
customerEmail: string;
customerPhone: string;
customerEmail?: string;
customerPhone?: string;
appointmentDate: string;
appointmentTime: string;
notes?: string;
@@ -55,6 +60,15 @@ function formatDateGerman(dateString: string): string {
return `${day}.${month}.${year}`;
}
// Helper to invalidate all reschedule tokens for a specific booking
async function invalidateRescheduleTokensForBooking(bookingId: string): Promise<void> {
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() }))
@@ -93,7 +107,8 @@ const getBookingByToken = os
const tokens = await cancellationKV.getAllItems();
const validToken = tokens.find(t =>
t.token === input.token &&
new Date(t.expiresAt) > new Date()
new Date(t.expiresAt) > new Date() &&
t.purpose === 'booking_access'
);
if (!validToken) {
@@ -217,4 +232,161 @@ 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: BookingAccessToken = {
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<any>("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<any>("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 };
}),
};