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:
@@ -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
150
src/server/rpc/gallery.ts
Normal 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,
|
||||
};
|
||||
|
||||
|
@@ -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,
|
||||
};
|
||||
|
@@ -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
294
src/server/rpc/reviews.ts
Normal 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,
|
||||
};
|
Reference in New Issue
Block a user