Productionize the support, feedback and review subsystem — handoff and phased plan
This issue is the single entry point for the remaining work across the three related
systems: support (the decision tree, escalation and ticketing), feedback (the
private per-call CSAT) and reviews (the public consultant review). It exists so that
whoever picks this up does not have to re-derive the research, re-litigate settled design
questions, or rediscover the defects that three QA passes have already found.
Everything below is either cited to a file and line in this repository, to a linked issue,
or to a primary source. Where something is an inference rather than a verified fact, it
says so.
Related issues, each of which carries evidence this plan depends on:
#1265 (research findings), #1021 (org-awareness gaps), #1269 (consent breaks the
appointments route), #739 (agentic support proposal), #1135 (paid async Q&A with an
enforced SLA), #1086 (deploy previews and Sentry), #1146 (orphaned Stream review
findings), #694 (document review subsystem), #705 (pre-MVP schema freeze), #746 and #867
(the enterprise roadmap that #1021 descends from).
1. Where the subsystem stands today
PR #1195 shipped the Swiggy-style support hub: a Postgres-backed decision tree with
escalation to a human, and a SupportResolver seam that an AI resolver can occupy later.
That architecture is correct and is not up for revision; #1265 §1 records why, and the
reasoning is about operational control rather than data residency.
PR #1267 then fixed the reported chat bug. The symptom the user reported was that
"sometimes the user side or the bot side does not appear while chatting", and it turned
out to be eight distinct defects rather than one. That PR is merged.
PR #1268 is open and carries the model correction: feedback belongs to a call, whereas a
review belongs to a consultant. It is not finished, and section 4 below lists precisely
what remains.
The three rating objects are deliberately distinct, and this separation is legally load
bearing rather than merely tidy. Under FTC 16 CFR §465.1(d) a bare star rating is already
a "consumer review" even with no text attached, so allowing a private CSAT score to feed a
public average would silently convert feedback the user believed to be private into a
published review. ConsultantReview is public and feeds the consultant's score,
AppointmentFeedback is private and feeds only the organization quality aggregate, and
the platform-level Feedback object is private and concerns the product rather than any
person.
2. The models we drew from, and what we actually took
The early drafting explicitly took inspiration from Swiggy, Uber and Amazon. Two of those
three held up under research and one did not, and it is worth recording which is which so
that nobody re-imports an assumption that was never sourced.
Uber is the closest published analogue to a tap-through help flow, and it validated
three separate decisions. Their in-app self-serve tree is a blocking synchronous request
per step with the server naming the next screen, which is exactly the transport we use;
they describe pushing updates to the client as future work rather than as a defect. Their
flow is a JSON DAG, which is structurally the same object as our lib/support/flows.ts
even though ours is serialized as TypeScript. They bind CSAT to the terminal node of the
flow rather than to a ticket, which is the pattern we adopted. Their versioning gate,
should we ever need it, is Draft → Shadow → Final → Live, where promotion requires at
least sixty percent of nodes to have been hit and at least twenty-four hours in shadow.
Swiggy gave us the metric we still do not have. They publish a "bot efficacy
percentage", meaning the share of conversations resolved by the bot rather than by a
support executive. Their messaging module is websocket-first, but that is because it is a
genuine chat product with humans in the loop, so it is not a precedent for changing our
transport.
Amazon is the one that did not survive. There is no first-party published description
of Amazon.com's retail help architecture, and Amazon Connect is a contact-centre product
rather than the thing that powers the retail help pages. Any design argument that cites
"how Amazon does it" is citing third-party inference, and #1265 records this explicitly
under things not published anywhere. Please do not reintroduce it.
Airbnb contributed the node-typed tree, dividing nodes into content, contextual and
action kinds, and the rule that a ticket is created only when the wizard fails to deflect.
Their published motivation for building an authoring platform was to stop needing an
engineer for every copy change, which is the trigger that should prompt us to move the
flow from code into rows.
Practo, Urban Company and Unacademy are the India-specific teardown in #1265 §5.
Practo computes an "Appointment Experience Score" from booking telemetry such as no-shows,
wait time and cancellation rate, entirely separately from written reviews, and they publish
a score only above a threshold so that a single review cannot define a new consultant.
Urban Company runs a server-driven decision tree and states in its Terms that ratings are
an enforcement input. Unacademy issues a ticket number at the first chat contact that both
the later email and form stages consume, which is the receipt pattern we adopted as
FAM-YYYY-NNNNNN.
BetterUp and CoachHub are the right analogue for the organization-facing side, because
they are confidential one-to-one coaching sold to an employer, which is structurally what
our B2B side is. Their confidentiality line is drawn at usage metadata versus content
rather than at aggregate versus individual, and BetterUp's current Privacy Notice states
it directly: the employer may receive individually identifiable usage data such as session
counts and dates, but never individually identifiable session content, which is shared only
de-identified or aggregated. That is very close to what ADR 20 already says, and section 6
below recommends restating ADR 20 in those terms.
Fiverr and Upwork shaped the contamination filter. The safe harbour at FTC
§465.7(b)(3) covers reviews wholly unrelated to the service offered, which means a rule
that strips platform complaints out of provider reviews is permitted only if the identical
rule also strips glowing platform praise. The filter has to be sentiment-blind or it is not
lawful.
3. Verified defects, with evidence
Each item below was confirmed by reading the current code or by a live probe, not inferred
from a report. Items marked as corrected are ones where an automated QA pass over-claimed
and the narrower statement is the accurate one.
3.1 Anonymous reviews can be de-anonymized by anyone, and this is live in production
GET /api/user/reviews/[id] is public by design, takes no authentication, selects
consulteeProfile: { select: { id: true, userId: true } } unconditionally, and returns the
record directly without passing it through stripAnonymousReviewer. For a review with
isAnonymous: true this hands an unauthenticated caller the author's profile id and their
userId.
The exploit requires no guessing, because the public list endpoint already publishes review
ids. A caller fetches the list for a consultant, takes the id of any review rendered as
"Verified client", and requests it by id.
This is not a PR #1268 regression. The identical handler is present on origin/dev
with no stripAnonymousReviewer call and no reference to isAnonymous anywhere, so it is
in production now. It should be fixed in its own small pull request against dev rather
than waiting behind #1268.
3.2 Two orphaned components are failing the SonarCloud quality gate
The gate on #1268 fails on new duplicated lines at 8.8 percent against a threshold of 3
percent, while all three quality ratings are clean. The duplication comes from
components/reviews/ReviewComposer.tsx at 49.3 percent and
components/reviews/SessionReviewCard.tsx at 41.7 percent.
Only SessionReviewCard and SessionRatingRow are actually rendered, both from
components/appointments/detail/AppointmentDetailClient.tsx. ReviewComposer.tsx and
ProfileReviewComposer.tsx are imported by nothing at all, including by each other.
Deleting those two dead files is very likely the entire fix.
3.3 The rating surfaces are gated so that in-progress programs can never be rated
Both the CSAT card and the review card are gated on vm.bucket === "past", which requires
every session in the program to have finished. A three-session subscription therefore shows
no rating interface until all three sessions are over, so a user cannot rate session one
after session one.
This directly constrains the per-call design. Per-call rating rows cannot work for
in-progress programs while that gate stands, so the gate has to be reconsidered as part of
the same change rather than afterwards.
3.4 The per-call comment column is neither written nor read
AppointmentFeedback.comment exists in the schema. The API returns it, and the appointment
feedback route's own comment documents that disclosing it to the consultant is deliberate.
However hooks/useSessionFeedback.ts defines its slot type with only
slotOfAppointmentId and rating, and its mapping loop accumulates only .rating, so the
comment is read off the wire and discarded before any component sees it. The read-only
branch of SessionRatingRow renders stars alone.
The field is therefore unreachable in both directions, which makes "add the optional note"
a larger job than it first appears.
3.5 Contiguous slots are independently rateable through the API
The appointment layer groups adjacent slots into a single call through
groupSlotsIntoRuns and sessionsOfAppointment, which is the fix that closed #1061. The
feedback route does not apply that grouping, so GET .../feedback returns every slot as
separately rateable and two ratings can be written for what is physically one call.
This is not reachable by clicking today, because the UI binds only to the run anchor.
It matters because the organization aggregate averages raw rows with no session weighting,
so any future surface that exposes rateableSlotIds would silently skew it.
3.6 The ticket reference is shown on one escalation path and not the other
The platform-level flow shows the reference inline at the moment of escalation, telling the
user to quote it. The appointment-scoped drawer shows only a confirmation and the SLA line,
with no FAM- reference anywhere in the rendered output. The reference is not lost, since
it appears later under the platform requests list, so this is an inconsistency between two
surfaces rather than data loss.
3.7 Deleting a review leaves it publicly readable for up to two minutes
GET /api/user/reviews sets Cache-Control: public, s-maxage=120, stale-while-revalidate=300.
purgeReviewSurfaces calls revalidateTag and revalidatePath, which reach Next.js's own
cache but not Netlify's durable CDN cache for a route handler's own header. A probe after a
hard delete showed the deleted review still present with cache-status: "Netlify Durable"; hit.
The staleness is bounded and self-heals, but deletions are frequently privacy-motivated,
which makes a two-minute window the wrong default.
3.8 The meeting join fallback cannot succeed
app/api/meetings/[meetingId]/join/route.ts calls await call.getOrCreate(); with no
arguments, whereas lib/meeting.ts passes { data: { starts_at, custom, ... } }. Stream
requires created_by or created_by_id when a call is created with server-side
authentication, so the bare call throws whenever the Stream call does not already exist.
Corrected scope. An automated pass reported this as blocking every join on the
platform. That is too strong. getOrCreate only creates when the call is absent, and a
booking made through the normal path mints the call through lib/meeting.ts first. The
route's own comment describes this as a deliberate fallback for sessions whose
MeetingSession row exists with no Stream object, such as seed data with faker
identifiers, rows written by createDbMeetingSession, or sessions ended by maintenance
drain. The accurate statement is that the fallback path is dead on arrival, which is still
a real bug and does block fixture-driven testing.
3.9 Production advertises a deploy preview URL in its Open Graph metadata
Every production page currently serves
<meta property="og:url" content="https://deploy-preview-1195--familiarise.netlify.app">.
Corrected diagnosis. An automated pass attributed this to a misconfigured
NEXT_PUBLIC_APP_URL and concluded that production sign-in was broken by CORS. The
environment is in fact correct in Netlify's production context, where both
NEXT_PUBLIC_APP_URL and BETTER_AUTH_TRUSTED_ORIGINS are https://familiarisenow.com.
Because app/layout.tsx reads SITE_URL from that variable and root-layout metadata is
evaluated at build time, this is stale static HTML from a build that ran in a preview
context. A fresh production build should clear it. The sign-in claim was not reproduced and
should not be acted on without evidence.
3.10 Withdrawing consent takes down the appointments route
Tracked in full at #1269. A consultee with no STREAM_DATA_PROCESSING consent artifact
causes ConsentRequiredError to throw from the render path of the appointments route.
Failing closed is correct for Stream itself, but taking the page down with it is not, and
the first person to exercise a right DPDP explicitly grants would lose access to their own
bookings. The gate should scope to the Stream-dependent parts of the page.
4. What remains on the consultee-facing side
The model correction in #1268 is not finished. The review form still renders on the
appointment detail page, which contradicts the argument the pull request itself makes. The
remaining work is to move the review to the expert profile as a client island so that the
existing ISR caching is untouched, leave the appointment page showing per-call stars plus a
link to the review, delete the two orphaned composers, and change the anonymity control's
label to "Don't show my name" while the public display continues to read "Verified client".
Four design decisions on this surface were put to the product owner and are recorded here
as proposed rather than settled, because the responses were flagged by the harness as not
genuine input and no code was changed on the strength of them. The recommendations were to
keep the feedback surface on the appointment page in a compact persistent form rather than
a full card, to leave a rating editable indefinitely because the data is private and
cohort-gated and the aggregate recomputes on read, to show one row per call because that is
what the @@unique([slotOfAppointmentId, userId]) constraint actually stores, and to
reveal the optional note only after a rating has been given. These need confirming before
implementation.
5. Phased implementation plan
Phase 0 — Security and unblocking, shipped against dev on its own
The de-anonymization leak in §3.1 is live and should not wait for anything else. Route the
by-id response through stripAnonymousReviewer, or gate the consulteeProfile relation on
!isAnonymous, and add a regression test that asserts an unauthenticated fetch of an
anonymous review exposes neither consulteeProfileId nor consulteeProfile.userId.
Delete the two orphaned composers identified in §3.2 so the quality gate on #1268 stops
failing, and fix the bare getOrCreate in §3.8 so that fixture-driven and
maintenance-drained sessions can be joined at all.
Phase 1 — Finish the consultee model correction
Complete the work described in section 4, and resolve the bucket === "past" gate from
§3.3 in the same change, because per-call rating rows are incoherent without it. Thread
comment through useSessionFeedback and render it in the read-only branch so the column
in §3.4 stops being unreachable, and apply groupSlotsIntoRuns in the feedback route to
close §3.5.
Phase 2 — Build the capture moment
No rating surface produces data if nothing asks for a rating, which is why the review
corpus is currently empty. The recommendation is a post-call prompt showing stars, then an
optional note, then the public review the first time only, and stopping after two
dismissals.
It should be rendered on the dashboard the user lands on, not by gating the meeting
room's redirect. cleanupAndNavigate releases the media tracks and then navigates, and
app/meetings/[id]/page.tsx tears the call down on unmount, so holding a user on a live
route in order to show a sheet risks leaking a camera or stranding someone in a call. The
dashboard placement captures the same moment psychologically, carries none of that risk,
and works for every exit path including the host ending the call and the tab closing.
Phase 3 — Operational correctness
Persist enough per turn to answer what fraction of threads resolve without escalation,
which is Swiggy's bot efficacy number and the one metric §6 of #1265 identifies as missing.
SupportFlowOutcome.organizationId is already written on every terminal turn and
deflectionSince already accepts an organization filter, but that function has no
production caller, so nothing renders it. Pair the deflection number with a re-contact
signal, because deflection alone scores a user who gave up as a success.
Bring the ticket reference to parity across both escalation surfaces per §3.6, close the
CDN purge gap in §3.7, and close the three silent notification paths where a user's reply
into an escalated thread, a re-escalation onto an already-linked ticket, and the thread
PATCH each notify nobody.
Phase 4 — Organization scoping, as a separate pull request
This is deliberately last and deliberately separate, because the current pull requests are
already long. Section 6 sets out the scope.
Phase 5 — Explicitly deferred
The retry idempotency key is deferred because it requires a fourth production schema push.
Moving the flow definition from code into rows is deferred until ops actually want to edit
copy without an engineer, which is the trigger both Airbnb and Uber name. The agentic
resolver in #739 stays behind the existing SupportResolver seam and is not in scope here.
Organization-scoped notifications have no existing entity to extend and remain deferred as
#1021 already records.
6. Organization scoping — what exists, and what should be built
An audit of the three subsystems found the organization story to be deliberately partial,
and thinner than the documentation implies.
Support is organization-aware on the write side and metadata-only and read-only on the read
side, so an organization can raise a concern but cannot triage one. Feedback is
organization-aware on write, but its entire read surface is four scalars covering an
all-time average, an all-time count, a thirty-day average and a thirty-day count, with no
per-consultant, per-team or per-member breakdown anywhere. Reviews have no organization
linkage at all: ConsultantReview has no organization column, no relation, no filter and
no read surface.
Two columns are written faithfully and read by nothing. SupportTicket.organizationId is
populated by three separate paths, yet the staff queue never filters or returns it, so
#1021's stated goal of routing enterprise tickets to the organization is written but not
realized. SupportFlowOutcome.organizationId is populated on every terminal turn and has
no production reader. Both have supporting indexes that currently serve no query.
There is also a live user-facing defect. The seven consultee flows in lib/support/flows.ts
gate on session stage and on isProvider, and never on whether the caller is an
organization operator. Since an operator has isProvider: false, flowsForContext offers
them chips for no-show, reschedule, payment status, recording access, quality complaint,
technical and documents, and the route then returns 403 for all seven because
ORG_PARTY_CATEGORIES permits only two. The security invariant holds and nothing leaks, but
an operator sees a row of buttons that cannot work. The sheet cannot compensate, because its
isOrgContext prop is destructured into an unused variable.
The recommended scope for Phase 4 is to fix those defects first, meaning the dead intent
chips, the category filter the API supports but the UI never sends, the pagination the UI
requests and then discards, and surfacing the organization attribution in the staff queue so
tickets can actually be routed. After that, add a per-consultant quality rollup over the
organization's own sessions, aggregated and cohort-gated, because that answers the question
an enterprise buyer actually has, which is whether to keep buying from a given expert. That
sits on the metadata side of the line ADR 20 draws, and BetterUp segments its dashboard the
same way.
Two smaller recommendations follow from the research. ADR 20 should be restated in terms of
usage metadata versus content rather than aggregate versus individual, since that is both
more defensible and closer to what the code already does. The cohort threshold should also
suppress the response count and not merely the average, because at a cohort of one the
organization currently learns that a rating exists even though it cannot see its value.
Finally, ConsultantReview should keep having no organization linkage, and this is now
externally validated rather than merely convenient. Across more than twenty platforms
surveyed, no platform suppresses public reviews because a booking was employer-sponsored.
Platforms either publish reviews for everyone, as Catalant does through opt-in public expert
profiles, or for nobody, as BetterUp, CoachHub, Toptal and Andela do by matching internally
instead. #1021 §3's instruction not to overload the public review object was correct.
#1021 is badly stale and should be rewritten in place rather than closed and superseded.
It asks for a SupportTicket.organizationId column that now exists, an organization
dashboard page that now exists, and an organization list route that was built for threads
rather than for tickets. It is referenced from three code comments, at
prisma/schema.prisma:270-272 and at app/api/user/support-tickets/route.ts:224 and
:238, so superseding it would orphan those references.
7. How to verify work on this subsystem
Do not run next dev or next build locally, because both exhaust available memory on the
development machine. Behaviour is verified on the Netlify deploy preview instead.
Run npx jest on the branch and also on the pristine baseline, then diff the two failure
sets, because a raw pass or fail count hides pre-existing failures. Run npx tsc --noEmit
with the incremental cache cleared, since a warm .tsbuildinfo masks errors. Run
npx eslint and treat warnings as blocking, because SonarCloud fails the pull request on
an unused variable even though ESLint classifies it as a warning.
Two facts about the environment are worth knowing before testing. Production and the
development environment share a single database, so every schema operation is a production
change. Browser sign-in on a deploy preview was previously believed impossible because
BETTER_AUTH_TRUSTED_ORIGINS names the production URL in every context, but a QA pass on
2026-08-30 signed in normally through the form on a preview and browsed authenticated pages,
so that assumption is stale and should be re-tested rather than worked around.
8. Open decisions that block implementation
The following are genuinely undecided and each one changes what gets built. They are listed
so that the next session does not assume a default.
The first is whether the post-call prompt is built at all and, if so, whether it renders on
the dashboard as recommended or in the meeting room. The second is the four appointment-page
questions summarized in section 4, covering whether the feedback surface persists, how long
a rating stays editable, whether a multi-call plan shows one row per call, and where the
optional note is written. The third is how far Phase 4 should go, ranging from defects only
through to the full scope of #1021. The fourth is whether an organization sees quality broken
out per consultant, which the research supports but which has not been confirmed.
Productionize the support, feedback and review subsystem — handoff and phased plan
This issue is the single entry point for the remaining work across the three related
systems: support (the decision tree, escalation and ticketing), feedback (the
private per-call CSAT) and reviews (the public consultant review). It exists so that
whoever picks this up does not have to re-derive the research, re-litigate settled design
questions, or rediscover the defects that three QA passes have already found.
Everything below is either cited to a file and line in this repository, to a linked issue,
or to a primary source. Where something is an inference rather than a verified fact, it
says so.
Related issues, each of which carries evidence this plan depends on:
#1265 (research findings), #1021 (org-awareness gaps), #1269 (consent breaks the
appointments route), #739 (agentic support proposal), #1135 (paid async Q&A with an
enforced SLA), #1086 (deploy previews and Sentry), #1146 (orphaned Stream review
findings), #694 (document review subsystem), #705 (pre-MVP schema freeze), #746 and #867
(the enterprise roadmap that #1021 descends from).
1. Where the subsystem stands today
PR #1195 shipped the Swiggy-style support hub: a Postgres-backed decision tree with
escalation to a human, and a
SupportResolverseam that an AI resolver can occupy later.That architecture is correct and is not up for revision; #1265 §1 records why, and the
reasoning is about operational control rather than data residency.
PR #1267 then fixed the reported chat bug. The symptom the user reported was that
"sometimes the user side or the bot side does not appear while chatting", and it turned
out to be eight distinct defects rather than one. That PR is merged.
PR #1268 is open and carries the model correction: feedback belongs to a call, whereas a
review belongs to a consultant. It is not finished, and section 4 below lists precisely
what remains.
The three rating objects are deliberately distinct, and this separation is legally load
bearing rather than merely tidy. Under FTC 16 CFR §465.1(d) a bare star rating is already
a "consumer review" even with no text attached, so allowing a private CSAT score to feed a
public average would silently convert feedback the user believed to be private into a
published review.
ConsultantReviewis public and feeds the consultant's score,AppointmentFeedbackis private and feeds only the organization quality aggregate, andthe platform-level
Feedbackobject is private and concerns the product rather than anyperson.
2. The models we drew from, and what we actually took
The early drafting explicitly took inspiration from Swiggy, Uber and Amazon. Two of those
three held up under research and one did not, and it is worth recording which is which so
that nobody re-imports an assumption that was never sourced.
Uber is the closest published analogue to a tap-through help flow, and it validated
three separate decisions. Their in-app self-serve tree is a blocking synchronous request
per step with the server naming the next screen, which is exactly the transport we use;
they describe pushing updates to the client as future work rather than as a defect. Their
flow is a JSON DAG, which is structurally the same object as our
lib/support/flows.tseven though ours is serialized as TypeScript. They bind CSAT to the terminal node of the
flow rather than to a ticket, which is the pattern we adopted. Their versioning gate,
should we ever need it, is
Draft → Shadow → Final → Live, where promotion requires atleast sixty percent of nodes to have been hit and at least twenty-four hours in shadow.
Swiggy gave us the metric we still do not have. They publish a "bot efficacy
percentage", meaning the share of conversations resolved by the bot rather than by a
support executive. Their messaging module is websocket-first, but that is because it is a
genuine chat product with humans in the loop, so it is not a precedent for changing our
transport.
Amazon is the one that did not survive. There is no first-party published description
of Amazon.com's retail help architecture, and Amazon Connect is a contact-centre product
rather than the thing that powers the retail help pages. Any design argument that cites
"how Amazon does it" is citing third-party inference, and #1265 records this explicitly
under things not published anywhere. Please do not reintroduce it.
Airbnb contributed the node-typed tree, dividing nodes into content, contextual and
action kinds, and the rule that a ticket is created only when the wizard fails to deflect.
Their published motivation for building an authoring platform was to stop needing an
engineer for every copy change, which is the trigger that should prompt us to move the
flow from code into rows.
Practo, Urban Company and Unacademy are the India-specific teardown in #1265 §5.
Practo computes an "Appointment Experience Score" from booking telemetry such as no-shows,
wait time and cancellation rate, entirely separately from written reviews, and they publish
a score only above a threshold so that a single review cannot define a new consultant.
Urban Company runs a server-driven decision tree and states in its Terms that ratings are
an enforcement input. Unacademy issues a ticket number at the first chat contact that both
the later email and form stages consume, which is the receipt pattern we adopted as
FAM-YYYY-NNNNNN.BetterUp and CoachHub are the right analogue for the organization-facing side, because
they are confidential one-to-one coaching sold to an employer, which is structurally what
our B2B side is. Their confidentiality line is drawn at usage metadata versus content
rather than at aggregate versus individual, and BetterUp's current Privacy Notice states
it directly: the employer may receive individually identifiable usage data such as session
counts and dates, but never individually identifiable session content, which is shared only
de-identified or aggregated. That is very close to what ADR 20 already says, and section 6
below recommends restating ADR 20 in those terms.
Fiverr and Upwork shaped the contamination filter. The safe harbour at FTC
§465.7(b)(3) covers reviews wholly unrelated to the service offered, which means a rule
that strips platform complaints out of provider reviews is permitted only if the identical
rule also strips glowing platform praise. The filter has to be sentiment-blind or it is not
lawful.
3. Verified defects, with evidence
Each item below was confirmed by reading the current code or by a live probe, not inferred
from a report. Items marked as corrected are ones where an automated QA pass over-claimed
and the narrower statement is the accurate one.
3.1 Anonymous reviews can be de-anonymized by anyone, and this is live in production
GET /api/user/reviews/[id]is public by design, takes no authentication, selectsconsulteeProfile: { select: { id: true, userId: true } }unconditionally, and returns therecord directly without passing it through
stripAnonymousReviewer. For a review withisAnonymous: truethis hands an unauthenticated caller the author's profile id and theiruserId.The exploit requires no guessing, because the public list endpoint already publishes review
ids. A caller fetches the list for a consultant, takes the id of any review rendered as
"Verified client", and requests it by id.
This is not a PR #1268 regression. The identical handler is present on
origin/devwith no
stripAnonymousReviewercall and no reference toisAnonymousanywhere, so it isin production now. It should be fixed in its own small pull request against
devratherthan waiting behind #1268.
3.2 Two orphaned components are failing the SonarCloud quality gate
The gate on #1268 fails on new duplicated lines at 8.8 percent against a threshold of 3
percent, while all three quality ratings are clean. The duplication comes from
components/reviews/ReviewComposer.tsxat 49.3 percent andcomponents/reviews/SessionReviewCard.tsxat 41.7 percent.Only
SessionReviewCardandSessionRatingRoware actually rendered, both fromcomponents/appointments/detail/AppointmentDetailClient.tsx.ReviewComposer.tsxandProfileReviewComposer.tsxare imported by nothing at all, including by each other.Deleting those two dead files is very likely the entire fix.
3.3 The rating surfaces are gated so that in-progress programs can never be rated
Both the CSAT card and the review card are gated on
vm.bucket === "past", which requiresevery session in the program to have finished. A three-session subscription therefore shows
no rating interface until all three sessions are over, so a user cannot rate session one
after session one.
This directly constrains the per-call design. Per-call rating rows cannot work for
in-progress programs while that gate stands, so the gate has to be reconsidered as part of
the same change rather than afterwards.
3.4 The per-call comment column is neither written nor read
AppointmentFeedback.commentexists in the schema. The API returns it, and the appointmentfeedback route's own comment documents that disclosing it to the consultant is deliberate.
However
hooks/useSessionFeedback.tsdefines its slot type with onlyslotOfAppointmentIdandrating, and its mapping loop accumulates only.rating, so thecomment is read off the wire and discarded before any component sees it. The read-only
branch of
SessionRatingRowrenders stars alone.The field is therefore unreachable in both directions, which makes "add the optional note"
a larger job than it first appears.
3.5 Contiguous slots are independently rateable through the API
The appointment layer groups adjacent slots into a single call through
groupSlotsIntoRunsandsessionsOfAppointment, which is the fix that closed #1061. Thefeedback route does not apply that grouping, so
GET .../feedbackreturns every slot asseparately rateable and two ratings can be written for what is physically one call.
This is not reachable by clicking today, because the UI binds only to the run anchor.
It matters because the organization aggregate averages raw rows with no session weighting,
so any future surface that exposes
rateableSlotIdswould silently skew it.3.6 The ticket reference is shown on one escalation path and not the other
The platform-level flow shows the reference inline at the moment of escalation, telling the
user to quote it. The appointment-scoped drawer shows only a confirmation and the SLA line,
with no
FAM-reference anywhere in the rendered output. The reference is not lost, sinceit appears later under the platform requests list, so this is an inconsistency between two
surfaces rather than data loss.
3.7 Deleting a review leaves it publicly readable for up to two minutes
GET /api/user/reviewssetsCache-Control: public, s-maxage=120, stale-while-revalidate=300.purgeReviewSurfacescallsrevalidateTagandrevalidatePath, which reach Next.js's owncache but not Netlify's durable CDN cache for a route handler's own header. A probe after a
hard delete showed the deleted review still present with
cache-status: "Netlify Durable"; hit.The staleness is bounded and self-heals, but deletions are frequently privacy-motivated,
which makes a two-minute window the wrong default.
3.8 The meeting join fallback cannot succeed
app/api/meetings/[meetingId]/join/route.tscallsawait call.getOrCreate();with noarguments, whereas
lib/meeting.tspasses{ data: { starts_at, custom, ... } }. Streamrequires
created_byorcreated_by_idwhen a call is created with server-sideauthentication, so the bare call throws whenever the Stream call does not already exist.
Corrected scope. An automated pass reported this as blocking every join on the
platform. That is too strong.
getOrCreateonly creates when the call is absent, and abooking made through the normal path mints the call through
lib/meeting.tsfirst. Theroute's own comment describes this as a deliberate fallback for sessions whose
MeetingSessionrow exists with no Stream object, such as seed data with fakeridentifiers, rows written by
createDbMeetingSession, or sessions ended by maintenancedrain. The accurate statement is that the fallback path is dead on arrival, which is still
a real bug and does block fixture-driven testing.
3.9 Production advertises a deploy preview URL in its Open Graph metadata
Every production page currently serves
<meta property="og:url" content="https://deploy-preview-1195--familiarise.netlify.app">.Corrected diagnosis. An automated pass attributed this to a misconfigured
NEXT_PUBLIC_APP_URLand concluded that production sign-in was broken by CORS. Theenvironment is in fact correct in Netlify's production context, where both
NEXT_PUBLIC_APP_URLandBETTER_AUTH_TRUSTED_ORIGINSarehttps://familiarisenow.com.Because
app/layout.tsxreadsSITE_URLfrom that variable and root-layout metadata isevaluated at build time, this is stale static HTML from a build that ran in a preview
context. A fresh production build should clear it. The sign-in claim was not reproduced and
should not be acted on without evidence.
3.10 Withdrawing consent takes down the appointments route
Tracked in full at #1269. A consultee with no
STREAM_DATA_PROCESSINGconsent artifactcauses
ConsentRequiredErrorto throw from the render path of the appointments route.Failing closed is correct for Stream itself, but taking the page down with it is not, and
the first person to exercise a right DPDP explicitly grants would lose access to their own
bookings. The gate should scope to the Stream-dependent parts of the page.
4. What remains on the consultee-facing side
The model correction in #1268 is not finished. The review form still renders on the
appointment detail page, which contradicts the argument the pull request itself makes. The
remaining work is to move the review to the expert profile as a client island so that the
existing ISR caching is untouched, leave the appointment page showing per-call stars plus a
link to the review, delete the two orphaned composers, and change the anonymity control's
label to "Don't show my name" while the public display continues to read "Verified client".
Four design decisions on this surface were put to the product owner and are recorded here
as proposed rather than settled, because the responses were flagged by the harness as not
genuine input and no code was changed on the strength of them. The recommendations were to
keep the feedback surface on the appointment page in a compact persistent form rather than
a full card, to leave a rating editable indefinitely because the data is private and
cohort-gated and the aggregate recomputes on read, to show one row per call because that is
what the
@@unique([slotOfAppointmentId, userId])constraint actually stores, and toreveal the optional note only after a rating has been given. These need confirming before
implementation.
5. Phased implementation plan
Phase 0 — Security and unblocking, shipped against
devon its ownThe de-anonymization leak in §3.1 is live and should not wait for anything else. Route the
by-id response through
stripAnonymousReviewer, or gate theconsulteeProfilerelation on!isAnonymous, and add a regression test that asserts an unauthenticated fetch of ananonymous review exposes neither
consulteeProfileIdnorconsulteeProfile.userId.Delete the two orphaned composers identified in §3.2 so the quality gate on #1268 stops
failing, and fix the bare
getOrCreatein §3.8 so that fixture-driven andmaintenance-drained sessions can be joined at all.
Phase 1 — Finish the consultee model correction
Complete the work described in section 4, and resolve the
bucket === "past"gate from§3.3 in the same change, because per-call rating rows are incoherent without it. Thread
commentthroughuseSessionFeedbackand render it in the read-only branch so the columnin §3.4 stops being unreachable, and apply
groupSlotsIntoRunsin the feedback route toclose §3.5.
Phase 2 — Build the capture moment
No rating surface produces data if nothing asks for a rating, which is why the review
corpus is currently empty. The recommendation is a post-call prompt showing stars, then an
optional note, then the public review the first time only, and stopping after two
dismissals.
It should be rendered on the dashboard the user lands on, not by gating the meeting
room's redirect.
cleanupAndNavigatereleases the media tracks and then navigates, andapp/meetings/[id]/page.tsxtears the call down on unmount, so holding a user on a liveroute in order to show a sheet risks leaking a camera or stranding someone in a call. The
dashboard placement captures the same moment psychologically, carries none of that risk,
and works for every exit path including the host ending the call and the tab closing.
Phase 3 — Operational correctness
Persist enough per turn to answer what fraction of threads resolve without escalation,
which is Swiggy's bot efficacy number and the one metric §6 of #1265 identifies as missing.
SupportFlowOutcome.organizationIdis already written on every terminal turn anddeflectionSincealready accepts an organization filter, but that function has noproduction caller, so nothing renders it. Pair the deflection number with a re-contact
signal, because deflection alone scores a user who gave up as a success.
Bring the ticket reference to parity across both escalation surfaces per §3.6, close the
CDN purge gap in §3.7, and close the three silent notification paths where a user's reply
into an escalated thread, a re-escalation onto an already-linked ticket, and the thread
PATCH each notify nobody.
Phase 4 — Organization scoping, as a separate pull request
This is deliberately last and deliberately separate, because the current pull requests are
already long. Section 6 sets out the scope.
Phase 5 — Explicitly deferred
The retry idempotency key is deferred because it requires a fourth production schema push.
Moving the flow definition from code into rows is deferred until ops actually want to edit
copy without an engineer, which is the trigger both Airbnb and Uber name. The agentic
resolver in #739 stays behind the existing
SupportResolverseam and is not in scope here.Organization-scoped notifications have no existing entity to extend and remain deferred as
#1021 already records.
6. Organization scoping — what exists, and what should be built
An audit of the three subsystems found the organization story to be deliberately partial,
and thinner than the documentation implies.
Support is organization-aware on the write side and metadata-only and read-only on the read
side, so an organization can raise a concern but cannot triage one. Feedback is
organization-aware on write, but its entire read surface is four scalars covering an
all-time average, an all-time count, a thirty-day average and a thirty-day count, with no
per-consultant, per-team or per-member breakdown anywhere. Reviews have no organization
linkage at all:
ConsultantReviewhas no organization column, no relation, no filter andno read surface.
Two columns are written faithfully and read by nothing.
SupportTicket.organizationIdispopulated by three separate paths, yet the staff queue never filters or returns it, so
#1021's stated goal of routing enterprise tickets to the organization is written but not
realized.
SupportFlowOutcome.organizationIdis populated on every terminal turn and hasno production reader. Both have supporting indexes that currently serve no query.
There is also a live user-facing defect. The seven consultee flows in
lib/support/flows.tsgate on session stage and on
isProvider, and never on whether the caller is anorganization operator. Since an operator has
isProvider: false,flowsForContextoffersthem chips for no-show, reschedule, payment status, recording access, quality complaint,
technical and documents, and the route then returns 403 for all seven because
ORG_PARTY_CATEGORIESpermits only two. The security invariant holds and nothing leaks, butan operator sees a row of buttons that cannot work. The sheet cannot compensate, because its
isOrgContextprop is destructured into an unused variable.The recommended scope for Phase 4 is to fix those defects first, meaning the dead intent
chips, the category filter the API supports but the UI never sends, the pagination the UI
requests and then discards, and surfacing the organization attribution in the staff queue so
tickets can actually be routed. After that, add a per-consultant quality rollup over the
organization's own sessions, aggregated and cohort-gated, because that answers the question
an enterprise buyer actually has, which is whether to keep buying from a given expert. That
sits on the metadata side of the line ADR 20 draws, and BetterUp segments its dashboard the
same way.
Two smaller recommendations follow from the research. ADR 20 should be restated in terms of
usage metadata versus content rather than aggregate versus individual, since that is both
more defensible and closer to what the code already does. The cohort threshold should also
suppress the response count and not merely the average, because at a cohort of one the
organization currently learns that a rating exists even though it cannot see its value.
Finally,
ConsultantReviewshould keep having no organization linkage, and this is nowexternally validated rather than merely convenient. Across more than twenty platforms
surveyed, no platform suppresses public reviews because a booking was employer-sponsored.
Platforms either publish reviews for everyone, as Catalant does through opt-in public expert
profiles, or for nobody, as BetterUp, CoachHub, Toptal and Andela do by matching internally
instead. #1021 §3's instruction not to overload the public review object was correct.
#1021 is badly stale and should be rewritten in place rather than closed and superseded.
It asks for a
SupportTicket.organizationIdcolumn that now exists, an organizationdashboard page that now exists, and an organization list route that was built for threads
rather than for tickets. It is referenced from three code comments, at
prisma/schema.prisma:270-272and atapp/api/user/support-tickets/route.ts:224and:238, so superseding it would orphan those references.7. How to verify work on this subsystem
Do not run
next devornext buildlocally, because both exhaust available memory on thedevelopment machine. Behaviour is verified on the Netlify deploy preview instead.
Run
npx jeston the branch and also on the pristine baseline, then diff the two failuresets, because a raw pass or fail count hides pre-existing failures. Run
npx tsc --noEmitwith the incremental cache cleared, since a warm
.tsbuildinfomasks errors. Runnpx eslintand treat warnings as blocking, because SonarCloud fails the pull request onan unused variable even though ESLint classifies it as a warning.
Two facts about the environment are worth knowing before testing. Production and the
development environment share a single database, so every schema operation is a production
change. Browser sign-in on a deploy preview was previously believed impossible because
BETTER_AUTH_TRUSTED_ORIGINSnames the production URL in every context, but a QA pass on2026-08-30 signed in normally through the form on a preview and browsed authenticated pages,
so that assumption is stale and should be re-tested rather than worked around.
8. Open decisions that block implementation
The following are genuinely undecided and each one changes what gets built. They are listed
so that the next session does not assume a default.
The first is whether the post-call prompt is built at all and, if so, whether it renders on
the dashboard as recommended or in the meeting room. The second is the four appointment-page
questions summarized in section 4, covering whether the feedback surface persists, how long
a rating stays editable, whether a multi-call plan shows one row per call, and where the
optional note is written. The third is how far Phase 4 should go, ranging from defects only
through to the full scope of #1021. The fourth is whether an organization sees quality broken
out per consultant, which the research supports but which has not been confirmed.