Email: Review-Link auf /review/:token umgestellt; Token-Erzeugung konsolidiert. Reviews: Client-Validation hinzugefügt. Verfügbarkeiten: Auto-Update nach Regelanlage. Galerie: Cover-Foto-Flag + Setzen im Admin, sofortige Aktualisierung nach Upload/Löschen/Reihenfolge-Änderung. Startseite: Featured-Foto = Reihenfolge 0, Seitenverhältnis beibehalten, Texte aktualisiert.

This commit is contained in:
2025-10-05 20:09:12 +02:00
parent 6d7e8eceba
commit 53aca01131
13 changed files with 1807 additions and 23 deletions

17
src/server/lib/auth.ts Normal file
View File

@@ -0,0 +1,17 @@
import { createKV } from "./create-kv.js";
type Session = { id: string; userId: string; expiresAt: string; createdAt: string };
type User = { id: string; username: string; email: string; passwordHash: string; role: "customer" | "owner"; createdAt: string };
export const sessionsKV = createKV<Session>("sessions");
export const usersKV = createKV<User>("users");
export async function assertOwner(sessionId: string): Promise<void> {
const session = await sessionsKV.getItem(sessionId);
if (!session) throw new Error("Invalid session");
if (new Date(session.expiresAt) < new Date()) throw new Error("Session expired");
const user = await usersKV.getItem(session.userId);
if (!user || user.role !== "owner") throw new Error("Forbidden");
}

View File

