From a2f01f9d80ba384f4ced7794c69f20cdfdf87746 Mon Sep 17 00:00:00 2001 From: Kaustav Ghosh <44238657+teetangh@users.noreply.github.com> Date: Sat, 29 Aug 2026 13:24:29 +0530 Subject: [PATCH 01/10] feat(feedback): feedback belongs to a call, a review belongs to a consultant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two corrections to the model #1267 shipped, made after looking at the screen it produced: it asked for two near-identical star ratings stacked on top of each other, and neither was attached to the thing the user thought they were rating. **Feedback moves to the CALL.** An appointment is not a session — a subscription booking holds up to 24 of them and a class holds about four — so one rating per appointment meant a single score for a three-month package, arriving months after the sessions it described. Urban Company rates every job, and a job is one visit; this is the same unit. `AppointmentFeedback` gains `slotOfAppointmentId`, the unique moves to (slot, user), and `appointmentId` stays denormalized because the org quality aggregate and every booking-scoped read filter on it. The POST now verifies the slot actually belongs to the booking, or a caller holding only an appointment id could rate a call from someone else's session run. **Reviews move back to the CONSULTANT.** Practo allows one feedback per patient per doctor however many visits they make, and that is the right shape: a reader wants one considered opinion of a person, not four near-identical ones from the same client. `appointmentId` stays on the row as PROVENANCE — which session prompted or last updated the review — which is what still makes "verified booking" a fact rather than a claim. Group weighting is untouched: two hundred webinar attendees are two hundred distinct consultees either way, and `ratingUnitId` still collapses them to one data point. `isAnonymous` rides along. Authenticity never depends on the displayed name here — the review is welded to a paid, attended session — so the name is a privacy choice, and the Airbnb experiment in our own research found the fear of being identified measurably suppresses honest criticism. **The support drawer stops pretending.** Per-bubble USER/BOT captions are gone — nobody labels their own messages, and "BOT" sat directly under a header claiming you were connected with the support team. The transcript is bottom-aligned against the composer instead of stranded at the top of an empty panel, and the hand-off to a human is finally marked, including the case where the thread has escalated but no staff reply has landed yet, which is what the reported screenshot showed. **And a regression of mine, fixed.** `persistHumanTurn` had become an interactive transaction with six sequential round trips. With PG_POOL_MAX=1 serialising everything onto one connection, and a cold instance stretching 400ms of idle await into twenty-plus seconds, that blew Prisma's 5s default and reached the user as "something went wrong" — the toast on an escalated thread. It is two writes again: the CAS and the sequence allocation are one statement, and the SLA clock and staff notification happen after the commit, because neither is an invariant of the message being stored. It also takes the ALLOCATION_TX budget the other two transactions already had. Part of #705 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Fb4vbRYxJH7wxc4tjhm6XJ --- .../payments/idempotency-minting.test.ts | 4 +- .../[appointmentId]/feedback/route.ts | 69 ++++- components/support/PlatformSupportSheet.tsx | 246 +++++++++--------- components/support/SupportThreadSheet.tsx | 183 +++++++------ lib/support/service.ts | 127 +++++---- prisma/schema.prisma | 59 +++-- prisma/sql/check-constraints.sql | 16 -- 7 files changed, 401 insertions(+), 303 deletions(-) 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/app/api/appointments/[appointmentId]/feedback/route.ts b/app/api/appointments/[appointmentId]/feedback/route.ts index a791b64e3..585c20dbf 100644 --- a/app/api/appointments/[appointmentId]/feedback/route.ts +++ b/app/api/appointments/[appointmentId]/feedback/route.ts @@ -1,8 +1,11 @@ /** - * #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"; @@ -10,10 +13,7 @@ import { z } from "zod"; import prisma from "@/lib/prisma"; import { appointmentRaterRole } from "@/lib/data/appointment-detail"; 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 +24,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,10 +40,23 @@ 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 } }, + // Every call of this booking the caller has rated, so the detail page can + // show a per-session breakdown rather than one number for the package. + const feedback = await prisma.appointmentFeedback.findMany({ + where: { appointmentId, userId: auth.userId }, + select: { + id: true, + slotOfAppointmentId: true, + rating: true, + comment: true, + createdAt: true, + }, + orderBy: { createdAt: "asc" }, }); return NextResponse.json({ data: feedback }); } catch (cause) { @@ -66,7 +81,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 +112,34 @@ 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, + deletedAt: null, + }, + select: { id: true }, + }); + if (!slot) { + return supportError({ + status: 404, + code: "NOT_FOUND", + message: "That session isn't part of this booking", + 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/components/support/PlatformSupportSheet.tsx b/components/support/PlatformSupportSheet.tsx index 4d3bc11da..814f9edc7 100644 --- a/components/support/PlatformSupportSheet.tsx +++ b/components/support/PlatformSupportSheet.tsx @@ -14,14 +14,7 @@ import { useEffect, useRef, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { - LifeBuoy, - Send, - UserRound, - Bot, - CheckCircle2, - Ticket, -} from "lucide-react"; +import { LifeBuoy, Send, CheckCircle2, Ticket } from "lucide-react"; import { Sheet, SheetContent, @@ -309,141 +302,140 @@ export function PlatformSupportSheet({ -
- {!flowId && - (catalog.isLoading ? ( -

Loading…

- ) : catalog.isError ? ( -
- {(catalog.error as Error)?.message ?? - "Couldn't load support topics."}{" "} - -
- ) : ( -
- {(catalog.data ?? []).map((f) => ( + {/* Bottom-aligned like the per-appointment drawer: a short transcript + sits against the composer rather than at the top of an empty panel. */} +
+
+ {!flowId && + (catalog.isLoading ? ( +

Loading…

+ ) : catalog.isError ? ( +
+ {(catalog.error as Error)?.message ?? + "Couldn't load support topics."}{" "} - ))} -
- ))} +
+ ) : ( +
+ {(catalog.data ?? []).map((f) => ( + + ))} +
+ ))} - {messages.map((m) => ( -
+ {messages.map((m) => (
- - {m.sender === "USER" ? ( - - ) : ( - - )} - {m.sender.toLowerCase()} - - {m.body} + {/* No per-bubble "USER"/"BOT" caption — side and colour say it. */} +
+ {m.body} +
-
- ))} + ))} - {turn.isPending && ( -
-
- {[0, 150, 300].map((delay) => ( - - ))} + {turn.isPending && ( +
+
+ {[0, 150, 300].map((delay) => ( + + ))} +
-
- )} + )} - {done?.resolved && !done.collectFeedback && ( - - Resolved - - )} + {done?.resolved && !done.collectFeedback && ( + + Resolved + + )} - {done?.collectFeedback && ( -
-