diff --git a/__tests__/payments/idempotency-minting.test.ts b/__tests__/payments/idempotency-minting.test.ts index dbf4eb647..a265ca798 100644 --- a/__tests__/payments/idempotency-minting.test.ts +++ b/__tests__/payments/idempotency-minting.test.ts @@ -59,7 +59,9 @@ describe("idempotency keys are always minted (#1093 §3)", () => { // Live, and below the banner — these are the ones the old split lost. expect(names).toContain("appointment_doc_thread_version_unique"); expect(names).toContain("onboarding_draft_payload_size"); - expect(names).toContain("consultant_review_legacy_pair_key"); + // `consultant_review_legacy_pair_key` used to live here. Reviews are now one + // per (consultant, consultee), which Prisma expresses as a real @@unique, so + // the sidecar's partial index was retired rather than left shadowing it. // `IF NOT EXISTS` must not be captured as an index name. expect(names).not.toContain("IF"); }); diff --git a/__tests__/reviews/review-privacy.test.ts b/__tests__/reviews/review-privacy.test.ts new file mode 100644 index 000000000..a9c8af384 --- /dev/null +++ b/__tests__/reviews/review-privacy.test.ts @@ -0,0 +1,82 @@ +/** + * @jest-environment node + */ + +/** + * #705 — anonymity has to hold in the PAYLOAD, not in the component. + * + * `isAnonymous` is a display choice, but stripping the name only where it is + * rendered would still ship it over the wire — and the public review feed is + * CDN-cached and readable by anyone with devtools. The reviewer's protection is + * only real if the server never sends the name. + */ + +import { + stripAnonymousReviewer, + stripAnonymousReviewers, +} from "@/lib/data/review-privacy"; + +const named = { + isAnonymous: false, + rating: 5, + consulteeProfile: { + id: "consultee-profile-1", + userId: "user-1", + user: { name: "Priya S.", image: "https://x/y.png" }, + }, +}; +const anon = { ...named, isAnonymous: true }; + +describe("anonymous reviewers", () => { + it("drops the whole profile, not just the name and avatar", () => { + const out = stripAnonymousReviewer(anon); + expect(out.consulteeProfile).toBeNull(); + }); + + it("does not leak a stable id that could re-identify the reviewer", () => { + // The real hazard is CORRELATION, not the name. Review one expert under + // your name and another anonymously, and a shared consulteeProfile.id in + // both public payloads joins the two and unmasks the anonymous one. An + // opaque id stops being opaque the second time it appears. + const serialised = JSON.stringify(stripAnonymousReviewer(anon)); + expect(serialised).not.toContain("consultee-profile-1"); + expect(serialised).not.toContain("user-1"); + expect(serialised).not.toContain("Priya"); + expect(serialised).not.toContain("y.png"); + }); + + it("leaves a named review completely untouched", () => { + expect(stripAnonymousReviewer(named)).toEqual(named); + }); + + it("keeps everything that is not identifying", () => { + // The rating still has to count and the review still has to render. + expect(stripAnonymousReviewer(anon).rating).toBe(5); + expect(stripAnonymousReviewer(anon).isAnonymous).toBe(true); + }); + + it("still names a NAMED reviewer \u2014 the strip is opt-in, not blanket", () => { + const out = stripAnonymousReviewer(named); + expect(JSON.stringify(out)).toContain("Priya S."); + }); + + it("does not mutate the row it was given", () => { + const row = { + ...anon, + consulteeProfile: { ...anon.consulteeProfile }, + }; + stripAnonymousReviewer(row); + expect(row.consulteeProfile).not.toBeNull(); + }); + + it("handles a review with no consultee profile at all", () => { + const orphan = { isAnonymous: true, consulteeProfile: null }; + expect(() => stripAnonymousReviewer(orphan)).not.toThrow(); + }); + + it("strips a mixed list, one by one", () => { + const out = stripAnonymousReviewers([named, anon]); + expect(JSON.stringify(out[0])).toContain("Priya S."); + expect(out[1].consulteeProfile).toBeNull(); + }); +}); diff --git a/__tests__/support/service.test.ts b/__tests__/support/service.test.ts index d81cc6cb1..264c3891d 100644 --- a/__tests__/support/service.test.ts +++ b/__tests__/support/service.test.ts @@ -27,7 +27,11 @@ jest.mock("../../lib/prisma", () => ({ findUniqueOrThrow: jest.fn(), }, supportMessage: { create: jest.fn() }, - supportTicket: { create: jest.fn(), findUnique: jest.fn(), update: jest.fn() }, + supportTicket: { + create: jest.fn(), + findUnique: jest.fn(), + update: jest.fn(), + }, supportTicketCounter: { upsert: jest.fn() }, user: { findMany: jest.fn() }, $transaction: jest.fn(), @@ -51,7 +55,11 @@ const mockPrisma = prisma as unknown as { findUniqueOrThrow: jest.Mock; }; supportMessage: { create: jest.Mock }; - supportTicket: { create: jest.Mock; findUnique: jest.Mock; update: jest.Mock }; + supportTicket: { + create: jest.Mock; + findUnique: jest.Mock; + update: jest.Mock; + }; supportTicketCounter: { upsert: jest.Mock }; user: { findMany: jest.Mock }; $transaction: jest.Mock; @@ -111,7 +119,9 @@ beforeEach(() => { mockPrisma.appointmentSupportThread.update.mockResolvedValue({ messageSeq: 0, }); - mockPrisma.appointmentSupportThread.updateMany.mockResolvedValue({ count: 1 }); + mockPrisma.appointmentSupportThread.updateMany.mockResolvedValue({ + count: 1, + }); mockPrisma.appointmentSupportThread.findUniqueOrThrow.mockResolvedValue({ status: "ESCALATED", messageSeq: 0, @@ -125,7 +135,9 @@ beforeEach(() => { describe("runSupportTurn", () => { it("returns 404-null when the appointment is gone", async () => { mockPrisma.appointment.findUnique.mockResolvedValueOnce(null); - const r = await runSupportTurn("missing", "user1", { category: "CANCEL_REFUND" }); + const r = await runSupportTurn("missing", "user1", { + category: "CANCEL_REFUND", + }); expect(r).toBeNull(); }); @@ -133,7 +145,9 @@ describe("runSupportTurn", () => { mockPrisma.appointmentSupportThread.upsert.mockResolvedValue( threadRow({ currentNodeId: null }), ); - const r = await runSupportTurn("appt1", "user1", { category: "CANCEL_REFUND" }); + const r = await runSupportTurn("appt1", "user1", { + category: "CANCEL_REFUND", + }); expect(r?.status).toBe("IN_PROGRESS"); expect(r?.currentNodeId).toBe("start"); expect(r?.escalated).toBe(false); @@ -145,8 +159,12 @@ describe("runSupportTurn", () => { mockPrisma.appointmentSupportThread.upsert.mockResolvedValue( threadRow({ currentNodeId: "start" }), ); - const r = await runSupportTurn("appt1", "user1", { chosenOptionId: "cancel" }); - expect(r?.actions).toEqual([{ kind: "OFFER_CANCEL_REFUND", refundPct: 100 }]); + const r = await runSupportTurn("appt1", "user1", { + chosenOptionId: "cancel", + }); + expect(r?.actions).toEqual([ + { kind: "OFFER_CANCEL_REFUND", refundPct: 100 }, + ]); }); it("escalates on a human keyword: creates a SupportTicket and flips to HUMAN", async () => { @@ -166,7 +184,10 @@ describe("runSupportTurn", () => { expect(mockPrisma.supportTicket.create).toHaveBeenCalledTimes(1); expect(mockPrisma.appointmentSupportThread.update).toHaveBeenCalledWith( expect.objectContaining({ - data: expect.objectContaining({ status: "ESCALATED", activeChannel: "HUMAN" }), + data: expect.objectContaining({ + status: "ESCALATED", + activeChannel: "HUMAN", + }), }), ); }); @@ -175,7 +196,9 @@ describe("runSupportTurn", () => { mockPrisma.appointmentSupportThread.upsert.mockResolvedValue( threadRow({ activeChannel: "HUMAN", supportTicketId: "ticket-existing" }), ); - const r = await runSupportTurn("appt1", "user1", { userMessage: "any update?" }); + const r = await runSupportTurn("appt1", "user1", { + userMessage: "any update?", + }); expect(r?.activeChannel).toBe("HUMAN"); expect(r?.supportTicketId).toBe("ticket-existing"); expect(mockPrisma.supportTicket.create).not.toHaveBeenCalled(); @@ -197,7 +220,9 @@ describe("runSupportTurn", () => { mockPrisma.appointmentSupportThread.upsert.mockResolvedValue( threadRow({ category: "CANCEL_REFUND", currentNodeId: "ghost-node" }), ); - const r = await runSupportTurn("appt1", "user1", { chosenOptionId: "cancel" }); + const r = await runSupportTurn("appt1", "user1", { + chosenOptionId: "cancel", + }); expect(r?.currentNodeId).toBe("start"); // presented the CURRENT entry… expect(r?.escalated).toBe(false); // …not failed safe to a human expect(mockPrisma.supportMessage.create).toHaveBeenCalledTimes(1); @@ -207,7 +232,9 @@ describe("runSupportTurn", () => { mockPrisma.appointmentSupportThread.upsert.mockResolvedValue( threadRow({ category: "CANCEL_REFUND", currentNodeId: "start" }), ); - const r = await runSupportTurn("appt1", "user1", { chosenOptionId: "bogus" }); + const r = await runSupportTurn("appt1", "user1", { + chosenOptionId: "bogus", + }); expect(r?.currentNodeId).toBe("start"); // cursor did not move // Exactly one bubble, and it is NOT a verbatim repeat of the prompt: this // used to persist nothing at all, so a user who typed at a prompt watched @@ -240,11 +267,41 @@ describe("runSupportTurn", () => { expect(written[1].sender).toBe("BOT"); }); + it("does not scold the user for typing the word the nudge told them to type", async () => { + // "agent" matches no option, so the walk emits "I didn't catch that…" — + // which is the very copy telling them to type "agent". Escalating while + // persisting that message left the transcript contradicting itself one line + // above the hand-off. + mockPrisma.appointmentSupportThread.upsert.mockResolvedValue( + threadRow({ category: "CANCEL_REFUND", currentNodeId: "start" }), + ); + mockPrisma.supportTicket.create.mockResolvedValue({ + id: "t-kw", + title: "T", + organizationId: null, + referenceNumber: "FAM-2026-000001", + }); + const r = await runSupportTurn("appt1", "user1", { userMessage: "agent" }); + expect(r?.escalated).toBe(true); + + const bodies = mockPrisma.supportMessage.create.mock.calls.map( + (c) => c[0].data, + ); + // The user's own words are still recorded… + expect(bodies.some((b) => b.sender === "USER" && b.body === "agent")).toBe( + true, + ); + // …but nothing tells them it wasn't understood. + expect(bodies.some((b) => /didn't catch that/i.test(b.body))).toBe(false); + }); + it("clicking the active intent chip restarts the flow at its entry", async () => { mockPrisma.appointmentSupportThread.upsert.mockResolvedValue( threadRow({ category: "CANCEL_REFUND", currentNodeId: "confirm" }), ); - const r = await runSupportTurn("appt1", "user1", { category: "CANCEL_REFUND" }); + const r = await runSupportTurn("appt1", "user1", { + category: "CANCEL_REFUND", + }); expect(r?.currentNodeId).toBe("start"); expect(r?.status).toBe("IN_PROGRESS"); }); diff --git a/app/api/appointments/[appointmentId]/feedback/route.ts b/app/api/appointments/[appointmentId]/feedback/route.ts index a791b64e3..6f020ed4c 100644 --- a/app/api/appointments/[appointmentId]/feedback/route.ts +++ b/app/api/appointments/[appointmentId]/feedback/route.ts @@ -1,19 +1,20 @@ /** - * #appt-support — private per-participant CSAT for an appointment (1–5 + note), - * distinct from the public ConsultantReview. One per (appointment, user), upsert - * so re-submitting edits. Gives webinar/class attendees a feedback path they - * lacked and feeds an org-level quality signal. + * #appt-support — private per-participant CSAT for ONE VIDEO CALL (1–5 + note), + * distinct from the public ConsultantReview. Upsert, so re-submitting edits. + * + * Per call, not per appointment: an appointment is not a session. A subscription + * booking holds up to 24 of them, so one rating per appointment meant a single + * score for a three-month package, arriving months after the sessions it + * described. GET returns every call of this booking the caller has rated. */ import { NextRequest, NextResponse } from "next/server"; import { z } from "zod"; import prisma from "@/lib/prisma"; import { appointmentRaterRole } from "@/lib/data/appointment-detail"; +import { heldSlot } from "@/lib/reviews"; import { AppointmentIdParams } from "@/schemas/support"; -import { - parseRouteParams, - supportError, -} from "@/lib/api/support-http"; +import { parseRouteParams, supportError } from "@/lib/api/support-http"; import { authorizeAppointment, appointmentAuthzError, @@ -24,6 +25,8 @@ const FEEDBACK_ROUTE = "appointments.feedback"; const feedbackSchema = z.object({ rating: z.number().int().min(1).max(5), comment: z.string().trim().max(2000).optional(), + /** Which call of this booking is being rated. */ + slotId: z.string().min(1).max(64), }); export async function GET( @@ -38,12 +41,51 @@ export async function GET( try { const auth = await authorizeAppointment(appointmentId); if ("code" in auth) { - return appointmentAuthzError(auth, { route: FEEDBACK_ROUTE, appointmentId }); + return appointmentAuthzError(auth, { + route: FEEDBACK_ROUTE, + appointmentId, + }); } - const feedback = await prisma.appointmentFeedback.findUnique({ - where: { appointmentId_userId: { appointmentId, userId: auth.userId } }, + // #705 — the CONSULTANT sees the individual ratings their calls received. + // A deliberate product call: at this volume an aggregate over two ratings + // tells nobody anything, so the detail is what makes it actionable. The + // copy the rater sees says so plainly — nothing here is promised private. + // On a 1:1 booking this identifies the rater, which is exactly why it is + // disclosed rather than quietly enabled. + const asProvider = + appointmentRaterRole(auth.userId, auth.detail) === "PROVIDER"; + + // Which calls of this booking the caller may rate at all, so the timeline + // offers stars only where a rating would be accepted rather than erroring + // after the click. + const rateable = asProvider + ? [] + : await prisma.slotOfAppointment.findMany({ + where: { appointmentId, ...heldSlot(auth.userId) }, + select: { id: true }, + }); + + // Every call of this booking the caller has rated (or, for the provider, + // every attendee rating on it), so the timeline can show a per-session + // breakdown instead of one number for the package. + const feedback = await prisma.appointmentFeedback.findMany({ + where: asProvider + ? { appointmentId, raterRole: "CONSULTEE" } + : { appointmentId, userId: auth.userId }, + select: { + id: true, + slotOfAppointmentId: true, + rating: true, + comment: true, + createdAt: true, + }, + // A provider could otherwise infer a rater from ordering on a group call. + orderBy: { createdAt: "asc" }, + }); + return NextResponse.json({ + data: feedback, + rateableSlotIds: rateable.map((s) => s.id), }); - return NextResponse.json({ data: feedback }); } catch (cause) { return supportError({ status: 500, @@ -66,7 +108,10 @@ export async function POST( try { const auth = await authorizeAppointment(appointmentId); if ("code" in auth) { - return appointmentAuthzError(auth, { route: FEEDBACK_ROUTE, appointmentId }); + return appointmentAuthzError(auth, { + route: FEEDBACK_ROUTE, + appointmentId, + }); } // CSAT is a PARTICIPANT's private rating: staff/admin read access must // not become write access — a privileged non-participant's row would @@ -94,9 +139,40 @@ export async function POST( }); } + // The slot must belong to THIS appointment: without the check a caller + // could rate a call from a booking they merely have access to the id of. + const slot = await prisma.slotOfAppointment.findFirst({ + where: { + id: body.data.slotId, + appointmentId, + // You may rate a call you ATTENDED, or one nobody could have recorded + // (an offline session). A COMPLETED slot the caller never joined does + // not qualify: a no-show rating would otherwise feed the consultant's + // quality signal. `heldSlot` also excludes cancelled and rescheduled + // calls, which never happened at all. + ...heldSlot(auth.userId), + }, + select: { id: true }, + }); + if (!slot) { + return supportError({ + status: 404, + code: "NOT_FOUND", + message: + "That session isn't part of this booking, or it didn't take place", + context: { route: FEEDBACK_ROUTE, action: "save", appointmentId }, + }); + } + const feedback = await prisma.appointmentFeedback.upsert({ - where: { appointmentId_userId: { appointmentId, userId: auth.userId } }, + where: { + slotOfAppointmentId_userId: { + slotOfAppointmentId: slot.id, + userId: auth.userId, + }, + }, create: { + slotOfAppointmentId: slot.id, appointmentId, userId: auth.userId, organizationId: auth.organizationId, diff --git a/app/api/appointments/[appointmentId]/support/route.ts b/app/api/appointments/[appointmentId]/support/route.ts index 9daa994e4..3ac3656d8 100644 --- a/app/api/appointments/[appointmentId]/support/route.ts +++ b/app/api/appointments/[appointmentId]/support/route.ts @@ -57,7 +57,13 @@ export async function GET( const thread = await prisma.appointmentSupportThread.findUnique({ where: { appointmentId_userId: { appointmentId, userId: auth.userId } }, - include: { messages: { orderBy: MESSAGE_ORDER } }, + include: { + messages: { orderBy: MESSAGE_ORDER }, + // #705 — an escalated thread is ASYNCHRONOUS: nobody is composing a + // reply, and a typing indicator promised otherwise. The drawer shows + // this deadline instead, which we already committed to at intake. + supportTicket: { select: { referenceNumber: true, ackDueAt: true } }, + }, }); // #support-hub — the intents the SERVER offers for this appointment diff --git a/app/api/user/reviews/[id]/route.ts b/app/api/user/reviews/[id]/route.ts index 4259877c6..808beb323 100644 --- a/app/api/user/reviews/[id]/route.ts +++ b/app/api/user/reviews/[id]/route.ts @@ -9,7 +9,7 @@ import { checkOwnership, forbiddenResponse, } from "@/lib/auth-helpers"; -import { recomputeConsultantRating } from "@/lib/reviews"; +import { recomputeConsultantRating, ModeratedReviewError } from "@/lib/reviews"; import { purgeReviewSurfaces } from "@/lib/data/public-cache"; import { withSerializableRetry } from "@/lib/db/serializable-retry"; import { UpdateReviewSchema } from "@/schemas/feedbacks"; @@ -40,7 +40,19 @@ export async function GET( return NextResponse.json(review, { status: 200 }); } catch (error) { - Sentry.captureException(error instanceof Error ? error : new Error(String(error)), { tags: { subsystem: "auth" } }); + if (error instanceof ModeratedReviewError) { + return NextResponse.json( + { + error: + "This review was removed by our moderation team and can't be edited.", + }, + { status: 409 }, + ); + } + Sentry.captureException( + error instanceof Error ? error : new Error(String(error)), + { tags: { subsystem: "auth" } }, + ); console.error("Error getting review:", error); return NextResponse.json( { error: "Internal Server Error" }, @@ -103,11 +115,22 @@ export async function PUT( const updatedReview = await withSerializableRetry(() => prisma.$transaction( async (tx) => { + // Re-read INSIDE the transaction. The guard above ran before it + // opened, so moderation removing the review in between let the edit + // land on a row the public can no longer see, and the author was told + // it published. + const current = await tx.consultantReview.findUnique({ + where: { id: id }, + select: { deletedAt: true }, + }); + if (current?.deletedAt) throw new ModeratedReviewError(); + const updated = await tx.consultantReview.update({ where: { id: id }, data: { rating: body.rating, reviewDescription: body.reviewDescription, + isAnonymous: body.isAnonymous, }, include: { consultantProfile: { select: consultantPublicScalars }, @@ -127,7 +150,10 @@ export async function PUT( return NextResponse.json(updatedReview, { status: 200 }); } catch (error) { - Sentry.captureException(error instanceof Error ? error : new Error(String(error)), { tags: { subsystem: "auth" } }); + Sentry.captureException( + error instanceof Error ? error : new Error(String(error)), + { tags: { subsystem: "auth" } }, + ); console.error("Error updating review:", error); return NextResponse.json( { error: "Internal Server Error" }, @@ -189,7 +215,10 @@ export async function DELETE( { status: 200 }, ); } catch (error) { - Sentry.captureException(error instanceof Error ? error : new Error(String(error)), { tags: { subsystem: "auth" } }); + Sentry.captureException( + error instanceof Error ? error : new Error(String(error)), + { tags: { subsystem: "auth" } }, + ); console.error("Error deleting review:", error); return NextResponse.json( { error: "Internal Server Error" }, diff --git a/app/api/user/reviews/reviewable-sessions/route.ts b/app/api/user/reviews/reviewable-sessions/route.ts index 788407162..8c2401aaa 100644 --- a/app/api/user/reviews/reviewable-sessions/route.ts +++ b/app/api/user/reviews/reviewable-sessions/route.ts @@ -21,7 +21,11 @@ export async function GET(req: NextRequest) { try { const session = await getSession(); if (!session?.user?.id) { - return supportError({ status: 401, code: "UNAUTHORIZED", context: { route: ROUTE } }); + return supportError({ + status: 401, + code: "UNAUTHORIZED", + context: { route: ROUTE }, + }); } const consulteeProfileId = session.user.consulteeProfileId; // Not having a consultee profile is not an error — it just means there is @@ -30,6 +34,23 @@ export async function GET(req: NextRequest) { return NextResponse.json({ data: [] }); } + // #705 — the profile page asks about a CONSULTANT, not an appointment: + // "have I earned the right to review this person, and have I already?" + // Returns the most recent qualifying session, which is the provenance the + // POST records. + const consultantProfileId = req.nextUrl.searchParams.get( + "consultantProfileId", + ); + if (consultantProfileId) { + const all = await listReviewableSessions( + consulteeProfileId, + session.user.id, + ); + return NextResponse.json({ + data: all.filter((s) => s.consultantProfileId === consultantProfileId), + }); + } + const appointmentId = req.nextUrl.searchParams.get("appointmentId"); if (appointmentId) { const one = await resolveReviewableSession( diff --git a/app/api/user/reviews/route.ts b/app/api/user/reviews/route.ts index 9c59b20f5..cba5e15e9 100644 --- a/app/api/user/reviews/route.ts +++ b/app/api/user/reviews/route.ts @@ -1,6 +1,7 @@ import * as Sentry from "@sentry/nextjs"; import { NextRequest, NextResponse } from "next/server"; import prisma from "@/lib/prisma"; +import { stripAnonymousReviewers } from "@/lib/data/review-privacy"; import { consultantPublicScalars } from "@/lib/data/consultant-public"; import { Prisma } from "@prisma/client"; import { notifyNewReview } from "@/lib/novu"; @@ -10,6 +11,7 @@ import { getSession } from "@/lib/auth-server"; import { purgeReviewSurfaces } from "@/lib/data/public-cache"; import { spamLimiter, applyRateLimit } from "@/lib/rate-limit"; import { + ModeratedReviewError, recomputeConsultantRating, resolveReviewableSession, } from "@/lib/reviews"; @@ -79,7 +81,9 @@ export async function GET(req: NextRequest) { }); return NextResponse.json( - { data: reviews }, + // PUBLIC and CDN-cached: a name withheld by the reviewer must not ship + // in the payload, or the anonymity is cosmetic. + { data: stripAnonymousReviewers(reviews) }, { status: 200, headers: { @@ -88,7 +92,10 @@ export async function GET(req: NextRequest) { }, ); } catch (error) { - Sentry.captureException(error instanceof Error ? error : new Error(String(error)), { tags: { subsystem: "auth" } }); + Sentry.captureException( + error instanceof Error ? error : new Error(String(error)), + { tags: { subsystem: "auth" } }, + ); return apiError({ tag: "[Reviews.GET]", error }); } } @@ -146,47 +153,83 @@ export async function POST(req: NextRequest) { // + retry so two concurrent reviews for the same consultant can't lose-update // the recomputed average (P2034 aborts one, retry then sees the committed row). const newReview = await withSerializableRetry(() => - prisma.$transaction(async (tx) => { - const created = await tx.consultantReview.create({ - data: { - rating: validatedData.rating, - reviewDescription: validatedData.reviewDescription, - consultantProfileId: reviewable.consultantProfileId, - consulteeProfileId: sessionConsulteeProfileId, - appointmentId: reviewable.appointmentId, - // Denormalized at write time: `groupBy` can only group on this - // model's own scalars, and this is what makes a 200-seat webinar one - // data point instead of two hundred. - ratingUnitId: reviewable.ratingUnitId, - }, - include: { - // #946 allowlist — the response goes back to the consultee who wrote - // the review; a bare `include:` handed them the consultant's PAN and - // bank account. - consultantProfile: { - select: { - ...consultantPublicScalars, - user: { select: { name: true } }, + prisma.$transaction( + async (tx) => { + // A review that moderation removed cannot be edited back into existence, + // and accepting the edit silently would tell the author it was published + // when nothing changed on the page. + const existing = await tx.consultantReview.findUnique({ + where: { + consultantProfileId_consulteeProfileId: { + consultantProfileId: reviewable.consultantProfileId, + consulteeProfileId: sessionConsulteeProfileId, + }, + }, + select: { deletedAt: true }, + }); + if (existing?.deletedAt) throw new ModeratedReviewError(); + + // UPSERT, not create. There is one review per consultant per consultee, + // so a second session with the same person updates the opinion rather + // than colliding on the unique — and `appointmentId`/`ratingUnitId` move + // to the session that prompted this edit, which is what keeps the group + // weighting pointed at the most recent thing they actually attended. + const created = await tx.consultantReview.upsert({ + where: { + consultantProfileId_consulteeProfileId: { + consultantProfileId: reviewable.consultantProfileId, + consulteeProfileId: sessionConsulteeProfileId, + }, + }, + update: { + rating: validatedData.rating, + reviewDescription: validatedData.reviewDescription, + appointmentId: reviewable.appointmentId, + ratingUnitId: reviewable.ratingUnitId, + isAnonymous: validatedData.isAnonymous ?? undefined, + // A moderated-away review must not be resurrected by re-submitting. + deletedAt: undefined, + }, + create: { + rating: validatedData.rating, + reviewDescription: validatedData.reviewDescription, + consultantProfileId: reviewable.consultantProfileId, + consulteeProfileId: sessionConsulteeProfileId, + appointmentId: reviewable.appointmentId, + isAnonymous: validatedData.isAnonymous ?? false, + // Denormalized at write time: `groupBy` can only group on this + // model's own scalars, and this is what makes a 200-seat webinar one + // data point instead of two hundred. + ratingUnitId: reviewable.ratingUnitId, }, - }, - consulteeProfile: { include: { - user: { + // #946 allowlist — the response goes back to the consultee who wrote + // the review; a bare `include:` handed them the consultant's PAN and + // bank account. + consultantProfile: { select: { - name: true, - image: true, + ...consultantPublicScalars, + user: { select: { name: true } }, + }, + }, + consulteeProfile: { + include: { + user: { + select: { + name: true, + image: true, + }, + }, }, }, }, - }, - }, - }); + }); - await recomputeConsultantRating(tx, created.consultantProfileId); + await recomputeConsultantRating(tx, created.consultantProfileId); - return created; - }, - { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }, + return created; + }, + { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }, ), ); @@ -209,7 +252,16 @@ export async function POST(req: NextRequest) { return NextResponse.json(newReview, { status: 201 }); } catch (error) { - // @@unique([appointmentId, consulteeProfileId]) — one review per session. + if (error instanceof ModeratedReviewError) { + return NextResponse.json( + { + error: + "This review was removed by our moderation team and can't be edited.", + }, + { status: 409 }, + ); + } + // @@unique([consultantProfileId, consulteeProfileId]) — one per consultant. if ( error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002" @@ -219,7 +271,10 @@ export async function POST(req: NextRequest) { { status: 409 }, ); } - Sentry.captureException(error instanceof Error ? error : new Error(String(error)), { tags: { subsystem: "auth" } }); + Sentry.captureException( + error instanceof Error ? error : new Error(String(error)), + { tags: { subsystem: "auth" } }, + ); return apiError({ tag: "[Reviews.POST]", error }); } } diff --git a/app/explore/experts/[consultantId]/components/Review.tsx b/app/explore/experts/[consultantId]/components/Review.tsx index e466ebbfb..10ed404bb 100644 --- a/app/explore/experts/[consultantId]/components/Review.tsx +++ b/app/explore/experts/[consultantId]/components/Review.tsx @@ -11,7 +11,11 @@ const Review: React.FC> = ({ rating, reviewDescription, }) => { - const reviewerName = consulteeProfile?.user?.name || "Anonymous"; + // "Verified client" rather than "Anonymous": the trust here comes from the + // review being welded to a paid, attended session, and that is worth saying + // out loud when the name is withheld. The server has already removed the + // name — this is the label for that, not the mechanism. + const reviewerName = consulteeProfile?.user?.name || "Verified client"; const reviewerImage = consulteeProfile?.user?.image || null; return ( diff --git a/components/appointments/SessionTimeline.tsx b/components/appointments/SessionTimeline.tsx index 4a2d7252c..c3f46ec10 100644 --- a/components/appointments/SessionTimeline.tsx +++ b/components/appointments/SessionTimeline.tsx @@ -27,6 +27,9 @@ import { CountdownBadge } from "./CountdownBadge"; type SessionStatus = "completed" | "noRecord" | "upcoming" | "joinable"; +/** Statuses that mean the session did not take place. */ +const DEAD_SESSION = new Set(["CANCELLED", "RESCHEDULED"]); + interface SessionTimelineProps { sessions: SessionVM[]; isJoining?: boolean; @@ -40,6 +43,12 @@ interface SessionTimelineProps { * Defaults to true so multi-session plans show the full list. */ defaultExpanded?: boolean; + /** + * Rendered at the end of a past session's row. #705 uses it for the per-call + * rating, so the question sits on the session being rated instead of in a + * separate card that could only describe the whole booking. + */ + renderSessionExtra?: (session: SessionVM) => React.ReactNode; } interface SessionGroup { @@ -136,6 +145,7 @@ export function SessionTimeline({ joinWindowMs = CONSULTEE_JOIN_WINDOW_MS, className, defaultExpanded = true, + renderSessionExtra, }: SessionTimelineProps) { const [expanded, setExpanded] = useState(defaultExpanded); useEffect(() => { @@ -253,6 +263,16 @@ export function SessionTimeline({ + {/* Nothing to rate on a call that never happened. */} + {renderSessionExtra && + status !== "upcoming" && + !joinable && + !DEAD_SESSION.has( + group.slots[group.slots.length - 1].completionStatus ?? "", + ) + ? renderSessionExtra(group.slots[group.slots.length - 1]) + : null} + {joinable ? ( + + ); + } + if (isLoading || !data?.length) return null; + + // Most recent qualifying session — the provenance the write records. + const session = data[0]; + + return ( +
+ +
+ ); +} diff --git a/components/reviews/ReviewComposer.tsx b/components/reviews/ReviewComposer.tsx new file mode 100644 index 000000000..6da0c5d66 --- /dev/null +++ b/components/reviews/ReviewComposer.tsx @@ -0,0 +1,204 @@ +"use client"; + +/** + * #705 — the ONE public-review form. + * + * Shared by the profile page, the post-call sheet and nothing else. It exists + * as its own component because the alternative is three copies drifting apart, + * and this form carries constraints that must not drift: everyone is asked + * identically after every held session, with no sentiment gate and no + * incentive. The FTC preamble is explicit that soliciting only the customers + * you believe are happy is not a "generalized solicitation", and the programme + * that tested incentives found incentivised reviews MORE negative with no + * revenue effect. + * + * A review is about the CONSULTANT — one per person, editable. The session is + * provenance: it proves the review is genuine and it is what granted access to + * this form. + */ + +import { useEffect, useRef, useState } from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { Star } from "lucide-react"; +import { Textarea } from "@/components/ui/textarea"; +import { Button } from "@/components/ui/button"; +import { useToast } from "@/hooks/use-toast"; +import { throwSupportError } from "@/lib/support/error-copy"; + +export interface ExistingReview { + id: string; + rating: number; + reviewDescription: string | null; + isAnonymous?: boolean; +} + +export function ReviewComposer({ + appointmentId, + consultantName, + contextLine, + existing, + invalidateKeys, + onSaved, + compact = false, +}: Readonly<{ + /** The session that grants eligibility — provenance, not subject. */ + appointmentId: string; + consultantName: string | null; + /** Small line under the heading, e.g. the session title. */ + contextLine?: string; + existing: ExistingReview | null; + /** Query keys to refresh once the write lands. */ + invalidateKeys: readonly (readonly unknown[])[]; + onSaved?: () => void; + compact?: boolean; +}>) { + const { toast } = useToast(); + const qc = useQueryClient(); + + const [rating, setRating] = useState(0); + const [hover, setHover] = useState(0); + const [text, setText] = useState(""); + const [anonymous, setAnonymous] = useState(false); + + // Seed ONCE per review, not on every render of a fresh object. React Query + // hands back a new object each refetch, so depending on its identity meant a + // background poll reset the textarea to the saved text — and a user who had + // just cleared it submitted the old value straight back. + const seededFor = useRef(null); + const seedKey = existing?.id ?? `none:${appointmentId}`; + useEffect(() => { + if (seededFor.current === seedKey) return; + seededFor.current = seedKey; + setRating(existing?.rating ?? 0); + setText(existing?.reviewDescription ?? ""); + setAnonymous(existing?.isAnonymous ?? false); + }, [seedKey, existing]); + + const save = useMutation({ + mutationFn: async () => { + // Explicit null when cleared, never undefined: UpdateReviewSchema is + // `.partial()`, so undefined means "leave it alone" and a consultee + // deleting their written review could never actually delete it. + const body = { + rating, + reviewDescription: text.trim() || null, + isAnonymous: anonymous, + }; + const res = existing + ? await fetch(`/api/user/reviews/${existing.id}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }) + : await fetch(`/api/user/reviews`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ ...body, appointmentId }), + }); + if (!res.ok) await throwSupportError(res, "review save"); + return res.json(); + }, + onSuccess: async () => { + toast({ + title: existing ? "Review updated" : "Thanks for your review", + description: `It appears on ${consultantName ?? "the expert"}'s profile.`, + }); + // AWAITED: until this resolves `existing` is still null and the button + // still reads "Post review", so a second press would POST again and take + // a 409 off the one-review-per-consultant unique. `isPending` stays true + // for the duration, which is what keeps the button disabled. + await Promise.all( + invalidateKeys.map((queryKey) => qc.invalidateQueries({ queryKey })), + ); + onSaved?.(); + }, + onError: (e: unknown) => { + toast({ + title: "Review", + description: e instanceof Error ? e.message : "Please try again.", + variant: "destructive", + }); + }, + }); + + return ( +
+

+ {existing ? "Your review of " : "Review "} + {consultantName ?? "this expert"} +

+

+ {contextLine ? `${contextLine} · ` : ""} + Public — it appears on their profile with the date. One review per + expert; you can edit it after a later session. +

+ +
+ {[1, 2, 3, 4, 5].map((n) => ( + + ))} +
+ +