From c403946f98df5aeefc58e7dea2cbd722c018bd21 Mon Sep 17 00:00:00 2001 From: Kaustav Ghosh <44238657+teetangh@users.noreply.github.com> Date: Mon, 3 Aug 2026 21:05:43 +0530 Subject: [PATCH 1/3] perf: cut dashboard nav TTFB via request-memoized session and slim reads (#1101) Memoizes getSession with React.cache so nested layout gates share one Better Auth call per render, narrows the consultant home/appointments Prisma graphs, drops pending-approval nests that were fetched and discarded, and code-splits the onboarding steps, create-org wizard, EarningsTabs (recharts) and SafeUnifiedCalendar. Measured: /form/onboarding first-load JS 392 kB -> 319 kB against a control route that grew 5 kB over the same window. A first pass moved the layout gates and requireApiAuth onto the Better Auth cookie cache; that is reverted. customSession re-runs its Prisma enrichment on every getSession call regardless, so the cache skipped roughly one query in four, while lib/auth-guard.ts has no ban check of its own and relied on the forced read to catch bans, DPDP erasure and revoked sessions. requireAuth now force-reads and checks banned explicitly too. Also fixes correctness regressions the perf work introduced: Financial Summary counts derived from a truncated array, an approvals badge that contradicted NeedsYou on the same screen, Home ranking that could empty Today/Upcoming, an INNER JOIN that blanked Trending, and a StreamProvider element-type swap that remounted the whole dashboard subtree. Pinned by __tests__/dashboards/consultant-home-read-shape.test.ts, each test verified by reintroducing its bug. Deriving UserRoleEnum from Prisma removes five 'as never' casts in onboarding. No raw SQL remains in lib/data or app. --- .../consultant-home-read-shape.test.ts | 135 ++++++++ actions/forms/onboarding.action.ts | 6 +- .../(features)/earnings/EarningsTabs.tsx | 17 +- .../(features)/home/HomePageClient.tsx | 2 +- .../[consultantId]/(features)/home/page.tsx | 25 +- .../(features)/home/HomePageClient.tsx | 5 +- .../[consulteeId]/(features)/home/page.tsx | 37 ++- app/form/onboarding/page.tsx | 89 +++++- components/Navbar.tsx | 62 ++-- components/dashboard/UrlTabs.tsx | 36 ++- components/scheduling/SafeUnifiedCalendar.tsx | 17 +- lib/auth-guard.ts | 24 +- lib/auth-helpers.ts | 8 + lib/auth-server.ts | 33 +- lib/auth/personal-dashboard-access.ts | 4 + lib/data/consultant-appointments.ts | 43 ++- lib/data/consultant-dashboard.ts | 293 ++++++++++-------- lib/data/explore-experts.ts | 4 +- lib/data/explore-programs.ts | 153 ++++----- lib/data/needs-you.ts | 144 +++++---- schemas/user.ts | 11 +- types/consultant-events.ts | 2 + 22 files changed, 781 insertions(+), 369 deletions(-) create mode 100644 __tests__/dashboards/consultant-home-read-shape.test.ts diff --git a/__tests__/dashboards/consultant-home-read-shape.test.ts b/__tests__/dashboards/consultant-home-read-shape.test.ts new file mode 100644 index 000000000..d4a5f7b1c --- /dev/null +++ b/__tests__/dashboards/consultant-home-read-shape.test.ts @@ -0,0 +1,135 @@ +/** + * @jest-environment node + */ + +/** + * #1101 — pins the three consultant-Home read regressions that shipped inside a + * perf change and produced quietly-wrong numbers rather than errors. + * + * These are asserted here rather than on the deploy preview because the dev + * database cannot reproduce any of them: its busiest consultant has 10 + * appointments (the display cap is 20) and there are 4 pending requests + * platform-wide (the old badge cap was 40). Clicking through the preview + * renders green on both the buggy and the fixed code. + */ + +import prisma from "@/lib/prisma"; +import { getConsultantDashboard } from "@/lib/data/consultant-dashboard"; + +jest.mock("../../lib/prisma", () => ({ + __esModule: true, + default: { + slotOfAppointment: { findMany: jest.fn(), groupBy: jest.fn() }, + appointment: { findMany: jest.fn() }, + consultation: { findMany: jest.fn(), count: jest.fn() }, + subscription: { findMany: jest.fn(), count: jest.fn() }, + activityLog: { findMany: jest.fn() }, + consultantEarnings: { aggregate: jest.fn() }, + consultantReview: { aggregate: jest.fn() }, + trialSession: { groupBy: jest.fn() }, + }, +})); + +const slotFindMany = prisma.slotOfAppointment.findMany as jest.Mock; +const apptFindMany = prisma.appointment.findMany as jest.Mock; +const consultationCount = prisma.consultation.count as jest.Mock; +const subscriptionCount = prisma.subscription.count as jest.Mock; + +describe("consultant Home read shape (#1101)", () => { + beforeEach(() => { + jest.clearAllMocks(); + slotFindMany.mockResolvedValue([]); + apptFindMany.mockResolvedValue([]); + consultationCount.mockResolvedValue(0); + subscriptionCount.mockResolvedValue(0); + (prisma.slotOfAppointment.groupBy as jest.Mock).mockResolvedValue([]); + (prisma.consultation.findMany as jest.Mock).mockResolvedValue([]); + (prisma.subscription.findMany as jest.Mock).mockResolvedValue([]); + (prisma.activityLog.findMany as jest.Mock).mockResolvedValue([]); + (prisma.trialSession.groupBy as jest.Mock).mockResolvedValue([]); + (prisma.consultantEarnings.aggregate as jest.Mock).mockResolvedValue({ + _sum: { consultantSharePaise: null, refundedShareAmount: null }, + }); + (prisma.consultantReview.aggregate as jest.Mock).mockResolvedValue({ + _avg: { rating: null }, + _count: { rating: 0 }, + }); + }); + + it("ranks Home appointments by slot time anchored at today, not by createdAt", async () => { + await getConsultantDashboard("cp-1").catch(() => undefined); + + expect(slotFindMany).toHaveBeenCalledTimes(1); + const args = slotFindMany.mock.calls[0][0]; + + // Ordering must key off the slot clock. `createdAt` truncated on the wrong + // key: a consultant who booked next month a fortnight ago and then took a + // burst of bookings for last week got 20 all-past rows. + expect(args.orderBy).toEqual({ startsAt: "asc" }); + + // ...and the window must be anchored at the PRESENT. Ordering ascending + // from a lower bound in the past returns the OLDEST slots, which + // reproduces the same empty Today/Upcoming widgets in a new disguise. + expect(args.where.endsAt?.gte).toBeInstanceOf(Date); + expect(args.where.startsAt).toBeUndefined(); + const anchor: Date = args.where.endsAt.gte; + const now = new Date(); + expect(anchor.getTime()).toBeLessThanOrEqual(now.getTime()); + // Start-of-today, so a session already running today survives. + expect(anchor.getHours()).toBe(0); + expect(anchor.getMinutes()).toBe(0); + expect(now.getTime() - anchor.getTime()).toBeLessThan(24 * 60 * 60 * 1000); + + // Tombstoned slots must not steer the ranking. + expect(args.where.deletedAt).toBeNull(); + }); + + it("counts pending requests with count(), uncapped, so the badge cannot disagree with NeedsYou", async () => { + // More pending than any list cap: the old badge read `approvals.length` + // off a capped list and contradicted NeedsYou on the same screen. + consultationCount.mockResolvedValue(37); + subscriptionCount.mockResolvedValue(18); + + const result = await getConsultantDashboard("cp-1"); + + expect(result.pendingRequestsCount).toBe(55); + + // NeedsYou counts every pending request regardless of age, so these must + // not inherit the list's 90-day bound or the two numbers drift apart. + for (const call of [ + consultationCount.mock.calls[0][0], + subscriptionCount.mock.calls[0][0], + ]) { + expect(call.where.status).toBe("PENDING"); + expect(call.where.requestedAt).toBeUndefined(); + } + }); + + it("derives active clients from a dedicated query, not the truncated display list", async () => { + // Display list is capped; the active book is not. Deriving counts from the + // capped array under-reported Financial Summary for any real consultant. + const activeBook = Array.from({ length: 64 }, (_, i) => ({ + consultation: { requestedBy: { id: `consultee-${i}` } }, + subscription: null, + class: null, + })); + + apptFindMany.mockImplementation((args: { select?: unknown }) => + // The active-book read is the `select` one; the display read uses `include`. + Promise.resolve(args.select ? activeBook : []), + ); + + const result = await getConsultantDashboard("cp-1"); + + expect(result.financialSummary.activeClients).toBe(64); + + const activeBookCall = apptFindMany.mock.calls.find((c) => c[0].select); + expect(activeBookCall).toBeDefined(); + // No cap on the counting read. + expect(activeBookCall![0].take).toBeUndefined(); + // Soft-deleted slots must not keep an appointment counted as active. + for (const clause of activeBookCall![0].where.AND) { + expect(clause.slotsOfAppointment.some.deletedAt).toBeNull(); + } + }); +}); diff --git a/actions/forms/onboarding.action.ts b/actions/forms/onboarding.action.ts index 938cc703d..18814a457 100644 --- a/actions/forms/onboarding.action.ts +++ b/actions/forms/onboarding.action.ts @@ -68,7 +68,11 @@ export async function updateOnboardingInformationAction( } // Use the central processing function - return await processOnboardingData(userId, body); + // No cookie-cache refresh here: requireOnboarded() reads force-fresh, so it + // already sees onboardingCompleted / profile ids. A refresh would also be the + // wrong tool — getSession reads the CALLER's headers, and this action lets an + // ADMIN/STAFF update someone else, whose session it could not refresh anyway. + return processOnboardingData(userId, body); } // #endregion diff --git a/app/dashboard/consultant/[consultantId]/(features)/earnings/EarningsTabs.tsx b/app/dashboard/consultant/[consultantId]/(features)/earnings/EarningsTabs.tsx index 98232b40c..8605c5cd2 100644 --- a/app/dashboard/consultant/[consultantId]/(features)/earnings/EarningsTabs.tsx +++ b/app/dashboard/consultant/[consultantId]/(features)/earnings/EarningsTabs.tsx @@ -1,9 +1,21 @@ "use client"; +import dynamic from "next/dynamic"; import { UrlTabs } from "@/components/dashboard/UrlTabs"; -import AnalyticsPageClient from "../analytics/AnalyticsPageClient"; import { EarningsSummaryPanel } from "./EarningsSummaryPanel"; +const AnalyticsPageClient = dynamic( + () => import("../analytics/AnalyticsPageClient"), + { + ssr: false, + loading: () => ( +
+ Loading analytics… +
+ ), + }, +); + /** * Earnings, with Analytics as its second panel. * @@ -13,6 +25,9 @@ import { EarningsSummaryPanel } from "./EarningsSummaryPanel"; * is the pattern the rule exists to stop. Both panels keep their own filter and * pagination state, deliberately: they answer different questions and resetting * one when the other moves would be surprising. + * + * Analytics (recharts) is code-split so the Summary tab does not pay for the + * charting library on first paint. */ export function EarningsTabs({ consultantId, diff --git a/app/dashboard/consultant/[consultantId]/(features)/home/HomePageClient.tsx b/app/dashboard/consultant/[consultantId]/(features)/home/HomePageClient.tsx index 26b08ef18..84284082a 100644 --- a/app/dashboard/consultant/[consultantId]/(features)/home/HomePageClient.tsx +++ b/app/dashboard/consultant/[consultantId]/(features)/home/HomePageClient.tsx @@ -84,7 +84,7 @@ export default function HomePageClient({ appointments={dashboardData.appointments} consultantId={consultantId} consultantName={consultantName} - pendingRequestsCount={dashboardData.approvals?.length ?? 0} + pendingRequestsCount={dashboardData.pendingRequestsCount ?? 0} performanceSnapshot={dashboardData.performanceSnapshot} financialSummary={dashboardData.financialSummary} /> diff --git a/app/dashboard/consultant/[consultantId]/(features)/home/page.tsx b/app/dashboard/consultant/[consultantId]/(features)/home/page.tsx index 7f64c8d40..eade1fd40 100644 --- a/app/dashboard/consultant/[consultantId]/(features)/home/page.tsx +++ b/app/dashboard/consultant/[consultantId]/(features)/home/page.tsx @@ -26,12 +26,12 @@ export default async function HomePage({ params }: Readonly) { // the VIEWER's memberships, which are not the profile owner's, so it would // answer a question nobody asked. Failure is non-fatal — the card is // supplementary and the page must not 500 because a count timed out. - let needsYou: NeedsYouSummary | null = null; - if (!access.isInspecting) { - needsYou = await getNeedsYouSummary(access.userId, consultantId).catch( - () => null, - ); - } + // + // Run NeedsYou in parallel with the dashboard prefetch — they share no + // dependency and sequential awaits previously paid two full TTFB chains. + const needsYouPromise: Promise = access.isInspecting + ? Promise.resolve(null) + : getNeedsYouSummary(access.userId, consultantId).catch(() => null); // #890 — SSR prefetch the dashboard so the client useQuery hydrates // without a fetch waterfall. Key MUST match @@ -40,11 +40,14 @@ export default async function HomePage({ params }: Readonly) { // only), so there is a single deterministic payload to prefetch. // allSettled so a read failure degrades to a client-side fetch rather // than crashing the route. - await Promise.allSettled([ - queryClient.prefetchQuery({ - queryKey: ["consultant-dashboard", consultantId], - queryFn: () => getConsultantDashboard(consultantId), - }), + const [, needsYou] = await Promise.all([ + Promise.allSettled([ + queryClient.prefetchQuery({ + queryKey: ["consultant-dashboard", consultantId], + queryFn: () => getConsultantDashboard(consultantId), + }), + ]), + needsYouPromise, ]); return ( diff --git a/app/dashboard/consultee/[consulteeId]/(features)/home/HomePageClient.tsx b/app/dashboard/consultee/[consulteeId]/(features)/home/HomePageClient.tsx index 9891b90ee..855c7782d 100644 --- a/app/dashboard/consultee/[consulteeId]/(features)/home/HomePageClient.tsx +++ b/app/dashboard/consultee/[consulteeId]/(features)/home/HomePageClient.tsx @@ -33,8 +33,9 @@ export default function HomePageClient({ const eventsQuery = { ...createConsulteeQueries(consulteeId, orgScopeParam).events, - // Show stale data immediately while fetching in background - staleTime: 0, + // Keep SSR-dehydrated events warm long enough to avoid an immediate + // refetch waterfall on first paint (aligned with dashboard staleTimes). + staleTime: 60_000, refetchOnWindowFocus: false, }; const { data: eventsData, isLoading, error, refetch } = useQuery(eventsQuery); diff --git a/app/dashboard/consultee/[consulteeId]/(features)/home/page.tsx b/app/dashboard/consultee/[consulteeId]/(features)/home/page.tsx index fe4e23ba4..6ef08e616 100644 --- a/app/dashboard/consultee/[consulteeId]/(features)/home/page.tsx +++ b/app/dashboard/consultee/[consulteeId]/(features)/home/page.tsx @@ -6,31 +6,56 @@ import { import HomePageClient from "./HomePageClient"; import { readConsulteeEvents } from "@/lib/data/consultee-events-read"; import { requirePersonalProfileAccess } from "@/lib/auth/personal-dashboard-access"; +import { getSession } from "@/lib/auth-server"; +import type { Scope } from "@/lib/api/scope/parse"; type PageProps = { params: Promise<{ consulteeId: string }>; searchParams: Promise<{ [key: string]: string | string[] | undefined }>; }; +/** + * Mirror useOrgScope({ defaultForOrgMember: "all" }) when the URL has no + * ?orgScope= — otherwise SSR dehydrates "personal" and org members immediately + * refetch "all". + */ +function defaultHomeScope(session: Awaited>): Scope { + const role = session?.user?.role; + const firstOrgId = + session?.user?.organizationMemberships?.[0]?.organizationId ?? null; + if (role === "ADMIN" || role === "STAFF" || firstOrgId) { + return { kind: "all" }; + } + return { kind: "personal" }; +} + +function scopeQueryKey(scope: Scope): string { + if (scope.kind === "personal") return "personal"; + if (scope.kind === "all") return "all"; + return scope.orgId; +} + export default async function HomePage({ params }: Readonly) { const { consulteeId } = await params; // Ownership is enforced HERE, not by the layout: the layout is a client // component, so its check runs after this server render has already read // and streamed the data. See lib/auth/personal-dashboard-access.ts. await requirePersonalProfileAccess("consultee", consulteeId); + // Shares React.cache with the access guard / dashboard layout. + const session = await getSession(); + const scope = defaultHomeScope(session); + const scopeKey = scopeQueryKey(scope); const queryClient = new QueryClient(); - // #890 — SSR prefetch the default (personal) scope so the client - // useQuery hydrates without a fetch waterfall. Key base MUST match + // #890 — SSR prefetch the same default scope the client useQuery asks for + // so hydration hits. Key base MUST match // createConsulteeQueries(...).events: ["consultee-events", id, scope]. - // The route's default (no ?orgScope=) is `personal`, so the scope - // segment is the literal "personal" and the read runs with that scope. // allSettled so a read failure degrades to a client-side fetch rather // than crashing the route. await Promise.allSettled([ queryClient.prefetchQuery({ - queryKey: ["consultee-events", consulteeId, "personal"], - queryFn: () => readConsulteeEvents(consulteeId, { kind: "personal" }), + queryKey: ["consultee-events", consulteeId, scopeKey], + queryFn: () => readConsulteeEvents(consulteeId, scope), }), ]); diff --git a/app/form/onboarding/page.tsx b/app/form/onboarding/page.tsx index 8c7c29309..d772f0484 100644 --- a/app/form/onboarding/page.tsx +++ b/app/form/onboarding/page.tsx @@ -16,21 +16,78 @@ import { useToast } from "@/hooks/use-toast"; import { signOut, useSession } from "@/lib/auth-client"; import { getPendingReferral, clearPendingReferral } from "@/lib/pending-referral"; import { useRouter } from "next/navigation"; +import dynamic from "next/dynamic"; import React, { useEffect, useState } from "react"; import { FormProvider, useForm } from "react-hook-form"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import ConsultantPreferredScheduleForm from "./components/ConsultantPreferredScheduleForm"; -import ConsultantProfessionalStep from "./components/ConsultantProfessionalStep"; -import ConsultantAgreementAndVerificationStep from "./components/ConsultantAgreementAndVerificationStep"; -import ConsultantReviewForm from "./components/ConsultantReviewForm"; -import ConsulteeAgreementForm from "./components/ConsulteeAgreementForm"; -import ConsulteeProfileForm from "./components/ConsulteeProfileForm"; -import ConsulteeReviewForm from "./components/ConsulteeReviewForm"; +// Step 0 stays eager — every user sees Personal Info first. import PersonalInfoAndRoleForm from "./components/PersonalInfoAndRoleForm"; -import StaffAgreementForm from "./components/StaffAgreementForm"; -import StaffProfileForm from "./components/StaffProfileForm"; -import StaffReviewForm from "./components/StaffReviewForm"; -import { CreateOrganizationWizard } from "@/components/organization/create-wizard/Wizard"; + +// Later steps + org wizard are code-split so the initial onboarding chunk +// does not pay for schedule UI, review forms, or the create-org wizard. +// +// Every split step needs `loading` — next/dynamic renders null while the chunk +// downloads, so without it pressing Next collapses the card to zero height and +// reads as a frozen app on a slow connection. The options object is repeated +// inline rather than hoisted to a shared const because SWC statically analyses +// it: a variable fails the build with "next/dynamic options must be an object +// literal". +function StepLoading() { + return ( +
+
+ Loading this step… +
+ ); +} + +const ConsultantPreferredScheduleForm = dynamic( + () => import("./components/ConsultantPreferredScheduleForm"), + { ssr: false, loading: () => }, +); +const ConsultantProfessionalStep = dynamic( + () => import("./components/ConsultantProfessionalStep"), + { ssr: false, loading: () => }, +); +const ConsultantAgreementAndVerificationStep = dynamic( + () => import("./components/ConsultantAgreementAndVerificationStep"), + { ssr: false, loading: () => }, +); +const ConsultantReviewForm = dynamic( + () => import("./components/ConsultantReviewForm"), + { ssr: false, loading: () => }, +); +const ConsulteeAgreementForm = dynamic( + () => import("./components/ConsulteeAgreementForm"), + { ssr: false, loading: () => }, +); +const ConsulteeProfileForm = dynamic( + () => import("./components/ConsulteeProfileForm"), + { ssr: false, loading: () => }, +); +const ConsulteeReviewForm = dynamic( + () => import("./components/ConsulteeReviewForm"), + { ssr: false, loading: () => }, +); +const StaffAgreementForm = dynamic( + () => import("./components/StaffAgreementForm"), + { ssr: false, loading: () => }, +); +const StaffProfileForm = dynamic( + () => import("./components/StaffProfileForm"), + { ssr: false, loading: () => }, +); +const StaffReviewForm = dynamic( + () => import("./components/StaffReviewForm"), + { ssr: false, loading: () => }, +); +const CreateOrganizationWizard = dynamic( + () => + import("@/components/organization/create-wizard/Wizard").then((m) => ({ + default: m.CreateOrganizationWizard, + })), + { ssr: false, loading: () => }, +); // Step labels for progress indicator const STEP_LABELS = { @@ -352,7 +409,7 @@ const MultiStepForm: React.FC = () => { [0]["initialData"]} + initialData={formData} /> ); default: @@ -373,7 +430,7 @@ const MultiStepForm: React.FC = () => { [0]["formData"]} + formData={formData} /> ); case "STAFF": @@ -381,7 +438,7 @@ const MultiStepForm: React.FC = () => { [0]["initialData"]} + initialData={formData} /> ); default: @@ -402,7 +459,7 @@ const MultiStepForm: React.FC = () => { [0]["formData"]} + formData={formData} onGoToStep={handleGoToStep} /> ); @@ -411,7 +468,7 @@ const MultiStepForm: React.FC = () => { [0]["formData"]} + formData={formData} onGoToStep={handleGoToStep} /> ); diff --git a/components/Navbar.tsx b/components/Navbar.tsx index ad54dad6e..7be9a2239 100644 --- a/components/Navbar.tsx +++ b/components/Navbar.tsx @@ -1,6 +1,5 @@ "use client"; -import { motion, AnimatePresence } from "framer-motion"; import { ChevronDown, Menu, @@ -347,22 +346,16 @@ function DesktopDropdownPanel({ const panelGrid = isWide ? "grid-cols-3" : "grid-cols-2"; return ( -
)} - + ); } @@ -508,15 +501,13 @@ function DesktopNavItem({ className={`w-3.5 h-3.5 transition-transform duration-200 ${open ? "rotate-180" : ""}`} /> - - {open && ( - setOpen(false)} - /> - )} - + {open && ( + setOpen(false)} + /> + )} ); } @@ -734,26 +725,22 @@ const Navbar = () => { - {/* Mobile Drawer */} - - {isOpen && ( + {/* Mobile Drawer — CSS transitions only; framer-motion was pulled into + every public page via the root navbar for enter/exit polish. */} + {isOpen && ( <> - {/* Backdrop */} - {/* Drawer */} - {/* Drawer Header */}
@@ -936,10 +923,9 @@ const Navbar = () => { )}
-
+ )} -
); }; diff --git a/components/dashboard/UrlTabs.tsx b/components/dashboard/UrlTabs.tsx index a842612ab..7d8a14627 100644 --- a/components/dashboard/UrlTabs.tsx +++ b/components/dashboard/UrlTabs.tsx @@ -10,18 +10,19 @@ * state has to be addressable — a plain `defaultValue` would drop the user on * the first panel regardless of where they came from. * - * Uses `router.replace` with `scroll: false`: switching a tab is a lateral - * move, not navigation, so it shouldn't stack history entries or jump the - * viewport. The browser back button still leaves the page rather than walking - * back through every tab the user glanced at. + * URL writes use `history.replaceState` (not `router.replace`) so switching a + * tab does not trigger a Next.js soft navigation / RSC refetch. Local state + * keeps the active panel in sync immediately; the address bar stays shareable. + * History is not pushed — back still leaves the page rather than walking + * every tab glance. * * Tabs are filtered by `show` so a caller can gate individual panels off the * same permission matrix that gates the sidebar — a hidden tab must not render * a trigger that 403s when clicked. */ -import { useCallback } from "react"; -import { usePathname, useRouter, useSearchParams } from "next/navigation"; +import { useCallback, useEffect, useState } from "react"; +import { usePathname, useSearchParams } from "next/navigation"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; @@ -42,7 +43,6 @@ export function UrlTabs({ paramName?: string; className?: string; }) { - const router = useRouter(); const pathname = usePathname(); const searchParams = useSearchParams(); @@ -50,20 +50,36 @@ export function UrlTabs({ const requested = searchParams?.get(paramName); // Fall back to the first visible tab when the param is absent or names a tab // this user can't see — a stale bookmark shouldn't render an empty page. - const active = + const urlActive = visible.find((t) => t.value === requested)?.value ?? visible[0]?.value; + // Local override so replaceState (which does not update useSearchParams) + // still flips the panel immediately. + const [localActive, setLocalActive] = useState(null); + const active = localActive ?? urlActive; + + // External URL change (e.g. redirect into ?tab=) wins over a stale local pick. + useEffect(() => { + setLocalActive(null); + }, [requested]); + const onValueChange = useCallback( (value: string) => { + setLocalActive(value); const params = new URLSearchParams(searchParams?.toString() ?? ""); params.set(paramName, value); // Panels that paginate all read the same `?page=`. Without this, moving // to page 3 of Documents and then clicking Trials would open Trials on // page 3 — or on an empty page, if it has fewer. params.delete("page"); - router.replace(`${pathname}?${params.toString()}`, { scroll: false }); + const qs = params.toString(); + const target = qs ? pathname + "?" + qs : pathname; + const current = window.location.pathname + window.location.search; + if (target !== current) { + window.history.replaceState(window.history.state, "", target); + } }, - [paramName, pathname, router, searchParams], + [paramName, pathname, searchParams], ); if (!active) return null; diff --git a/components/scheduling/SafeUnifiedCalendar.tsx b/components/scheduling/SafeUnifiedCalendar.tsx index 1ffc46397..0faac5ed1 100644 --- a/components/scheduling/SafeUnifiedCalendar.tsx +++ b/components/scheduling/SafeUnifiedCalendar.tsx @@ -1,7 +1,9 @@ "use client"; -import { UnifiedCalendar, UnifiedCalendarProps } from "./UnifiedCalendar"; +import dynamic from "next/dynamic"; +import type { UnifiedCalendarProps } from "./UnifiedCalendar"; import CalendarErrorBoundary from "./CalendarErrorBoundary"; +import { CalendarGridSkeleton } from "@/components/scheduling/CalendarSkeletons"; import { SlotStatusLegend } from "./SlotStatusLegend"; import { BUYER_LEGEND_KEYS, @@ -9,6 +11,15 @@ import { } from "@/lib/scheduling/slot-status-tokens"; import { cn } from "@/utils/tailwind"; +const UnifiedCalendar = dynamic( + () => + import("./UnifiedCalendar").then((m) => ({ default: m.UnifiedCalendar })), + { + ssr: false, + loading: () => , + }, +); + /** * Mounts the legend alongside the calendar. * @@ -16,6 +27,10 @@ import { cn } from "@/utils/tailwind"; * what any of them meant, so a consultant seeing a yellow cell had to guess * whether it was bookable. Putting the legend here rather than inside * UnifiedCalendar means every caller gets it and none can forget it. + * + * UnifiedCalendar itself is code-split here so SlotPicker / allocate / + * reschedule routes do not pay the calendar module on first paint of the + * surrounding page chrome. */ export function SafeUnifiedCalendar({ className, diff --git a/lib/auth-guard.ts b/lib/auth-guard.ts index 8ad67b859..eaf1c10cb 100644 --- a/lib/auth-guard.ts +++ b/lib/auth-guard.ts @@ -45,12 +45,26 @@ function isFullyOnboarded(user: SessionUser): boolean { /** * Require an authenticated session. Redirects to sign-in if no session. * Returns the validated session (never null). + * + * Force-fresh for the same reason as requireOnboarded below: this guard covers + * /settings, /profile and all of /dashboard/org-workspace (including billing), + * and a cookie-cached read cannot see a session that was revoked, erased under + * DPDP, or signed out from another device — those delete the session row, which + * only a fresh lookup consults. It costs those routes one session read; that is + * the intended trade. */ export async function requireAuth() { - const session = await getSession(); + const session = await getSession(true); if (!session?.user?.id) { redirectWithCookieCleanup(); } + // Mirrors requireApiAuth's #693 check. `banned` is rebuilt by customSession on + // every call, so it stays accurate even in the window where ban-time session + // deletion has not landed yet — worth checking explicitly rather than relying + // on row deletion alone. + if (session.user.banned === true) { + redirectWithCookieCleanup(); + } return session; } @@ -81,6 +95,14 @@ async function onboardingRedirectTarget( * Require an authenticated AND fully onboarded user. * Redirects to sign-in if no session, to onboarding if not completed or * profile is missing. Uses disableCookieCache to avoid stale values. + * + * Do NOT switch this to the cookie cache. This guard has no `banned` check of + * its own — it catches bans, DPDP erasure and revoked sessions only because the + * forced read finds no session row. A 5-minute cookie cache would keep those + * users inside /dashboard/admin, /checkout and /settings. The cache would also + * buy almost nothing: customSession re-runs its Prisma enrichment on every + * getSession call regardless, so the cache skips one query out of ~4. The + * per-render dedupe that actually helps is getSession's React.cache. */ export async function requireOnboarded() { const session = await getSession(true); diff --git a/lib/auth-helpers.ts b/lib/auth-helpers.ts index 434f5219d..460eaa984 100644 --- a/lib/auth-helpers.ts +++ b/lib/auth-helpers.ts @@ -17,6 +17,14 @@ import { /** * Requires API authentication and returns the session or an error response. * Use this at the start of protected API route handlers. + * + * Always reads force-fresh, and there is deliberately no opt-out. Session + * revocation is invisible to a cached read, and `session.user.role` comes from + * the cookie payload — ~86 call sites branch on that role directly (e.g. the + * ADMIN gate on DELETE /api/bookings/subscriptions/[id]), so a stale read + * honours a demotion up to 5 minutes late. The cookie cache would save roughly + * one query in four anyway, because customSession re-runs its enrichment on + * every call regardless. */ export async function requireApiAuth(): Promise< { session: Session; error?: never } | { session?: never; error: NextResponse } diff --git a/lib/auth-server.ts b/lib/auth-server.ts index ba1275508..4263a22de 100644 --- a/lib/auth-server.ts +++ b/lib/auth-server.ts @@ -1,9 +1,40 @@ +import { cache } from "react"; import { auth } from "@/lib/auth"; import { headers } from "next/headers"; -export async function getSession(disableCookieCache = false) { +/** + * Render-memoized session read. Nested layouts that call requireOnboarded / + * requireAuth in the same RSC render share one Better Auth getSession call + * instead of re-running customSession enrichment for each guard. This is the + * dedupe that actually cuts dashboard TTFB — the Better Auth cookie cache is + * not, because customSession re-runs its Prisma work on every call anyway. + * + * Keyed by disableCookieCache so a force-fresh read never serves a cached + * cookie-cache result (and vice versa) within the same request. + * + * Two limits worth knowing. React.cache memoizes only during an RSC render, so + * Route Handlers and Server Actions get a throwaway cache per call and still + * pay per getSession. And the memo holds the promise, so if the first read + * rejects every later guard in that render re-throws the same rejection rather + * than retrying independently (documented: react.dev/reference/react/cache). + * + * Undeclared dependency, deliberately recorded: package.json pins react + * ^18.3.1, and react@18.3.1 does NOT export `cache` — `Object.keys(require( + * "react")).includes("cache")` is false. This resolves only because Next + * aliases `react` to its own vendored React 19 inside the RSC layer. It works + * (the build is green and 5 pages plus lib/data already rely on it), but it + * rests on a bundler alias rather than on the declared dep. If that alias ever + * stops applying, this silently degrades to no memoization — correct results, + * N times the queries, and no test would catch it. Revisit when React 19 + * lands properly; Next 15's App Router targets it. + */ +const getSessionCached = cache(async (disableCookieCache: boolean) => { return auth.api.getSession({ headers: await headers(), ...(disableCookieCache && { query: { disableCookieCache: true } }), }); +}); + +export async function getSession(disableCookieCache = false) { + return getSessionCached(disableCookieCache); } diff --git a/lib/auth/personal-dashboard-access.ts b/lib/auth/personal-dashboard-access.ts index bb784e231..6d5423313 100644 --- a/lib/auth/personal-dashboard-access.ts +++ b/lib/auth/personal-dashboard-access.ts @@ -55,6 +55,10 @@ export async function requirePersonalProfileAccess( kind: PersonalProfileKind, profileId: string, ): Promise { + // Force-fresh, matching requireOnboarded(): ownership and role are re-read + // from prisma below, but only a fresh read notices a REVOKED session, and + // this is the gate on someone's personal dashboard. Both readers pass `true`, + // so they share one React.cache entry per request anyway. const session = await getSession(true); if (!session?.user?.id) redirect("/auth/signin"); diff --git a/lib/data/consultant-appointments.ts b/lib/data/consultant-appointments.ts index 4ad46dd5d..c68e3506b 100644 --- a/lib/data/consultant-appointments.ts +++ b/lib/data/consultant-appointments.ts @@ -25,6 +25,7 @@ import prisma from "@/lib/prisma"; import { AppointmentsType, AppointmentStatus, Prisma } from "@prisma/client"; +import type { User } from "@prisma/client"; import { toPlain } from "@/lib/data/serialize"; import type { TAppointment } from "@/types/appointment"; @@ -63,9 +64,26 @@ export interface GetConsultantAppointmentsArgs { * (the default "personal" view); the route passes the full arg set parsed * from the request query string. */ +/** + * Slot users on this surface are projected down to `listUserSelect`. TAppointment + * still declares the full User relation, so name the narrower shape here instead + * of letting the `as unknown as` at the bottom imply fields that were never + * queried — reading e.g. `.role` off one of these is `undefined` at runtime. + */ +type ListSlotUser = Pick; +export type ConsultantListAppointment = Omit< + TAppointment, + "slotsOfAppointment" +> & { + slotsOfAppointment: (Omit< + TAppointment["slotsOfAppointment"][number], + "user" + > & { user: ListSlotUser[] })[]; +}; + export async function getConsultantAppointments( args: GetConsultantAppointmentsArgs, -): Promise { +): Promise { const { type, consultantProfileId, @@ -254,13 +272,20 @@ export async function getConsultantAppointments( whereClause.AND = [...existingAnd, { OR: statusFilters }]; } + const listUserSelect = { + id: true, + name: true, + email: true, + image: true, + } as const; + const appointments = await prisma.appointment.findMany({ where: whereClause, include: { slotsOfAppointment: { orderBy: { startsAt: "asc" }, include: { - user: true, + user: { select: listUserSelect }, meetingSession: { select: { id: true, endedAt: true }, }, @@ -272,14 +297,14 @@ export async function getConsultantAppointments( include: { consultantProfile: { include: { - user: true, + user: { select: listUserSelect }, }, }, }, }, requestedBy: { include: { - user: true, + user: { select: listUserSelect }, }, }, }, @@ -291,14 +316,14 @@ export async function getConsultantAppointments( include: { consultantProfile: { include: { - user: true, + user: { select: listUserSelect }, }, }, }, }, requestedBy: { include: { - user: true, + user: { select: listUserSelect }, }, }, schedulingPeriodStartsAt: true, @@ -315,7 +340,7 @@ export async function getConsultantAppointments( include: { consultantProfile: { include: { - user: true, + user: { select: listUserSelect }, }, }, collaborators: { @@ -344,7 +369,7 @@ export async function getConsultantAppointments( include: { consultantProfile: { include: { - user: true, + user: { select: listUserSelect }, }, }, collaborators: { @@ -388,5 +413,5 @@ export async function getConsultantAppointments( // result extension carries an inspect symbol), so the payload must be // plainified before it crosses the RSC→Client HydrationBoundary. Preserves // Dates as Dates. No-op for the route path (JSON drops symbols). - return toPlain(sorted) as unknown as TAppointment[]; + return toPlain(sorted) as unknown as ConsultantListAppointment[]; } diff --git a/lib/data/consultant-dashboard.ts b/lib/data/consultant-dashboard.ts index 9c01a19e2..536990247 100644 --- a/lib/data/consultant-dashboard.ts +++ b/lib/data/consultant-dashboard.ts @@ -29,15 +29,81 @@ import type { TConsultantDashboardResponse } from "@/types/consultant-events"; // Prisma Query Types - Derived from actual query shape for type safety // ============================================================================= +/** Home widgets only need identity + avatar; omit phone/role from the graph. */ const userSelectFields = { id: true, name: true, email: true, image: true, - role: true, - phone: true, } as const; +/** Approvals list only needs the requester display name. */ +const pendingUserSelect = { + id: true, + name: true, + image: true, +} as const; + +/** Home surfaces a handful of cards — history lives on /appointments. */ +const HOME_APPOINTMENTS_TAKE = 20; +const HOME_PENDING_TAKE = 20; + +/** + * Every appointment this consultant owns or collaborates on. Shared by the + * Home display read and the active-clients count so the two can never drift. + */ +const consultantAppointmentScope = (consultantProfileId: string) => + ({ + OR: [ + { + consultation: { + consultationPlan: { consultantProfileId }, + status: "APPROVED" as const, + }, + }, + { + subscription: { + subscriptionPlan: { consultantProfileId }, + status: "APPROVED" as const, + }, + }, + { + webinar: { + webinarPlan: { consultantProfileId }, + status: "SCHEDULED" as const, + }, + }, + { + // Collaborated webinars (co-host, moderator, etc.) + webinar: { + webinarPlan: { + collaborators: { + some: { consultantProfileId, status: "ACCEPTED" as const }, + }, + }, + status: "SCHEDULED" as const, + }, + }, + { + class: { + classPlan: { consultantProfileId }, + status: "SCHEDULED" as const, + }, + }, + { + // Collaborated classes (co-instructor, TA, etc.) + class: { + classPlan: { + collaborators: { + some: { consultantProfileId, status: "ACCEPTED" as const }, + }, + }, + status: "SCHEDULED" as const, + }, + }, + ], + }) satisfies Prisma.AppointmentWhereInput; + const appointmentInclude = { slotsOfAppointment: { orderBy: { startsAt: "asc" as const }, @@ -162,76 +228,25 @@ const appointmentInclude = { }, } satisfies Prisma.AppointmentInclude; -const consultationInclude = { - consultationPlan: { - include: { - consultantProfile: { - include: { - user: { - select: userSelectFields, - }, - }, - }, - }, - }, +/** Pending-approvals widget only needs id + requester name + requestedAt. */ +const pendingConsultationInclude = { requestedBy: { include: { user: { - select: userSelectFields, + select: pendingUserSelect, }, }, }, - appointment: { - include: { - slotsOfAppointment: { - include: { - user: { - select: userSelectFields, - }, - }, - orderBy: { - startsAt: "asc" as const, - }, - }, - payment: true, - }, - }, } satisfies Prisma.ConsultationInclude; -const subscriptionInclude = { - subscriptionPlan: { - include: { - consultantProfile: { - include: { - user: { - select: userSelectFields, - }, - domain: true, - subDomains: true, - tags: true, - }, - }, - }, - }, +const pendingSubscriptionInclude = { requestedBy: { include: { user: { - select: userSelectFields, + select: pendingUserSelect, }, }, }, - appointments: { - include: { - slotsOfAppointment: { - include: { - user: { - select: userSelectFields, - }, - }, - }, - payment: true, - }, - }, } satisfies Prisma.SubscriptionInclude; // Derive types from the include objects via the extended client — raw @@ -243,12 +258,12 @@ type DashboardAppointment = Prisma.Result< >; type DashboardConsultation = Prisma.Result< typeof prisma.consultation, - { include: typeof consultationInclude }, + { include: typeof pendingConsultationInclude }, "findFirstOrThrow" >; type DashboardSubscription = Prisma.Result< typeof prisma.subscription, - { include: typeof subscriptionInclude }, + { include: typeof pendingSubscriptionInclude }, "findFirstOrThrow" >; @@ -323,12 +338,46 @@ export async function getConsultantDashboard( const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); const ninetyDaysAgo = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000); + // Which appointments Home actually shows. Appointment has no top-level start + // column, so a plain `orderBy: createdAt` + `take` truncated on the wrong key: + // a consultant who booked next month a fortnight ago and then took a burst of + // bookings for last week got 20 rows that were all in the past, and both the + // Today and Upcoming widgets rendered empty. Rank on the slot table instead — + // it has @@index([startsAt, endsAt]) — and keep only the soonest ids. Slots + // are per-appointment, so over-fetch before deduping to ids. + // + // Anchor on endsAt >= start of today, NOT on a lower bound in the past: + // ordering ascending from 90 days ago returns the OLDEST slots in the window, + // which reproduces the empty-widget bug in a new disguise. Using endsAt keeps + // a session that started earlier today and is still running. + const startOfToday = new Date( + now.getFullYear(), + now.getMonth(), + now.getDate(), + ); + const soonestSlots = await prisma.slotOfAppointment.findMany({ + where: { + deletedAt: null, + endsAt: { gte: startOfToday }, + appointment: consultantAppointmentScope(consultantProfileId), + }, + select: { appointmentId: true }, + orderBy: { startsAt: "asc" }, + take: HOME_APPOINTMENTS_TAKE * 5, + }); + const homeAppointmentIds = [ + ...new Set(soonestSlots.map((s) => s.appointmentId)), + ].slice(0, HOME_APPOINTMENTS_TAKE); + // PERFORMANCE FIX #364: Use direct Prisma queries instead of internal HTTP fetches // This eliminates network overhead and reduces response time significantly const [ appointmentsRaw, + activeBookRows, pendingConsultations, pendingSubscriptions, + pendingConsultationCount, + pendingSubscriptionCount, recentActivities, earningsThisMonth, earningsLastMonth, @@ -339,73 +388,40 @@ export async function getConsultantDashboard( readyEarningsAgg, ] = await Promise.all([ // Fetch approved appointments for consultations, subscriptions, webinars, and classes + prisma.appointment.findMany({ + where: { id: { in: homeAppointmentIds } }, + include: appointmentInclude, + }), + // Active clients / programs are counted over the consultant's whole active + // book, not the handful of rows Home renders. Deriving them from the + // display array meant the Financial Summary card under-reported for anyone + // with more appointments than the page shows. Ids only — no include graph. prisma.appointment.findMany({ where: { - // TTFB bound: Home only renders today/upcoming widgets, so cap - // to appointments with a recent-or-future slot. Full history lives - // on the dedicated /appointments page (separate endpoint). - slotsOfAppointment: { some: { startsAt: { gte: ninetyDaysAgo } } }, - OR: [ - { - consultation: { - consultationPlan: { consultantProfileId }, - status: "APPROVED", - }, - }, - { - subscription: { - subscriptionPlan: { consultantProfileId }, - status: "APPROVED", - }, - }, + ...consultantAppointmentScope(consultantProfileId), + AND: [ { - webinar: { - webinarPlan: { consultantProfileId }, - status: "SCHEDULED", + slotsOfAppointment: { + some: { deletedAt: null, startsAt: { gte: ninetyDaysAgo } }, }, }, { - // Collaborated webinars (co-host, moderator, etc.) - webinar: { - webinarPlan: { - collaborators: { - some: { - consultantProfileId, - status: "ACCEPTED", - }, - }, - }, - status: "SCHEDULED", - }, - }, - { - class: { - classPlan: { consultantProfileId }, - status: "SCHEDULED", - }, - }, - { - // Collaborated classes (co-instructor, TA, etc.) - class: { - classPlan: { - collaborators: { - some: { - consultantProfileId, - status: "ACCEPTED", - }, - }, + slotsOfAppointment: { + some: { + deletedAt: null, + completionStatus: { notIn: ["COMPLETED", "CANCELLED"] }, }, - status: "SCHEDULED", }, }, ], }, - include: appointmentInclude, - // No top-level start field on Appointment (slots carry startsAt), - // so order by createdAt to make `take` deterministic; the JS - // sortedAppointments step re-sorts by slot startsAt afterwards. - orderBy: { createdAt: "desc" }, - take: 200, + select: { + consultation: { select: { requestedBy: { select: { id: true } } } }, + subscription: { + select: { id: true, requestedBy: { select: { id: true } } }, + }, + class: { select: { id: true } }, + }, }), // Fetch pending consultations prisma.consultation.findMany({ @@ -420,11 +436,11 @@ export async function getConsultantDashboard( // a 90-day-old PENDING request is stale. requestedAt: { gte: ninetyDaysAgo }, }, - include: consultationInclude, + include: pendingConsultationInclude, orderBy: { requestedAt: "desc", }, - take: 200, + take: HOME_PENDING_TAKE, }), // Fetch pending subscriptions prisma.subscription.findMany({ @@ -437,11 +453,31 @@ export async function getConsultantDashboard( // a 90-day-old PENDING request is stale. requestedAt: { gte: ninetyDaysAgo }, }, - include: subscriptionInclude, + include: pendingSubscriptionInclude, orderBy: { requestedAt: "desc", }, - take: 200, + take: HOME_PENDING_TAKE, + }), + // The approvals badge is a total, not a list length. Counting the capped + // list made it disagree with NeedsYou — which counts properly — on the very + // same screen once a consultant had more pending requests than the cap. + // + // Deliberately unbounded by date, unlike the list above. NeedsYou counts + // every pending request regardless of age, and these two numbers render + // inches apart, so matching its definition is what stops them contradicting + // each other. The 90-day bound stays on the list, which is only a preview. + prisma.consultation.count({ + where: { + consultationPlan: { consultantProfile: { id: consultantProfileId } }, + status: "PENDING", + }, + }), + prisma.subscription.count({ + where: { + subscriptionPlan: { consultantProfileId }, + status: "PENDING", + }, }), // Fetch recent activities prisma.activityLog.findMany({ @@ -669,6 +705,10 @@ export async function getConsultantDashboard( time: formatTime(approval.requestedAt), })); + // Total pending requests, independent of how many the widget lists. + const pendingRequestsCount = + pendingConsultationCount + pendingSubscriptionCount; + // Transform activities for display const activities = recentActivities.map((activity) => ({ id: activity.id, @@ -735,17 +775,13 @@ export async function getConsultantDashboard( const payoutMinimum = PAYOUT_CONSTANTS.MINIMUM_PAYOUT_AMOUNT; const payoutEligible = readyEarningsVal >= payoutMinimum; - // Active clients + programs: single pass over sorted appointments + // Active clients + programs: single pass over the full active book. The + // query already excludes fully completed/cancelled appointments, so every + // row here counts. const activeClientIds = new Set(); const activeSubIds = new Set(); const activeClassIds = new Set(); - for (const apt of sortedAppointments) { - const isCompleted = apt.slotsOfAppointment.every( - (s) => - s.completionStatus === "COMPLETED" || - s.completionStatus === "CANCELLED", - ); - if (isCompleted) continue; + for (const apt of activeBookRows) { const consulteeId = apt.consultation?.requestedBy?.id ?? apt.subscription?.requestedBy?.id; if (consulteeId) activeClientIds.add(consulteeId); @@ -775,6 +811,7 @@ export async function getConsultantDashboard( appointments: transformedAppointments, activities, approvals, + pendingRequestsCount, performanceSnapshot: { earningsThisMonth: earningsThisMonthVal, earningsLastMonth: earningsLastMonthVal, diff --git a/lib/data/explore-experts.ts b/lib/data/explore-experts.ts index d9657ec86..8e9008985 100644 --- a/lib/data/explore-experts.ts +++ b/lib/data/explore-experts.ts @@ -253,7 +253,9 @@ export async function fetchExpertsMetadata() { select: { languages: true }, }) .then((rows) => - Array.from(new Set(rows.flatMap((r) => r.languages))).sort(), + Array.from(new Set(rows.flatMap((r) => r.languages))) + .filter(Boolean) + .sort((a, b) => a.localeCompare(b)), ), // Available companies (from verified consultants' work experiences) prisma.workExperience diff --git a/lib/data/explore-programs.ts b/lib/data/explore-programs.ts index 1758c3c85..6841de8a1 100644 --- a/lib/data/explore-programs.ts +++ b/lib/data/explore-programs.ts @@ -61,54 +61,70 @@ const liveConsultantWhere = { // --------------------------------------------------------------------------- /** - * Trending rank step 1: load every marketplace plan's last-30-day slot ids - * and sort by count IN MEMORY. That scan is O(all plans × recent slots) per - * call — with React.cache alone it ran once per REQUEST, so 1000 concurrent - * explore loads each paid it. unstable_cache shares one computation across - * requests for 60s; the cached value is just the FULL ranked id array - * (callers slice to their limit — passing limit as an arg would key separate - * cache entries per limit, each paying the scan). Staleness is harmless — - * trending order changing 60s late is invisible. + * Trending rank step 1: last-30-day slot count per plan. + * + * ORM read + JS tally (no raw SQL). The earlier shape nested classes → + * appointments → slots under every marketplace plan, so the cost scaled with + * plans × their whole slot history. Instead read the two sides independently: + * the discoverable plan ids, and only slots created in the window. The tally is + * a single pass over that bounded set. Plans with no recent activity keep a + * count of 0 and stay in the ranking — dropping them empties the Trending row + * in a quiet window. + * + * The scan is shared across requests for 60s via unstable_cache; the cached + * value is the FULL ranked id array (callers slice — passing limit as an arg + * would key separate entries). Staleness is harmless: trending order changing + * 60s late is invisible. + */ +/** Slot window shared by both plan families. */ +const recentSlotWindow = () => { + const thirtyDaysAgo = new Date(); + thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30); + return { deletedAt: null, createdAt: { gte: thirtyDaysAgo } } as const; +}; + +/** + * Tally plan ids, then order plans by that count. Plans absent from the tally + * score 0 and keep their place — dropping them empties the Trending row. */ +function rankPlansByCount( + plans: { id: string; createdAt: Date }[], + planIds: (string | null | undefined)[], +): string[] { + const counts = new Map(); + for (const id of planIds) { + if (id) counts.set(id, (counts.get(id) ?? 0) + 1); + } + return plans + .sort( + (a, b) => + (counts.get(b.id) ?? 0) - (counts.get(a.id) ?? 0) || + b.createdAt.getTime() - a.createdAt.getTime(), + ) + .map((p) => p.id); +} + const getTrendingClassPlanIds = unstable_cache( async (): Promise => { - const thirtyDaysAgo = new Date(); - thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30); - - const ranked = await prisma.classPlan.findMany({ - where: { ...eventPlanDiscoverableWhere(), ...liveConsultantWhere }, // #726 — no ORG_ONLY in curated feed - select: { - id: true, - classes: { - select: { - appointments: { - select: { - slotsOfAppointment: { - where: { createdAt: { gte: thirtyDaysAgo } }, - select: { id: true }, - }, - }, - }, - }, + const [plans, slots] = await Promise.all([ + prisma.classPlan.findMany({ + where: { ...eventPlanDiscoverableWhere(), ...liveConsultantWhere }, // #726 + select: { id: true, createdAt: true }, + }), + prisma.slotOfAppointment.findMany({ + where: { + ...recentSlotWindow(), + appointment: { deletedAt: null, classId: { not: null } }, }, - }, - }); - - return ranked - .map((p) => ({ - id: p.id, - count: p.classes.reduce( - (sum, cls) => - sum + - cls.appointments.reduce( - (s, apt) => s + apt.slotsOfAppointment.length, - 0, - ), - 0, - ), - })) - .sort((a, b) => b.count - a.count) - .map((r) => r.id); + select: { + appointment: { select: { class: { select: { classPlanId: true } } } }, + }, + }), + ]); + return rankPlansByCount( + plans, + slots.map((s) => s.appointment?.class?.classPlanId), + ); }, ["trending-class-plan-ids"], { revalidate: 60 }, @@ -116,38 +132,27 @@ const getTrendingClassPlanIds = unstable_cache( const getTrendingWebinarPlanIds = unstable_cache( async (): Promise => { - const thirtyDaysAgo = new Date(); - thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30); - - const ranked = await prisma.webinarPlan.findMany({ - where: { ...eventPlanDiscoverableWhere(), ...liveConsultantWhere }, // #726 — no ORG_ONLY in curated feed - select: { - id: true, - webinars: { - select: { - appointment: { - select: { - slotsOfAppointment: { - where: { createdAt: { gte: thirtyDaysAgo } }, - select: { id: true }, - }, - }, - }, + const [plans, slots] = await Promise.all([ + prisma.webinarPlan.findMany({ + where: { ...eventPlanDiscoverableWhere(), ...liveConsultantWhere }, // #726 + select: { id: true, createdAt: true }, + }), + prisma.slotOfAppointment.findMany({ + where: { + ...recentSlotWindow(), + appointment: { deletedAt: null, webinarId: { not: null } }, + }, + select: { + appointment: { + select: { webinar: { select: { webinarPlanId: true } } }, }, }, - }, - }); - - return ranked - .map((p) => ({ - id: p.id, - count: p.webinars.reduce( - (sum, w) => sum + (w.appointment?.slotsOfAppointment?.length ?? 0), - 0, - ), - })) - .sort((a, b) => b.count - a.count) - .map((r) => r.id); + }), + ]); + return rankPlansByCount( + plans, + slots.map((s) => s.appointment?.webinar?.webinarPlanId), + ); }, ["trending-webinar-plan-ids"], { revalidate: 60 }, diff --git a/lib/data/needs-you.ts b/lib/data/needs-you.ts index b7de6b7f7..3419f3189 100644 --- a/lib/data/needs-you.ts +++ b/lib/data/needs-you.ts @@ -30,6 +30,39 @@ export interface NeedsYouSummary { total: number; } +function pendingConsultationWhere( + consultantProfileId: string, + organizationId: string | null, +) { + const isPersonal = organizationId === null; + return { + status: "PENDING" as const, + consultationPlan: { consultantProfileId }, + ...(isPersonal + ? { + OR: [ + { appointment: null }, + { appointment: { organizationId: null } }, + ], + } + : { appointment: { organizationId } }), + }; +} + +function pendingSubscriptionWhere( + consultantProfileId: string, + organizationId: string | null, +) { + const isPersonal = organizationId === null; + return { + status: "PENDING" as const, + subscriptionPlan: { consultantProfileId }, + appointments: isPersonal + ? { none: { organizationId: { not: null } } } + : { some: { organizationId } }, + }; +} + /** * @param userId the signed-in user * @param consultantProfileId their delivering profile @@ -38,73 +71,52 @@ export async function getNeedsYouSummary( userId: string, consultantProfileId: string, ): Promise { - // Orgs this person DELIVERS into. A LEARNER membership is not a delivery - // context and must not appear here — nothing is ever awaiting their - // allocation there. - const deliveringMemberships = await prisma.membership.findMany({ - where: { - userId, - status: "ACTIVE", - consultantProfileId, - organization: { canHost: true }, - }, - select: { - organizationId: true, - organization: { select: { name: true } }, - }, - }); + // Memberships + personal-scope counts share no dependency — fetch together + // so the personal context does not wait on the membership round-trip. + const [deliveringMemberships, personalConsultations, personalSubscriptions] = + await Promise.all([ + prisma.membership.findMany({ + where: { + userId, + status: "ACTIVE", + consultantProfileId, + organization: { canHost: true }, + }, + select: { + organizationId: true, + organization: { select: { name: true } }, + }, + }), + prisma.consultation.count({ + where: pendingConsultationWhere(consultantProfileId, null), + }), + prisma.subscription.count({ + where: pendingSubscriptionWhere(consultantProfileId, null), + }), + ]); - const scopes: { organizationId: string | null; label: string; href: string }[] = - [ - { - organizationId: null, - label: "Personal", - href: `/dashboard/consultant/${consultantProfileId}/requests`, - }, - ...deliveringMemberships.map((m) => ({ - organizationId: m.organizationId, - label: m.organization.name, - href: `/dashboard/organization/${m.organizationId}/requests`, - })), - ]; - - const contexts = await Promise.all( - scopes.map(async (scope) => { - // Scope predicates are copied verbatim from the two endpoints that back - // the Requests page (app/api/bookings/{consultations,subscriptions}), - // including their asymmetry: Consultation has one optional Appointment - // and counts a missing one as personal ("not org-funded → personal"), - // whereas Subscription has many and uses none/some. If the panel and the - // page disagreed on what personal means, the count would send people to - // a list that doesn't contain the item. - const isPersonal = scope.organizationId === null; + const orgScopes = deliveringMemberships.map((m) => ({ + organizationId: m.organizationId as string, + label: m.organization.name, + href: `/dashboard/organization/${m.organizationId}/requests`, + })); + const orgCounts = await Promise.all( + orgScopes.map(async (scope) => { const [consultations, subscriptions] = await Promise.all([ prisma.consultation.count({ - where: { - status: "PENDING", - consultationPlan: { consultantProfileId }, - ...(isPersonal - ? { - OR: [ - { appointment: null }, - { appointment: { organizationId: null } }, - ], - } - : { appointment: { organizationId: scope.organizationId } }), - }, + where: pendingConsultationWhere( + consultantProfileId, + scope.organizationId, + ), }), prisma.subscription.count({ - where: { - status: "PENDING", - subscriptionPlan: { consultantProfileId }, - appointments: isPersonal - ? { none: { organizationId: { not: null } } } - : { some: { organizationId: scope.organizationId } }, - }, + where: pendingSubscriptionWhere( + consultantProfileId, + scope.organizationId, + ), }), ]); - return { ...scope, pendingRequests: consultations + subscriptions, @@ -112,10 +124,18 @@ export async function getNeedsYouSummary( }), ); - const withWork = contexts.filter((c) => c.pendingRequests > 0); + const contexts = [ + { + organizationId: null, + label: "Personal", + href: `/dashboard/consultant/${consultantProfileId}/requests`, + pendingRequests: personalConsultations + personalSubscriptions, + }, + ...orgCounts, + ].filter((c) => c.pendingRequests > 0); return { - contexts: withWork, - total: withWork.reduce((sum, c) => sum + c.pendingRequests, 0), + contexts, + total: contexts.reduce((sum, c) => sum + c.pendingRequests, 0), }; } diff --git a/schemas/user.ts b/schemas/user.ts index a56df6071..e18fcfe31 100644 --- a/schemas/user.ts +++ b/schemas/user.ts @@ -1,5 +1,6 @@ // schemas/user.ts import { z } from "zod"; +import { UserRole } from "@prisma/client"; import { experienceValidation } from "./shared"; // #region Enums @@ -29,12 +30,10 @@ export const BudgetPreferenceEnum = z.enum([ export const SessionTypeEnum = z.enum(["ONE_ON_ONE", "GROUP", "ASYNC_REVIEW"]); -export const UserRoleEnum = z.enum([ - "CONSULTANT", - "CONSULTEE", - "ADMIN", - "STAFF", -]); +// Derived from Prisma, not hand-listed: this drifted when ORG_WORKSPACE landed, +// which made Partial unassignable to every step-form prop +// type in app/form/onboarding and forced a chain of casts at the call sites. +export const UserRoleEnum = z.nativeEnum(UserRole); export const ScheduleTypeEnum = z.enum(["WEEKLY", "CUSTOM"]); diff --git a/types/consultant-events.ts b/types/consultant-events.ts index 4de152c49..a8597ca74 100644 --- a/types/consultant-events.ts +++ b/types/consultant-events.ts @@ -61,6 +61,8 @@ export interface TConsultantDashboardResponse { appointments: TAppointment[]; activities: TConsultantActivity[]; approvals: TConsultantApproval[]; + /** Total pending requests — `approvals` is a capped preview, so don't count it. */ + pendingRequestsCount: number; performanceSnapshot: TPerformanceSnapshot; financialSummary: TFinancialSummary; } From 1c1d5917ed0d511d9fa54e6c8b410b551b46bfb5 Mon Sep 17 00:00:00 2001 From: Kaustav Ghosh <44238657+teetangh@users.noreply.github.com> Date: Mon, 3 Aug 2026 22:52:36 +0530 Subject: [PATCH 2/3] perf: stream consultant home behind Suspense boundaries (#1102) Splits the dashboard prefetch and NeedsYou roll-up into their own async components behind Suspense, and renders the page header as real text outside them. The server now emits a shell at ~500ms instead of producing nothing until all ~14 queries resolve. Measured on the deploy preview (same URL/account/route, warm): shell HTML moved from blocked-behind-data to ~500ms. FCP did NOT move (6240ms -> 6008ms), and that is expected: the dashboard layout is a client component that wraps children in StreamProvider with ssr:false, so the server renders a spinner instead of the subtree and

