import { NextRequest } from "next/server"; import { db } from "@/lib/db"; import crypto from "crypto"; import type { ReviewSession } from "@prisma/client"; export type TokenValidation = | { type: "ok"; session: ReviewSession } | { type: "invalid" } | { type: "requiresPassword" }; export function makeUnlockCookieValue(token: string): string { const secret = process.env.AUTH_SECRET ?? "fallback-secret"; return crypto.createHmac("sha256", secret).update(token).digest("hex"); } export async function validateReviewToken( token: string, req: NextRequest ): Promise { const session = await db.reviewSession.findUnique({ where: { token } }); if (!session || !session.isActive) return { type: "invalid" }; if (session.expiresAt && session.expiresAt < new Date()) return { type: "invalid" }; if (session.passwordHash) { const cookieName = `rsauth_${token}`; const cookieValue = req.cookies.get(cookieName)?.value; const expected = makeUnlockCookieValue(token); if (!cookieValue || cookieValue !== expected) { return { type: "requiresPassword" }; } } return { type: "ok", session }; } /** * The email a review session writes its activity under. * * A session with no email on it still needs a stable identity so its comments * group under one author and stay attributable to that link — hence the * token-derived fallback. Note what this means for ownership: everyone holding * the same review link *is* the same author. That is the intended scope, since * the link is the credential. */ export function reviewClientEmail(session: ReviewSession, token: string): string { return session.email ?? `client+${token.slice(0, 8)}@review.external`; } /** The guest User a review session posts as, creating it on first activity. */ export async function getOrCreateReviewUser(session: ReviewSession, token: string) { const email = reviewClientEmail(session, token); const existing = await db.user.findUnique({ where: { email } }); if (existing) return existing; return db.user.create({ data: { email, name: session.label ?? email.split("@")[0], role: "CLIENT", isActive: true, }, }); } /** * The guest User a review session posts as, or null if it has never posted. * * Read-only counterpart to getOrCreateReviewUser, for the paths that only need * to ask "is this theirs?" — merely opening a review link should not conjure a * User row. */ export async function findReviewUser(session: ReviewSession, token: string) { return db.user.findUnique({ where: { email: reviewClientEmail(session, token) }, select: { id: true }, }); } /** * May this review session change or remove this comment? * * Only its own. The studio's notes and those of every other review session are * read-only through a client link, and an authorless comment (its User was * deleted) belongs to nobody and so cannot be claimed. * * Shared by the listing route, which uses it to decide whether to offer the * controls, and the edit/delete route, which enforces it. */ export function ownsComment( comment: { authorId: string | null }, reviewUserId: string | null | undefined ): boolean { return !!reviewUserId && !!comment.authorId && comment.authorId === reviewUserId; }