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 (
+
-
+
>
)}
-
>
);
};
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;