import { call, os } from "@orpc/server"; import { z } from "zod"; 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(), }); // KV Storage const reviewsKV = createKV("reviews"); const cancellationKV = createKV("cancellation_tokens"); const bookingsKV = createKV("bookings"); // Helper Function: validateBookingToken async function validateBookingToken(token) { 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 = { 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", 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", 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 () => { 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 = 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, };