@@ -85,8 +85,8 @@ export async function renderBookingPendingHTML(params: { name: string; date: str
return renderBrandedEmail("Deine Terminanfrage ist eingegangen", inner);
}
export async function renderBookingConfirmedHTML(params: { name: string; date: string; time: string; cancellationUrl?: string }) {
const { name, date, time, cancellationUrl } = params;
export async function renderBookingConfirmedHTML(params: { name: string; date: string; time: string; cancellationUrl?: string; reviewUrl?: string }) {
const { name, date, time, cancellationUrl, reviewUrl } = params;
const formattedDate = formatDateGerman(date);
const domain = process.env.DOMAIN || 'localhost:5173';
const protocol = domain.includes('localhost') ? 'http' : 'https';
@@ -107,6 +107,14 @@ export async function renderBookingConfirmedHTML(params: { name: string; date: s
<a href="${cancellationUrl}" style="display: inline-block; background-color: #db2777; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: 600;">Termin ansehen & verwalten</a>
</div>
` : ''}
${reviewUrl ? `
<div style="background-color: #eff6ff; border-left: 4px solid #3b82f6; padding: 16px; margin: 20px 0; border-radius: 4px;">
<p style="margin: 0; font-weight: 600; color: #3b82f6;">⭐ Bewertung abgeben:</p>
<p style="margin: 8px 0 12px 0; color: #475569;">Nach deinem Termin würden wir uns über deine Bewertung freuen!</p>
<a href="${reviewUrl}" style="display: inline-block; background-color: #3b82f6; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: 600;">Bewertung schreiben</a>
<p style="margin: 12px 0 0 0; color: #64748b; font-size: 13px;">Du kannst deine Bewertung nach dem Termin über diesen Link abgeben.</p>
</div>
` : ''}
<div style="background-color: #f8fafc; border-left: 4px solid #3b82f6; padding: 16px; margin: 20px 0; border-radius: 4px;">
<p style="margin: 0; font-weight: 600; color: #3b82f6;">📋 Rechtliche Informationen:</p>
<p style="margin: 8px 0 12px 0; color: #475569;">Weitere Informationen findest du in unserem <a href="${legalUrl}" style="color: #3b82f6; text-decoration: underline;">Impressum und Datenschutz</a>.</p>

View File

@@ -426,7 +426,8 @@ const updateStatus = os
name: booking.customerName,
date: booking.appointmentDate,
time: booking.appointmentTime,
cancellationUrl: bookingUrl // Now points to booking status page
cancellationUrl: bookingUrl, // Now points to booking status page
reviewUrl: generateUrl(`/review/${bookingAccessToken.token}`)
});
// Get treatment information for ICS file
@@ -609,7 +610,8 @@ const createManual = os
name: input.customerName,
date: input.appointmentDate,
time: input.appointmentTime,
cancellationUrl: bookingUrl
cancellationUrl: bookingUrl,
reviewUrl: generateUrl(`/review/${bookingAccessToken.token}`)
});
await sendEmailWithAGBAndCalendar({
@@ -774,11 +776,13 @@ export const router = {
// Send confirmation to customer (no BCC to avoid duplicate admin emails)
if (updated.customerEmail) {
const bookingAccessToken = await queryClient.cancellation.createToken({ bookingId: updated.id });
const html = await renderBookingConfirmedHTML({
name: updated.customerName,
date: updated.appointmentDate,
time: updated.appointmentTime,
cancellationUrl: generateUrl(`/booking/${(await queryClient.cancellation.createToken({ bookingId: updated.id })).token}`),
cancellationUrl: generateUrl(`/booking/${bookingAccessToken.token}`),
reviewUrl: generateUrl(`/review/${bookingAccessToken.token}`),
});
await sendEmailWithAGBAndCalendar({
to: updated.customerEmail,
@@ -827,11 +831,12 @@ export const router = {
// Notify customer that original stays
if (booking.customerEmail) {
const bookingAccessToken = await queryClient.cancellation.createToken({ bookingId: booking.id });
await sendEmail({
to: booking.customerEmail,
subject: "Terminänderung abgelehnt",
text: `Du hast den Vorschlag zur Terminänderung abgelehnt. Dein ursprünglicher Termin am ${formatDateGerman(booking.appointmentDate)} um ${booking.appointmentTime} bleibt bestehen.`,
html: await renderBookingConfirmedHTML({ name: booking.customerName, date: booking.appointmentDate, time: booking.appointmentTime, cancellationUrl: generateUrl(`/booking/${(await queryClient.cancellation.createToken({ bookingId: booking.id })).token}`) }),
html: await renderBookingConfirmedHTML({ name: booking.customerName, date: booking.appointmentDate, time: booking.appointmentTime, cancellationUrl: generateUrl(`/booking/${bookingAccessToken.token}`), reviewUrl: generateUrl(`/review/${bookingAccessToken.token}`) }),
}).catch(() => {});
}

150
src/server/rpc/gallery.ts Normal file
View File

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

View File

@@ -5,6 +5,8 @@ import { router as auth } from "./auth.js";
import { router as recurringAvailability } from "./recurring-availability.js";
import { router as cancellation } from "./cancellation.js";
import { router as legal } from "./legal.js";
import { router as gallery } from "./gallery.js";
import { router as reviews } from "./reviews.js";
export const router = {
demo,
@@ -14,4 +16,6 @@ export const router = {
recurringAvailability,
cancellation,
legal,
gallery,
reviews,
};

View File

@@ -2,6 +2,7 @@ import { call, os } from "@orpc/server";
import { z } from "zod";
import { randomUUID } from "crypto";
import { createKV } from "../lib/create-kv.js";
import { assertOwner } from "../lib/auth.js";
// Datenmodelle
const RecurringRuleSchema = z.object({
@@ -35,19 +36,7 @@ const timeOffPeriodsKV = createKV<TimeOffPeriod>("timeOffPeriods");
const bookingsKV = createKV<any>("bookings");
const treatmentsKV = createKV<any>("treatments");
// Owner-Authentifizierung
type Session = { id: string; userId: string; expiresAt: string; createdAt: string };
type User = { id: string; username: string; email: string; passwordHash: string; role: "customer" | "owner"; createdAt: string };
const sessionsKV = createKV<Session>("sessions");
const usersKV = createKV<User>("users");
async function assertOwner(sessionId: string): Promise<void> {
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-Authentifizierung zentralisiert in ../lib/auth.ts
// Helper-Funktionen
function parseTime(timeStr: string): number {

294
src/server/rpc/reviews.ts Normal file
View File

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