247 lines
8.9 KiB
JavaScript
247 lines
8.9 KiB
JavaScript
import { call, os } from "@orpc/server";
|
|
import { z } from "zod";
|
|
import { createKV } from "../lib/create-kv.js";
|
|
import { assertOwner, getSessionFromCookies } from "../lib/auth.js";
|
|
import { checkAdminRateLimit, getClientIP } from "../lib/rate-limiter.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({ id: z.string() }))
|
|
.handler(async ({ input, context }) => {
|
|
try {
|
|
await assertOwner(context);
|
|
// Admin Rate Limiting
|
|
const ip = getClientIP(context.req.raw.headers);
|
|
const session = await getSessionFromCookies(context);
|
|
if (session) {
|
|
const result = checkAdminRateLimit({ ip, userId: session.userId });
|
|
if (!result.allowed) {
|
|
throw new Error(`Zu viele Admin-Anfragen. Bitte versuche es in ${result.retryAfterSeconds} Sekunden erneut.`);
|
|
}
|
|
}
|
|
const review = await reviewsKV.getItem(input.id);
|
|
if (!review) {
|
|
throw new Error("Bewertung nicht gefunden");
|
|
}
|
|
const session2 = await getSessionFromCookies(context);
|
|
const updatedReview = {
|
|
...review,
|
|
status: "approved",
|
|
reviewedAt: new Date().toISOString(),
|
|
reviewedBy: session2?.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({ id: z.string() }))
|
|
.handler(async ({ input, context }) => {
|
|
try {
|
|
await assertOwner(context);
|
|
// Admin Rate Limiting
|
|
const ip = getClientIP(context.req.raw.headers);
|
|
const session = await getSessionFromCookies(context);
|
|
if (session) {
|
|
const result = checkAdminRateLimit({ ip, userId: session.userId });
|
|
if (!result.allowed) {
|
|
throw new Error(`Zu viele Admin-Anfragen. Bitte versuche es in ${result.retryAfterSeconds} Sekunden erneut.`);
|
|
}
|
|
}
|
|
const review = await reviewsKV.getItem(input.id);
|
|
if (!review) {
|
|
throw new Error("Bewertung nicht gefunden");
|
|
}
|
|
const session2 = await getSessionFromCookies(context);
|
|
const updatedReview = {
|
|
...review,
|
|
status: "rejected",
|
|
reviewedAt: new Date().toISOString(),
|
|
reviewedBy: session2?.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({ id: z.string() }))
|
|
.handler(async ({ input, context }) => {
|
|
try {
|
|
await assertOwner(context);
|
|
// Admin Rate Limiting
|
|
const ip = getClientIP(context.req.raw.headers);
|
|
const session = await getSessionFromCookies(context);
|
|
if (session) {
|
|
const result = checkAdminRateLimit({ ip, userId: session.userId });
|
|
if (!result.allowed) {
|
|
throw new Error(`Zu viele Admin-Anfragen. Bitte versuche es in ${result.retryAfterSeconds} Sekunden erneut.`);
|
|
}
|
|
}
|
|
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({
|
|
statusFilter: z.enum(["all", "pending", "approved", "rejected"]).optional().default("all"),
|
|
}))
|
|
.handler(async ({ input, context }) => {
|
|
try {
|
|
await assertOwner(context);
|
|
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({
|
|
statusFilter: z.enum(["all", "pending", "approved", "rejected"]).optional().default("all"),
|
|
}))
|
|
.handler(async function* ({ input, context, signal }) {
|
|
await assertOwner(context);
|
|
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,
|
|
};
|