never reaches the HTML. Suspense can only stream markup the server is willing to produce. Restoring that SSR is the follow-up; this change is its prerequisite, because restoring SSR while the page still blocks on every query would just move the 4.9s wait into the HTML. Also fixes a real bug: the header title came from the session, which belongs to the VIEWER, so an ADMIN/STAFF inspecting another consultant's dashboard was greeted by their own name. Inspectors now get a neutral title. The auth gate stays outside the boundaries, prefetch and dehydrate stay in one component, and the page stays first in the file so the textual ownership-order assertion in personal-dashboard-ssr-ownership.test.ts still holds. --- .../(features)/home/HomePageClient.tsx | 12 +- .../(features)/home/HomeTab.tsx | 12 +- .../[consultantId]/(features)/home/page.tsx | 133 +++++++++++++----- components/dashboard/DashboardSkeletons.tsx | 22 ++- 4 files changed, 121 insertions(+), 58 deletions(-) diff --git a/app/dashboard/consultant/[consultantId]/(features)/home/HomePageClient.tsx b/app/dashboard/consultant/[consultantId]/(features)/home/HomePageClient.tsx index 84284082a..7ad6742b0 100644 --- a/app/dashboard/consultant/[consultantId]/(features)/home/HomePageClient.tsx +++ b/app/dashboard/consultant/[consultantId]/(features)/home/HomePageClient.tsx @@ -1,6 +1,6 @@ "use client"; -import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useQuery } from "@tanstack/react-query"; import { AlertCircle, Inbox } from "lucide-react"; import { DashboardErrorBoundary } from "@/components/DashboardErrorBoundary"; import { HomeSkeleton } from "@/components/dashboard/DashboardSkeletons"; @@ -13,12 +13,6 @@ import type { TConsultantDashboardResponse } from "@/types/consultant-events"; export default function HomePageClient({ consultantId, }: Readonly<{ consultantId: string }>) { - const queryClient = useQueryClient(); - - // Read consultant name from the cached profile (already fetched by layout) - const consultantProfile = queryClient.getQueryData<{ user?: { name?: string } }>(["consultant-data", consultantId]); - const consultantName = consultantProfile?.user?.name; - // Use the centralized query configuration with optimized settings for immediate rendering const dashboardQuery = { ...createConsultantQueries(consultantId).dashboard, @@ -37,7 +31,8 @@ export default function HomePageClient({ // Show skeleton only for initial load when no data exists if (isLoading && !dashboardData) { - return ; + // Header is owned by the server page now — see its comment on FCP. + return ; } if (error && !dashboardData) { @@ -83,7 +78,6 @@ export default function HomePageClient({ - - + {/* The header is rendered by the server page, outside the Suspense + boundary, so it can paint as real text while this tab is still + waiting on data. Keeping a copy here would double it up. */} ; }; +// The page stays FIRST in this file on purpose. The suspended sections below +// hold the reads, and __tests__/security/personal-dashboard-ssr-ownership.test.ts +// asserts the ownership guard appears ahead of them in source order. Runtime +// ordering is guaranteed regardless — the guard is awaited before the JSX +// naming those sections is returned, so neither can start early — but keeping +// the source in the same order keeps that invariant cheap to verify. export default async function HomePage({ params }: Readonly) { const { consultantId } = await params; // Ownership is enforced HERE, not by the layout: the layout is a client // component, so its check runs after this server render has already read // and streamed the data. See lib/auth/personal-dashboard-access.ts. + // + // This await deliberately stays OUTSIDE the Suspense boundaries. It is the + // authorization gate, and streaming chrome before it resolves would paint + // dashboard shell for someone who is about to be redirected. const access = await requirePersonalProfileAccess("consultant", consultantId); - const queryClient = new QueryClient(); - // Cross-context roll-up (ADR 19's sanctioned "derived read"). Skipped when an - // ADMIN/STAFF is inspecting someone else's dashboard: the summary keys off - // the VIEWER's memberships, which are not the profile owner's, so it would - // answer a question nobody asked. Failure is non-fatal — the card is - // supplementary and the page must not 500 because a count timed out. - // - // Run NeedsYou in parallel with the dashboard prefetch — they share no - // dependency and sequential awaits previously paid two full TTFB chains. - const needsYouPromise: Promise = access.isInspecting - ? Promise.resolve(null) - : getNeedsYouSummary(access.userId, consultantId).catch(() => null); + // Free: requirePersonalProfileAccess above already resolved this exact call, + // and getSession is React.cache'd per render, so both share one entry. + const session = await getSession(true); + // An ADMIN/STAFF inspecting someone else's dashboard would otherwise be + // greeted by their OWN name, since the session is the viewer's. The owner's + // name lives in the layout's cached profile, which is not available here + // without another round trip — so inspectors get a neutral title instead. + const firstName = access.isInspecting + ? null + : session?.user?.name?.split(" ")[0]; - // #890 — SSR prefetch the dashboard so the client useQuery hydrates - // without a fetch waterfall. Key MUST match - // createConsultantQueries(...).dashboard: ["consultant-dashboard", id]. - // The Home query is NOT org-scoped (route filters by consultantProfileId - // only), so there is a single deterministic payload to prefetch. - // allSettled so a read failure degrades to a client-side fetch rather - // than crashing the route. - const [, needsYou] = await Promise.all([ - Promise.allSettled([ - queryClient.prefetchQuery({ - queryKey: ["consultant-dashboard", consultantId], - queryFn: () => getConsultantDashboard(consultantId), - }), - ]), - needsYouPromise, - ]); + // Everything below streams. Measured before this change: the first byte + // already arrived at ~0.4s, but the response did not complete until ~4.9s + // and FCP landed at 6.2s, because the page awaited every query before + // returning any JSX. The queries are unchanged — they just no longer gate + // the shell. + return ( + <> + {/* Real text, rendered server-side outside every boundary. A skeleton + cannot trigger FCP — it has no text, image or SVG — which is why the + first pass moved the shell to 458ms and left FCP at ~6s anyway. */} + + {/* fallback={null}, not a skeleton: NeedsYouCard renders nothing for a + consultant with no org contexts, so a placeholder would flash a card + that then vanishes. */} + {!access.isInspecting && ( + + + + )} + }> + + + + ); +} + +/** + * Cross-context roll-up (ADR 19's sanctioned "derived read"). Rendered only for + * the profile owner: the summary keys off the VIEWER's memberships, which are + * not the owner's when an ADMIN/STAFF inspects, so it would answer a question + * nobody asked. Failure is non-fatal — the card is supplementary and the page + * must not 500 because a count timed out. + */ +async function NeedsYouSection({ + userId, + consultantId, +}: Readonly<{ userId: string; consultantId: string }>) { + const needsYou = await getNeedsYouSummary(userId, consultantId).catch( + () => null, + ); + if (!needsYou) return null; + return ( +
+ +
+ ); +} + +/** + * #890 — SSR prefetch the dashboard so the client useQuery hydrates without a + * fetch waterfall. Key MUST match createConsultantQueries(...).dashboard: + * ["consultant-dashboard", id]. The Home query is NOT org-scoped (the route + * filters by consultantProfileId only), so there is a single deterministic + * payload to prefetch. + * + * The prefetch and the dehydrate must stay together in this component: + * dehydrate only captures what has already resolved, so hoisting either half + * back into the page would serialize an empty cache. + */ +async function DashboardSection({ + consultantId, +}: Readonly<{ consultantId: string }>) { + const queryClient = new QueryClient(); + // Swallow, don't rethrow: a read failure should degrade to a client-side + // fetch rather than surfacing the Suspense error boundary for the whole tab. + await queryClient + .prefetchQuery({ + queryKey: ["consultant-dashboard", consultantId], + queryFn: () => getConsultantDashboard(consultantId), + }) + .catch(() => undefined); return ( - {needsYou && ( -
- -
- )}
); diff --git a/components/dashboard/DashboardSkeletons.tsx b/components/dashboard/DashboardSkeletons.tsx index 10f639e69..b43110318 100644 --- a/components/dashboard/DashboardSkeletons.tsx +++ b/components/dashboard/DashboardSkeletons.tsx @@ -187,14 +187,24 @@ export function RequestsSkeleton() { } // Home dashboard skeleton -export function HomeSkeleton() { +/** + * `withHeader={false}` when the caller already rendered the real header. Note + * that a skeleton is made of `Skeleton` boxes with no text, image or SVG, so it + * cannot trigger First Contentful Paint — measured on #1102, where the shell + * HTML arrived at 458ms but FCP still waited ~6s for real text. If a surface + * needs an early FCP, it has to render actual text, not a placeholder for it. + */ +export function HomeSkeleton({ + withHeader = true, +}: Readonly<{ withHeader?: boolean }> = {}) { return (
- {/* Header */} -
- - -
+ {withHeader && ( +
+ + +
+ )} {/* Content */}
From 8ba7e4362b273956fa045b353b4755b6cb81a6e6 Mon Sep 17 00:00:00 2001 From: Kaustav Ghosh <44238657+teetangh@users.noreply.github.com> Date: Mon, 3 Aug 2026 23:33:20 +0530 Subject: [PATCH 3/3] perf: restore dashboard SSR by scoping the Stream SDK contexts (#1103) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit StreamProvider wrapped {children} in a next/dynamic component with ssr:false, which skips server rendering for the component AND its children. The connector now renders null as a sibling and publishes state to a module store read via useSyncExternalStore, so children sit in a fixed position. That also removes the once-per-session remount documented in StreamProviderImpl (children -> -> as the sockets settled), which tore down the whole dashboard — the storm behind 'I pressed Join ten times' (#248). The SDK contexts move to the surfaces that consume them: to the three Messages tabs, to /meetings. A completeness sweep caught a latent bug this scoping would otherwise have introduced: useEventActions:115 powers Join on consultee and org appointments, routes that no longer mount , so every Join there would have failed. It now reads the singleton at click time via getGlobalVideoClient(). Does NOT improve FCP, and is not merged as a perf claim. Measured on the preview, the server HTML still contains no

and no layout nav: the client layout returns PersonalDashboardShellSkeleton instead of children while its queries load, which is always during SSR because consultant-data is never server-prefetched. That is the dominant blocker and the next PR. Verified on the preview: consultant Messages (channels render, unread badge live, no console errors), consultant Appointments, and /meetings mounting without a context crash. --- .../(features)/messages/MessagesTab.tsx | 5 +- .../(features)/messages/MessagesTab.tsx | 5 +- .../[orgId]/messages/MessagesClient.tsx | 7 +- app/meetings/layout.tsx | 6 +- .../ConsulteeAppointmentsAdapter.tsx | 7 +- .../appointments/consultee/useEventActions.ts | 6 +- components/stream/StreamChatScope.tsx | 43 +++++++ components/stream/StreamVideoScope.tsx | 40 ++++++ lib/stream/connection-store.ts | 79 ++++++++++++ providers/StreamProvider.tsx | 110 ++++++++-------- providers/StreamProviderImpl.tsx | 118 ++++++------------ 11 files changed, 288 insertions(+), 138 deletions(-) create mode 100644 components/stream/StreamChatScope.tsx create mode 100644 components/stream/StreamVideoScope.tsx create mode 100644 lib/stream/connection-store.ts diff --git a/app/dashboard/consultant/[consultantId]/(features)/messages/MessagesTab.tsx b/app/dashboard/consultant/[consultantId]/(features)/messages/MessagesTab.tsx index 5facfcf03..bb4878454 100644 --- a/app/dashboard/consultant/[consultantId]/(features)/messages/MessagesTab.tsx +++ b/app/dashboard/consultant/[consultantId]/(features)/messages/MessagesTab.tsx @@ -4,6 +4,7 @@ import { Loader2 } from "lucide-react"; import { ChatLayout } from "@/components/chat/ChatLayout"; import { ChatUnavailable } from "@/components/chat/ChatUnavailable"; import { useStreamConnection } from "@/providers/StreamProvider"; +import { StreamChatScope } from "@/components/stream/StreamChatScope"; interface MessagesTabProps { userId: string; @@ -24,7 +25,9 @@ export function MessagesTab({ userId: _userId }: Readonly) { {error ? ( ) : chatConnected ? ( - + + + ) : (
diff --git a/app/dashboard/consultee/[consulteeId]/(features)/messages/MessagesTab.tsx b/app/dashboard/consultee/[consulteeId]/(features)/messages/MessagesTab.tsx index 7f0c3b8fa..80e0bc668 100644 --- a/app/dashboard/consultee/[consulteeId]/(features)/messages/MessagesTab.tsx +++ b/app/dashboard/consultee/[consulteeId]/(features)/messages/MessagesTab.tsx @@ -4,6 +4,7 @@ import { Loader2 } from "lucide-react"; import { ChatLayout } from "@/components/chat/ChatLayout"; import { ChatUnavailable } from "@/components/chat/ChatUnavailable"; import { useStreamConnection } from "@/providers/StreamProvider"; +import { StreamChatScope } from "@/components/stream/StreamChatScope"; /** * Full-bleed chat surface: cancels PageScaffold padding and fills the @@ -20,7 +21,9 @@ export default function MessagesTab() { {error ? ( ) : chatConnected ? ( - + + + ) : (
diff --git a/app/dashboard/organization/[orgId]/messages/MessagesClient.tsx b/app/dashboard/organization/[orgId]/messages/MessagesClient.tsx index 6d377fb2d..18ef986e8 100644 --- a/app/dashboard/organization/[orgId]/messages/MessagesClient.tsx +++ b/app/dashboard/organization/[orgId]/messages/MessagesClient.tsx @@ -3,6 +3,7 @@ import { Loader2 } from "lucide-react"; import { ChatLayout } from "@/components/chat/ChatLayout"; +import { StreamChatScope } from "@/components/stream/StreamChatScope"; import { ChatUnavailable } from "@/components/chat/ChatUnavailable"; import { useStreamConnection } from "@/providers/StreamProvider"; @@ -38,5 +39,9 @@ export function MessagesClient() { ); } - return ; + return ( + + + + ); } diff --git a/app/meetings/layout.tsx b/app/meetings/layout.tsx index 9e0d574e4..2601b0595 100644 --- a/app/meetings/layout.tsx +++ b/app/meetings/layout.tsx @@ -1,5 +1,6 @@ import "@stream-io/video-react-sdk/dist/css/styles.css"; import StreamProvider from "@/providers/StreamProvider"; +import { StreamVideoScope } from "@/components/stream/StreamVideoScope"; import { requireOnboarded } from "@/lib/auth-guard"; export default async function MeetingsLayout({ @@ -16,7 +17,10 @@ export default async function MeetingsLayout({ enableChat={false} enableVideo={true} > - {children} + {/* /meetings is the video surface, so the SDK context is mounted for the + whole route rather than per-page. StreamProvider no longer supplies it + — see components/stream/StreamVideoScope. */} + {children} ); } diff --git a/components/appointments/consultee/ConsulteeAppointmentsAdapter.tsx b/components/appointments/consultee/ConsulteeAppointmentsAdapter.tsx index bfc471bf9..3de3dd631 100644 --- a/components/appointments/consultee/ConsulteeAppointmentsAdapter.tsx +++ b/components/appointments/consultee/ConsulteeAppointmentsAdapter.tsx @@ -3,7 +3,7 @@ import { useState } from "react"; import * as Sentry from "@sentry/nextjs"; import { useParams, useRouter } from "next/navigation"; -import { useStreamVideoClient } from "@stream-io/video-react-sdk"; +import { getGlobalVideoClient } from "@/lib/stream/disconnect"; import { useQueryClient } from "@tanstack/react-query"; import { useToast } from "@/hooks/use-toast"; @@ -113,7 +113,6 @@ const KIND_TO_REPORT_TYPE: Record< export function useConsulteeAppointmentsAdapter(): AppointmentActionAdapter { const router = useRouter(); const { toast } = useToast(); - const client = useStreamVideoClient(); const { data: session } = useSession(); const queryClient = useQueryClient(); const params = useParams<{ consulteeId: string }>(); @@ -144,6 +143,10 @@ export function useConsulteeAppointmentsAdapter(): AppointmentActionAdapter { // Join can't go through useEventActions — its args follow activeVm state, // which wouldn't be committed yet on a same-click join from a row. const joinNow = async (vm: AppointmentVM, slot: SlotLike) => { + // Read the singleton at click time rather than via useStreamVideoClient: + // the SDK context is now scoped to /meetings, and this is the same instance + // would hand back. Matches the #248 lazy-join idiom. + const client = getGlobalVideoClient(); if (!client) { toast({ title: "Not signed in", diff --git a/components/appointments/consultee/useEventActions.ts b/components/appointments/consultee/useEventActions.ts index 075c85039..76b8dd422 100644 --- a/components/appointments/consultee/useEventActions.ts +++ b/components/appointments/consultee/useEventActions.ts @@ -5,7 +5,7 @@ import * as Sentry from "@sentry/nextjs"; import { useToast } from "@/hooks/use-toast"; import { useParams, useRouter } from "next/navigation"; import { useQueryClient } from "@tanstack/react-query"; -import { useStreamVideoClient } from "@stream-io/video-react-sdk"; +import { getGlobalVideoClient } from "@/lib/stream/disconnect"; import { getOrCreateAppointmentMeeting } from "@/lib/meeting"; import type { TAppointment } from "@/types/appointment"; import type { SlotOfAppointment } from "@prisma/client"; @@ -112,7 +112,6 @@ export function useEventActions({ }: UseEventActionsOptions) { const { toast } = useToast(); const router = useRouter(); - const client = useStreamVideoClient(); const queryClient = useQueryClient(); const params = useParams<{ consulteeId: string }>(); const consulteeId = params?.consulteeId; @@ -310,6 +309,9 @@ export function useEventActions({ const handleJoinSession = async (forceSlot?: SlotOfAppointment) => { const slotToUse = forceSlot || getJoinableSlot(); + // Singleton at click time: the SDK context is scoped to /meetings now, and + // this is the same instance would return (#248 idiom). + const client = getGlobalVideoClient(); if (!client) { toast({ title: "Not signed in", diff --git a/components/stream/StreamChatScope.tsx b/components/stream/StreamChatScope.tsx new file mode 100644 index 000000000..e3eda1c71 --- /dev/null +++ b/components/stream/StreamChatScope.tsx @@ -0,0 +1,43 @@ +"use client"; + +import dynamic from "next/dynamic"; +import { useSyncExternalStore } from "react"; +import { + getStreamConnectionServerSnapshot, + getStreamConnectionSnapshot, + subscribeStreamConnection, +} from "@/lib/stream/connection-store"; + +/** + * Mounts the Stream `` context around a chat surface. + * + * This used to wrap the WHOLE dashboard from StreamProvider, which cost two + * things: `ssr: false` on that wrapper meant no dashboard markup was ever + * server-rendered, and the wrapper appearing once the socket settled changed + * the element type at that position and remounted everything under it (#248). + * + * Scoping it here is safe because every `useChatContext` consumer lives under + * `components/chat/`. The sidebar's unread badge is deliberately NOT one of + * them — `hooks/useChatUnreadCount` reads the `StreamChat` singleton directly + * and documents that it works outside the provider. + * + * Renders children unwrapped until the client connects; chat consumers already + * guard a null client, and this keeps the surface visible while connecting. + */ +const ChatProvider = dynamic( + () => import("stream-chat-react").then((m) => ({ default: m.Chat })), + { ssr: false }, +); + +export function StreamChatScope({ + children, +}: Readonly<{ children: React.ReactNode }>) { + const { clients } = useSyncExternalStore( + subscribeStreamConnection, + getStreamConnectionSnapshot, + getStreamConnectionServerSnapshot, + ); + + if (!clients?.chat) return <>{children}; + return {children}; +} diff --git a/components/stream/StreamVideoScope.tsx b/components/stream/StreamVideoScope.tsx new file mode 100644 index 000000000..d1317b0f6 --- /dev/null +++ b/components/stream/StreamVideoScope.tsx @@ -0,0 +1,40 @@ +"use client"; + +import dynamic from "next/dynamic"; +import { useSyncExternalStore } from "react"; +import { + getStreamConnectionServerSnapshot, + getStreamConnectionSnapshot, + subscribeStreamConnection, +} from "@/lib/stream/connection-store"; + +/** + * Mounts the Stream `` context around a video surface. + * + * Sibling of StreamChatScope — see that file for why these contexts no longer + * wrap the entire dashboard. Video consumers (`useStreamVideoClient`, `useCall`) + * are confined to `/meetings` plus the consultee appointments adapter. + * + * Renders children unwrapped until the client connects; the existing video + * consumers already guard a null client. + */ +const VideoProvider = dynamic( + () => + import("@stream-io/video-react-sdk").then((m) => ({ + default: m.StreamVideo, + })), + { ssr: false }, +); + +export function StreamVideoScope({ + children, +}: Readonly<{ children: React.ReactNode }>) { + const { clients } = useSyncExternalStore( + subscribeStreamConnection, + getStreamConnectionSnapshot, + getStreamConnectionServerSnapshot, + ); + + if (!clients?.video) return <>{children}; + return {children}; +} diff --git a/lib/stream/connection-store.ts b/lib/stream/connection-store.ts new file mode 100644 index 000000000..943e074b3 --- /dev/null +++ b/lib/stream/connection-store.ts @@ -0,0 +1,79 @@ +import type { StreamChat } from "stream-chat"; +import type { StreamVideoClient } from "@stream-io/video-react-sdk"; + +/** + * Module-level store for the Stream connection, read via `useSyncExternalStore`. + * + * Why a store and not React state: the provider used to WRAP `children` and + * swap the wrapper set once the sockets settled (`children` → `` → + * ``). React tears down a subtree when the element type at a position + * changes, so that swap remounted the whole dashboard — the remount storm + * behind "I pressed Join ten times" (#248). Publishing to a store instead lets + * the connector render `null` as a SIBLING of `children`, so nothing above the + * dashboard ever changes shape. + * + * It also un-blocks SSR. The connector is still `ssr: false`, but `ssr: false` + * skips server rendering for the component AND its children — so while it + * wrapped the dashboard, no dashboard markup reached the HTML at all. Measured + * on #1102: ` void>(); + +export function subscribeStreamConnection(listener: () => void): () => void { + listeners.add(listener); + return () => listeners.delete(listener); +} + +export function getStreamConnectionSnapshot(): StreamConnectionSnapshot { + return snapshot; +} + +/** + * Server snapshot must be a STABLE reference, not a fresh object — React calls + * this during SSR and would loop forever on a new identity each time. + */ +export function getStreamConnectionServerSnapshot(): StreamConnectionSnapshot { + return INITIAL; +} + +export function setStreamConnection( + patch: Partial, +): void { + const next = { ...snapshot, ...patch }; + // Bail on no-op writes so consumers do not re-render on every heartbeat. + const unchanged = ( + Object.keys(next) as (keyof StreamConnectionSnapshot)[] + ).every((k) => next[k] === snapshot[k]); + if (unchanged) return; + snapshot = next; + for (const l of listeners) l(); +} + +/** Test/logout helper — drops connection state without touching the clients. */ +export function resetStreamConnection(): void { + snapshot = INITIAL; + for (const l of listeners) l(); +} diff --git a/providers/StreamProvider.tsx b/providers/StreamProvider.tsx index 7180e03cb..595b29cb9 100644 --- a/providers/StreamProvider.tsx +++ b/providers/StreamProvider.tsx @@ -1,7 +1,12 @@ "use client"; -import { createContext, useContext } from "react"; +import { createContext, useCallback, useContext, useSyncExternalStore } from "react"; import dynamic from "next/dynamic"; +import { + getStreamConnectionServerSnapshot, + getStreamConnectionSnapshot, + subscribeStreamConnection, +} from "@/lib/stream/connection-store"; // Re-export the logout helper from the SDK-free module so existing importers of // `disconnectStreamClients` from this path keep working unchanged. Callers that @@ -10,10 +15,8 @@ import dynamic from "next/dynamic"; export { disconnectStreamClients } from "@/lib/stream/disconnect"; // ── Connection-state context ──────────────────────────────────────────────── -// Defined in this SDK-free shell (not the heavy impl) so consumers like -// DebugDialog can import useStreamConnection without pulling the SDK, and so the -// context identity is stable across the lazy boundary. The heavy impl imports -// this same context and pushes the live value into it once connected. +// Defined in this SDK-free shell (not the heavy connector) so consumers like +// DebugDialog can import useStreamConnection without pulling the SDK. export interface StreamConnectionState { chatConnected: boolean; @@ -23,11 +26,6 @@ export interface StreamConnectionState { retryConnection: () => void; } -// Default (impl-not-yet-loaded) connection state. Returned by -// useStreamConnection while the dynamic impl is still loading so consumers never -// crash during the lazy-load window. Previously useStreamConnection threw when -// used outside the provider; the dashboard never relied on that throw in prod, -// and a safe default is required now that the provider is lazy. const DEFAULT_CONNECTION_STATE: StreamConnectionState = { chatConnected: false, videoConnected: false, @@ -51,52 +49,58 @@ export interface StreamProviderProps { enableVideo?: boolean; } -// Loading strategy (deliberate, per-scenario — NOT blanket lazy): -// -// • SDK code-split (lazy chunk): YES. The Stream SDK is heavy and the dashboard -// LANDING route is always /home, which renders no chat/video UI. Splitting the -// SDK + its two stylesheets into a chunk keeps them out of /home's synchronous -// bundle, so /home parses/paints without paying for the SDK up front. -// -// • When does that chunk actually load? On dashboard routes the provider is -// mounted by the LAYOUT, so the chunk begins downloading on /home too — but -// IN PARALLEL, off the critical bundle, not blocking first paint. This is the -// right call (deferred/parallel, not "only when chat opens") because the -// consultant sidebar shows a chat-unread badge on every route incl. /home, -// which needs the chat client connected. Gating the whole provider behind -// "user opened chat" would break that badge. The actual connect (websocket) -// is further deferred to requestIdleCallback inside the impl (see #248 there). -// -// • ssr:false: the SDK is browser-only (websockets/WebRTC); SSR-ing it is wasted -// server work and hydration mismatch risk. -// -// Children are rendered through the impl, which renders them DIRECTLY even before -// connection completes (it no longer blocks on a spinner until connected). During -// the brief chunk-download window the fallback below shows — this is strictly -// shorter than the previous behavior, which blocked children until BOTH clients -// finished their websocket handshake. -function StreamProviderLoading() { - return ( -
-
-
- ); -} - -const LazyStreamProviderImpl = dynamic( - () => import("@/providers/StreamProviderImpl"), - { ssr: false, loading: () => }, -); +// The connector holds the SDK + the websocket lifecycle and renders NOTHING. +// ssr:false keeps the browser-only SDK off the server, and because it no longer +// wraps `children`, that no longer costs the dashboard its server rendering. +const StreamConnector = dynamic(() => import("@/providers/StreamProviderImpl"), { + ssr: false, +}); /** - * SDK-free shell. Renders children through the lazily-loaded implementation, - * which provides the chat/video contexts + connection lifecycle once its chunk - * loads. The impl renders children directly even before connection completes; - * video consumers already guard a null client, and chat consumers only render on - * the chat route (under ). + * SDK-free shell. Renders `children` DIRECTLY — server-side included — and + * mounts the connector as a sibling. + * + * Two bugs this shape exists to prevent, both measured: + * + * 1. `ssr: false` skips server rendering for the component AND its children. + * While the connector wrapped the dashboard, no dashboard markup reached + * the HTML: `` → ``). A changed element type at a + * position remounts that whole subtree — the storm behind "I pressed Join + * ten times" (#248). Children now sit in a fixed position forever. + * + * The SDK's own `` / `` contexts are mounted by the surfaces + * that actually consume them (the Messages tabs and /meetings), not here. */ -const StreamProvider = (props: StreamProviderProps) => { - return ; +const StreamProvider = ({ children, ...connectorProps }: StreamProviderProps) => { + const snapshot = useSyncExternalStore( + subscribeStreamConnection, + getStreamConnectionSnapshot, + getStreamConnectionServerSnapshot, + ); + + const retryConnection = useCallback(() => { + // The connector owns the retry loop; it listens for this event so the + // shell does not have to import anything from the SDK bundle to expose it. + window.dispatchEvent(new CustomEvent("stream:retry-connection")); + }, []); + + const value: StreamConnectionState = { + chatConnected: snapshot.chatConnected, + videoConnected: snapshot.videoConnected, + isConnecting: snapshot.isConnecting, + error: snapshot.error, + retryConnection, + }; + + return ( + + {children} + + + ); }; export default StreamProvider; diff --git a/providers/StreamProviderImpl.tsx b/providers/StreamProviderImpl.tsx index a05d093a8..e844b104c 100644 --- a/providers/StreamProviderImpl.tsx +++ b/providers/StreamProviderImpl.tsx @@ -12,8 +12,7 @@ import { useCallback, useEffect, useState, useRef } from "react"; import { StreamChat } from "stream-chat"; -import { Chat } from "stream-chat-react"; -import { StreamVideo, StreamVideoClient } from "@stream-io/video-react-sdk"; +import { StreamVideoClient } from "@stream-io/video-react-sdk"; import { chatTokenProvider, tokenProvider, @@ -23,12 +22,18 @@ import { syncUserEventChannels } from "@/actions/stream/chat/event-channel.actio import { useUserData } from "@/hooks/useUserData"; import { mapRoleToStream } from "@/lib/user"; import { streamLogger } from "@/lib/stream-logger"; -import StreamErrorBoundary from "@/components/stream/StreamErrorBoundary"; -import { - StreamConnectionContext, - type StreamConnectionState, - type StreamProviderProps, -} from "@/providers/StreamProvider"; +import { setStreamConnection } from "@/lib/stream/connection-store"; + +/** + * The connector takes no `children`. It renders nothing and publishes the + * connection to the store instead — see lib/stream/connection-store.ts for why + * (SSR of the dashboard subtree, and the remount storm). + */ +export interface StreamConnectorProps { + userId: string; + enableChat?: boolean; + enableVideo?: boolean; +} // Shared module-level client refs now live in an SDK-free module so SDK-free // callers can disconnect on logout without linking the Stream SDK. #248 import { @@ -71,11 +76,10 @@ interface SettledStreamClients { } const StreamProviderImpl = ({ - children, userId, enableChat = true, enableVideo = true, -}: StreamProviderProps) => { +}: StreamConnectorProps) => { const [clients, setClients] = useState(null); const [chatConnected, setChatConnected] = useState(false); const [videoConnected, setVideoConnected] = useState(false); @@ -84,7 +88,7 @@ const StreamProviderImpl = ({ // We need BOTH a ref and state for retry count: the ref (connectionAttemptsRef) // is used inside setTimeout/async closures where state would be stale, while // this state variable drives re-renders so the UI shows the correct attempt count. - const [retryCount, setRetryCount] = useState(0); + const [, setRetryCount] = useState(0); // Use ref for connection attempts to avoid stale closures in retry logic const connectionAttemptsRef = useRef(0); @@ -493,72 +497,32 @@ const StreamProviderImpl = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [userDetails?.id, isLoading, connectServices]); - // Connection state for context - const connectionState: StreamConnectionState = { - chatConnected, - videoConnected, - isConnecting, - error, - retryConnection, - }; - - // #248: do NOT block children render on connection. Previously this returned - // a full-screen spinner until both clients connected, which gated the entire - // dashboard route (incl. home) behind the Stream connect-storm. We now always - // render children immediately; the Stream context providers wrap them once the - // clients are ready, and the video/chat consumers already guard a null client. - - // The wrapper set is derived from ONE settled value and nested in a fixed - // order — Chat outside, StreamVideo inside — so the shape here is a pure - // function of `clients` rather than of which socket won the race. In the - // normal case that means exactly one shape change for the whole session: - // unwrapped while connecting, then wrapped once both connects settle. - // - // A connect that genuinely FAILS still costs a second change if a later retry - // succeeds. That is accepted: it is a degraded path, the retry loop is capped - // at 5 attempts, and withholding the client that did connect would break the - // sidebar's chat-unread badge on every route (#248). - let content = children; - - if (clients?.video) { - content = {content}; - } - - if (clients?.chat) { - content = {content}; - } - - // The connection-failed banner renders alongside children (not in place of - // them) so the underlying route stays usable while Stream retries/recovers. - return ( - { - streamLogger.error("Stream Provider Error", error, { - componentStack: errorInfo.componentStack, - }); - setError(error.message); - }} - enableRetry={true} - > - - {error && retryCount >= 5 && ( -
-
-

Connection Failed

-

{error}

- -
-
- )} - {content} -
-
- ); + // Publish to the store rather than wrapping children. The wrapper set used to + // be derived here — `children` → `` → `` — which changed + // the element type at that position once the sockets settled and remounted + // the whole dashboard (#248). The SDK contexts are now mounted by the + // surfaces that consume them; this component only reports state. + useEffect(() => { + setStreamConnection({ + clients, + chatConnected, + videoConnected, + isConnecting, + error, + }); + }, [clients, chatConnected, videoConnected, isConnecting, error]); + + // The shell exposes `retryConnection` without importing the SDK bundle, so it + // asks for a retry by event rather than by calling into here directly. + useEffect(() => { + const onRetry = () => retryConnection(); + window.addEventListener("stream:retry-connection", onRetry); + return () => window.removeEventListener("stream:retry-connection", onRetry); + }, [retryConnection]); + + // Renders nothing: it is a sibling of `children`, not a wrapper. Consumers + // read connection state from the context in providers/StreamProvider.tsx. + return null; }; export default StreamProviderImpl;