diff --git a/.claude/skills/nextjs-netlify-caching/SKILL.md b/.claude/skills/nextjs-netlify-caching/SKILL.md
new file mode 100644
index 000000000..126da4707
--- /dev/null
+++ b/.claude/skills/nextjs-netlify-caching/SKILL.md
@@ -0,0 +1,242 @@
+---
+name: nextjs-netlify-caching
+description: Decide and verify how a Next.js App Router route renders, caches and revalidates on Netlify — static vs ISR vs dynamic, revalidate windows, on-demand purge, the Netlify durable cache, and the specific traps that make a route silently dynamic or silently un-server-rendered. Use when adding or changing `export const dynamic` / `export const revalidate` / `generateStaticParams`, when a page is slow or its TTFB/FCP is bad, when SSR output looks empty, when asked "should this be ISR", or before claiming any rendering or caching change actually worked.
+---
+
+# Next.js App Router rendering and caching on Netlify
+
+This skill encodes what was measured on this codebase during the 2026-07/08 dashboard-performance campaign, not what the framework documentation promises. Where the two disagree, the measurement is recorded and marked. Version-sensitive facts are pinned to Next 15.5.15 and the Netlify Next Runtime. Re-verify them after **any** change to the Next version or the Netlify adapter/runtime, not only a major one — the adapter ships independently of our releases and is not version-pinned in this repo, so its behaviour can change under a build we did not trigger. Record the exact versions alongside any new measurement you add.
+
+## The rule that outranks everything else in this file
+
+Every confident claim made by reasoning about code structure, without measuring, was wrong at least once during this campaign. Every measured claim held. Green CI is not verification: `tsc`, ESLint and the full Jest suite were all green on a build that failed, on a route that returned 500 on every request, and on three separate changes that moved no metric at all.
+
+Therefore, never report a rendering or caching change as working until you have observed the specific artefact listed under "How to verify" below. If you cannot observe it, say the change is unverified rather than describing what should happen.
+
+## Step 1 — Classify the route before touching anything
+
+Ask what the response depends on, because that determines the strategy and nothing else does.
+
+| The response varies by | Correct strategy | What to export |
+|---|---|---|
+| Nothing; it is the same for every visitor | Static, or ISR if the content changes | `export const revalidate = N` (omit for fully static) |
+| Public data that changes on a human timescale | ISR with a revalidate window | `export const revalidate = N` |
+| A high-cardinality or unbounded path parameter over public data | On-demand ISR | `export const revalidate = N` **and** `generateStaticParams()` returning `[]` |
+| The signed-in user, their role, their organisation, or their money | Dynamic | `export const dynamic = "force-dynamic"` |
+
+Two corollaries that are easy to get wrong on this codebase.
+
+A page whose data is public but whose *chrome* differs for signed-in visitors is still a static page. The auth-dependent affordance belongs in a **client-only** component that resolves the session in the browser, not in the page's server render. Note that a server-rendered "dynamic island" is not an option on our pinned version: without PPR, a request-scoped read inside a `Suspense` boundary still forces the whole route dynamic — see the rejected-options section. Pulling a session read into the page body to swap one button converts the whole route to dynamic and forfeits the cache.
+
+A route parameter with unbounded cardinality must never be prerendered exhaustively at build. Returning `[]` from `generateStaticParams` means nothing is built ahead of time, each parameter renders on its first request, and the result is then cached and revalidated on the window. Confirm `dynamicParams` is left at its default of `true`, or unlisted parameters will 404 instead of rendering.
+
+## Step 2 — Know what silently pins a route to dynamic
+
+Reading any request-scoped API anywhere in a route's server render tree forces that route dynamic regardless of what it exports. The route will keep rendering as `ƒ` and your `revalidate` will be inert, with no error and no warning. The APIs that do this are `cookies()`, `headers()`, `draftMode()`, `connection()`, and `searchParams`.
+
+The read does not have to be visible in the page file. A shared helper, an auth guard, or a data-layer function three imports deep will do it. Before adding `revalidate` to any route, trace its full server import graph for those five APIs and report what you found per route.
+
+On this codebase specifically, note that an `export const revalidate` on a route that is already dynamic produces an **empty** Revalidate column in the build route table. That empty column is the tell that the export did nothing. It was exactly the defect in the first shape of PR #1110, where the two heaviest reads carried a revalidate that never applied.
+
+## Step 2b — Your `revalidate` is a ceiling, not a setting
+
+A route's effective revalidate is the **minimum** of its segment-level `revalidate` and every data-cache entry read during that render. A short `unstable_cache` window deep in the data layer silently caps the whole route, with no warning.
+
+This was measured on #1110: `/` declared `revalidate = 3600` and the build table reported `2m`, because 120-second `unstable_cache` windows in `lib/data/home.ts` were pulling it down. Raising those windows to match was what made the declared value real. Whenever the build table shows a revalidate you did not ask for, this is the first thing to check.
+
+## Step 2c — Read the route-table symbols precisely
+
+The build table distinguishes three states, and conflating the last two wastes time. `○` means prerendered as static HTML at build. `●` means prerendered as static HTML *using `generateStaticParams`*. `ƒ` means rendered on demand.
+
+A `[param]` route with `generateStaticParams` returning `[]` renders as `●` with an **empty Revalidate column**, and this is correct rather than broken. The Revalidate column is populated from prerendered entries, and returning `[]` deliberately produces none, so a non-empty column there and an empty build-time prerender list are mutually exclusive by construction. Do not chase it.
+
+To confirm ISR really is active on such a route, verify at runtime instead: request it on a deploy preview and check that `cache-control` is `public` rather than the dynamic route's `private, no-cache, no-store`, then request it again and confirm the `age` header climbs. A climbing `age` is one cached object aging, which is the proof; a dynamic control route requested alongside it makes the comparison airtight.
+
+## Step 3 — Understand what build-time prerendering costs here
+
+A route that renders `○` in the build route table executes its Server Component data reads **inside `next build`**. On this project that has three consequences worth stating plainly.
+
+The build must reach Supabase. CI decodes a real `.env` before building, so it does. Issue #932 records a build that crashed on a cold cross-region pooler connect, so this is a demonstrated failure mode rather than a theoretical one.
+
+Every Netlify context — deploy preview, branch deploy and production — shares one Supabase database. Build-time reads therefore touch production data from preview builds. This is a real consequence of choosing prerendering and belongs in any PR description that introduces it.
+
+Worst of all, a swallowed error at build time gets frozen into the output. This codebase's `fallbackOnTransientDbError` used to return empty on a transient failure, and the empty result was baked into static HTML. The ISR cache turned out to be the same hazard as build output, so since #1123 the helper rethrows unless a call site opts in with `perRequest` — see "Fail-open and a cacheable response" below.
+
+The guard for this is to rethrow during the build phase, converting a silent bad bake into a visible, retryable build failure:
+
+```ts
+import { PHASE_PRODUCTION_BUILD } from "next/constants";
+
+// A swallowed transient error at build time is baked into static HTML. Fail
+// loudly instead — a retried build is cheap, a silently empty page is not. #932
+if (process.env.NEXT_PHASE === PHASE_PRODUCTION_BUILD) throw err;
+```
+
+`PHASE_PRODUCTION_BUILD` is verified present in `next/constants` at Next 15.5.15 with the value `phase-production-build`. Import the constant rather than hardcoding the string. Pair the guard with a bounded retry and backoff, because #932 was a *cold* connect and a retry may therefore help. That it converts a hard failure into a success is untested — no measurement here exercised the retry path — so treat it as a hypothesis and measure before claiming it works.
+
+## Step 4 — Cache headers follow the strategy, and you do not choose them
+
+Next.js sets `Cache-Control` purely from the rendering strategy of each route. You cannot negotiate with this from inside the app.
+
+| Strategy | Header Next.js emits |
+|---|---|
+| Static, no revalidation | `s-maxage=31536000` |
+| ISR, time-based revalidation | `s-maxage={revalidate}, stale-while-revalidate={expire - revalidate}` |
+| Dynamic | `private, no-cache, no-store, max-age=0, must-revalidate` |
+
+The third row is the whole argument against `force-dynamic` on public pages: it makes them uncacheable at every CDN by construction, so each visitor pays a full origin round trip. On this deployment that is Netlify `ap-southeast-1` to Supabase `ap-south-1`, and the tail was measured at 30–33 seconds. See "What the 23-second TTFB actually decomposes into" below — the cost is a ~24 second event-loop stall on a newly created instance, not the round trip and not the boot.
+
+The converse is the strongest argument for prerendering public pages: a prerendered page is served as static HTML from the edge with **no function invocation at all**, so it also sidesteps the cold start entirely on the pages where LCP matters most.
+
+## Step 5 — What Netlify adds on top
+
+The Netlify Next Runtime implements Next's cache handler against Netlify Blobs, so both the Full Route Cache and the Data Cache are durable and shared across every function invocation and CDN node rather than being per-instance. Cacheable responses on Runtime 5.5.0 and later automatically use the durable cache, and an edge node without a local copy checks the durable cache before invoking a function. This means `unstable_cache` and ISR results genuinely persist between requests here — do not assume serverless per-instance memory semantics.
+
+When you need to cache a response Next would mark uncacheable, Netlify honours cache-control headers in order of specificity: `Netlify-CDN-Cache-Control` wins over `CDN-Cache-Control`, which wins over `Cache-Control`. Both `s-maxage` and `stale-while-revalidate` are supported, and `Cache-Control` and `CDN-Cache-Control` are always passed downstream so other caches can use them. Function responses are not cached by default precisely because they are dynamic, so caching them is an explicit opt-in via those headers.
+
+Only reach for that opt-in on a response that is safe to share between users. These headers target a **shared** cache, so applying them to a response whose body depends on `cookies()`, `headers()` or session state will serve one visitor's page to the next one. If the response varies by any request input, that input must appear in the cache key via `Netlify-Vary` before the override is safe; if it varies by identity, it should stay private and uncached. Note that this failure is silent and will not reproduce for the first user who loads the page.
+
+For invalidation, `revalidatePath` and `revalidateTag` both work and propagate through the durable cache. A copy cached at the CDN purely by an `s-maxage` header is a different matter: `revalidateTag` invalidates the Next server cache but the CDN keeps serving its copy until the TTL expires, so a raw CDN cache needs `Netlify-Cache-Tag` on the response and a `purgeCache({ tags })` call alongside the revalidation.
+
+Documented adapter limitations worth remembering: pages set to the `edge` runtime actually run in the functions region, `beforeFiles` rewrites cannot point at static files in `public/`, and headers and redirects are evaluated after middleware.
+
+## How to verify — the only four techniques that have worked here
+
+**Read the CI build route table.** This is authoritative for *build-time* classification — whether a route prerenders at build, and what revalidate the build applied — and it settled the #1110 dispute definitively. It is not authoritative for on-demand ISR, where a `●` route legitimately shows an empty Revalidate column and no build entries; confirm those at runtime from `cache-control` and a climbing `age` instead, as described in Step 2c. `○` means prerendered at build; `ƒ` means dynamic; the Revalidate column shows whether a `revalidate` export actually applied. Pull it from the `TypeScript, Tests & Build` check run with `gh run view --log` and paste the actual lines into your report. Never run `next build` locally — it is RAM-heavy and has taken this machine down.
+
+**Stream the HTML and grep for markup that should be present.** Distinguish real markup (`">Members<"`) from the RSC flight payload (the bare string `Members`). If the string appears only in the payload, the content was **not** server-rendered, however much it looks present in the browser. When the bare-string count equals the markup count, there are no payload-only occurrences and the result is genuine.
+
+**A/B two deploy previews.** Same account, same page, exactly one variable changed. This is what finally proved both real wins in this campaign, and what exposed a route returning 500 on every request that CI had called green.
+
+**Read the `Cache-Status` response header.** Netlify emits RFC 9211 `Cache-Status`, and it answers "was this response written to the durable cache" outright, which no amount of reasoning about status codes will. `"Netlify Durable"; fwd=uri-miss; stored` means it was stored; `"Netlify Durable"; hit` with a climbing `age` means it is being replayed; `"Netlify Durable"; fwd=bypass` alongside a 500 means nothing was persisted. This is the artefact that proved both the #1119 bug and its fix.
+
+**Warm the function first.** Cold measurements on this deployment span 1.9 s to 33 s on the same route, so any single cold timing is noise. Warm the instance, or take enough cold samples to see the distribution — it is bimodal, not noisy-around-a-mean, and the mean is meaningless.
+
+## Measured facts that keep getting rediscovered
+
+A skeleton cannot fire First Contentful Paint. FCP requires text, an image, canvas or SVG, and a component built purely from `Skeleton` boxes has none of those. Measured on #1102: the shell HTML arrived at 458 ms while FCP still waited about 6 s for real text. If a surface needs an early FCP it must render actual text, not a placeholder for it.
+
+`next/dynamic` with `ssr: false` skips server rendering for the component **and all of its children**. Wrapping `{children}` in such a component removes an entire subtree from the HTML. Its options must also be an inline object literal at each call site, because SWC analyses them statically — hoisting them to a `const` passes `tsc`, ESLint and Jest and then fails the build.
+
+A client layout that returns a skeleton while its queries load returns that skeleton during SSR too, because nothing prefetched those queries on the server. The fix is to seed the query from a server component with `prefetchQuery` plus `dehydrate` and a `HydrationBoundary`. Both sides must derive an identical key *value*. That is why seeding failed here: the client keyed on `useSession()`, which is still pending during SSR, so its key was `["user-details", undefined]` while the server seeded the real id. Passing the resolved user id down from the server — the same serialized value on both sides — is what fixes it. The mismatch is invisible to `tsc` and to every test.
+
+A `loading.tsx` creates an implicit Suspense boundary, which is what allows a layout to flush while its page is still pending.
+
+`React.cache` memoizes fulfilled results but **not** rejections. Retrying a `cache`d reader therefore really re-runs the query rather than replaying the failure — checked directly against the React that Next 15.5.15 vendors (`next/dist/compiled/react`, `19.2.0-canary-0bdb9206`), for a thrown error and a rejected promise alike. Note that `react` in `package.json` is 18.3.1 and its `cache` is unusable outside experimental channels, so test against the vendored copy, not the installed one.
+
+## The database connection pool holds one client, so parallelising queries wins nothing
+
+`PG_POOL_MAX` is set to **1** on production, deploy preview and branch deploy alike, and `lib/prisma.ts` passes it to `pg.Pool` as `max`. Each function instance therefore holds exactly one client, and concurrent Prisma queries serialise instead of overlapping.
+
+The practical consequence is that wrapping independent Prisma reads in `Promise.all` cannot make anything faster on this deployment. This was measured rather than assumed: across 20 order-balanced interleaved rounds against a real session, the parallelised branch came in at 1,176 ms against 1,142 ms sequential, while anonymous requests were identical on both deployments at 557 and 558 ms, ruling out a deployment-level offset. The change was reverted inside the PR that introduced it.
+
+Check `PG_POOL_MAX` before proposing any "these queries are independent, parallelise them" optimisation anywhere in this codebase. The idea is dead until the pool is widened, and widening it is a capacity question about Supabase pooler limits across concurrent function instances rather than a config tweak. It is tracked as issue #1117.
+
+This also reframes the wave-depth measurements. The finding that one `appointment.findMany` issues 30 SQL statements for 3 appointments, with summed database time of 3,108 ms exceeding wall time of 1,127 ms, is about statement count under a serialising pool as much as about cross-region round trips. That makes Prisma's `relationJoins` more promising than it first appeared, because collapsing statement count is the only lever a single connection responds to.
+
+## What the 23-second TTFB actually decomposes into
+
+This was measured on 2026-08-09 against deploy preview 1118, anonymously, on `/explore/experts/[consultantId]` — an on-demand ISR route, so every distinct parameter forces one real server render. Forty-two cold renders were taken across four batches. Every number below is client-side `time_starttransfer` from `curl`, correlated against the Netlify function log.
+
+The headline is that the 23-second figure was misattributed. Cold boot and render cost are both ruled out by measurement below.
+
+**The database is not the slow part, and the ~30 seconds is a stalled event loop on a newly created function instance.** This was settled on 2026-08-09 against deploy preview 1123 by a diagnostic route that reported instance identity, `process.uptime()`, an event-loop lag probe, and per-attempt connect timings in its response body — see "The connect budget cannot be enforced" below. The earlier reading on this page, that a pooler connection was the thing timing out, was wrong: the connect error is a *casualty* of the stall, not its cause.
+
+The evidence is a bimodal distribution with nothing in the middle:
+
+| Batch | Concurrency | Instance state | Samples | Result |
+|---|---|---|---|---|
+| A | 1 (strictly sequential) | new | 8 | 1.80–2.72 s, median 1.88 s, no outliers |
+| B | 12 concurrent | mostly new | 12 | six at 1.90–4.66 s, six at 30.99–33.08 s |
+| C | 16 concurrent | 12 already warm | 16 | twelve at 2.64–2.94 s, four at 30.83–33.12 s |
+| D | 12 concurrent | all warm | 12 | 3.33–5.89 s, zero slow |
+
+No sample in those four batches landed between 6.8 s and 30.8 s. The gap is the signature of the cold-instance stall: an instance either serves normally or loses roughly 24 seconds to it, with nothing in between. The shape replicated exactly on 2026-08-09 against `dev` at 1fa94c17 — eight sequential renders at 1.79–4.94 s with no outliers, then twelve concurrent renders splitting one fast at 1.85 s, six at 30.67–32.68 s and five hard 500s at 36.3 s.
+
+The pattern replicated on a second, independently built deploy: eight concurrent requests, all against brand-new instances, split five fast at 9.20–11.40 s and three slow at 34.16–36.76 s, with the same `prisma:error timeout exceeded when trying to connect` and a 29,155 ms invocation in the log (that error is a casualty of the stall, not its cause — see below). Note that the fast mode there is 9–11 s rather than 2–3 s, because *every* instance in that batch was new — so the fast mode is not a constant, but the gap is always present and the slow mode always lands at 30–37 s.
+
+Batch A settles the cold-boot question on its own: a brand-new instance rendering a real database-backed page sequentially costs about 1.9 seconds end to end. Boot is not the problem. Batch D settles the concurrency question: twelve simultaneous renders against warm instances cost 3.3–5.9 seconds and never degrade. What produces the tail is instance creation: concurrency forces new instances, and a new instance pays the stall.
+
+The slow-count arithmetic supports that reading directly. Batch B ran twelve requests against roughly six existing instances and produced exactly six slow responses; batch C ran sixteen against the roughly twelve instances batch B had created and produced exactly four. In both cases the number of slow responses equals the number of instances that had to be created. The log does not expose instance identity, which is why this was inference at the time; a diagnostic route that returned a per-instance id later confirmed it directly — every stalled sample had an instance age under 100 ms and an invocation count of 1.
+
+Three further facts are worth carrying forward, because each one costs an afternoon to rediscover.
+
+**On this path, one HTTP request was one function invocation.** Six concurrent uncached `/explore/experts/[consultantId]` document requests produced exactly six `Duration:` lines, and each client timing exceeded its matching invocation duration by a near-constant 0.37 s of CDN and network overhead. The 32.23 s request maps to a single invocation of 30,113.69 ms. So for these requests nothing chained, nothing was retried by the platform, and the document request was not followed by a second billable render.
+
+Do not promote that to a platform rule — it is a statement about anonymous document requests to this route. A logged-in dashboard navigation, a route that redirects, or a client-side RSC fetch can each add invocations. What it does rule out is explaining *this* measurement by a redirect chain, a middleware hop or an RSC follow-up, because none of those appeared.
+
+**Do not plan on reading `Init Duration` here.** Netlify documents it as the cold-start discriminator, and staff describe a full Lambda-style report line — `Duration … Billed Duration … Memory Size … Max Memory Used … Init Duration …`. The Next.js server handler on this account does not emit that. Through both `netlify logs` and its historical API the line is reduced to `Duration:` and `Memory Usage:` only. A user on Netlify's own forum reports the identical absence for this same function ("I have checked my full log and I can't find any `init duration` in it"). Whether the dashboard UI shows more was not checked.
+
+Two substitutes work instead. An invocation *start* appears as an info line with an empty message, so start and end pair by adding the duration to the start timestamp — verified exact to the millisecond. And a genuine module load prints the Better Auth pair `Social provider github is missing clientId or clientSecret` / `… facebook …`, which makes each new instance countable.
+
+**You cannot add a `console.*` line to server code and read it in production.** `next.config.mjs` sets `compiler.removeConsole: process.env.NODE_ENV === "production"`, and this strips server-side console calls too, not just client bundles. It was tested directly: a `console.warn` added at Prisma client construction produced **zero** occurrences in the function log across a deploy where two module loads were independently confirmed by the Better Auth markers in the same window. The reason third-party lines still appear is that `node_modules` is not compiled by SWC — which is exactly why Better Auth and `prisma:error` survive while our own would not.
+
+The practical consequences are worth stating plainly. Any diagnostic you add this way is inert in production while passing every local check, so it is worse than nothing. Roughly 993 `console.*` call sites already exist under `lib/` and `app/api`, all of them silently dead in production, including the ones the comment in `lib/prisma.ts` cites as "the lib/ convention" — see issue #1122. Reach for `Sentry.logger` instead, but note it ships to Sentry rather than to the function log, so it does not help anyone reading `netlify logs`.
+
+### The connect budget cannot be enforced, and the reason is not the database
+
+`lib/prisma.ts` tunes a 3 s connect budget, and that value really is passed through to `pg.Pool` — verified in `node_modules/@prisma/adapter-pg/dist/index.mjs`, where the factory hands its config straight to `new pg.Pool(...)`, and reproduced locally, where a black-holed connect with `connectionTimeoutMillis: 3000` failed in 3,003 ms with the exact two error strings seen in production. Prisma itself does not retry: one query is one connect attempt.
+
+Yet a production invocation logged that connect timeout at t+29.78 s. The reason is that **both pg timers are plain `setTimeout`s** — `pg-pool/index.js:219` for the queue wait and `pg/lib/client.js:148` for the socket — and a `setTimeout` cannot fire while the event loop is blocked.
+
+Measured on deploy preview 1123 with a diagnostic route that ran 400 ms of pure idle `await` **before touching the database at all**, then reported the loop lag it observed. Three of forty invocations came back like this:
+
+| instance age | `process.uptime()` | invocation | 400 ms idle phase took | max loop lag | first DB attempt |
+|---|---|---|---|---|---|
+| 40 ms | 3,363 ms | 1 | 23,905 ms | 23,746 ms | ok in 862 ms |
+| 39 ms | 3,361 ms | 1 | 24,634 ms | 24,534 ms | ok in 866 ms |
+| 100 ms | 3,355 ms | 1 | 24,823 ms | 24,655 ms | ok in 1,037 ms |
+
+The other thirty-seven, all on instances at least 47 s old, completed the same idle phase in 400–453 ms with lag of 1–70 ms. So the stall is roughly 24 seconds, it happens **before any database work**, and it happens only on a brand-new instance serving its first invocation. The database query that follows takes about a second — and in an earlier round where a connect *did* get caught by the stall and failed after 25.7 s and 26.1 s, the immediately retried attempt connected in 340 ms and 327 ms.
+
+Three consequences worth carrying forward. No value of `PG_CONNECT_TIMEOUT_MS` can bound this, so do not propose tuning it. `prisma:error timeout exceeded when trying to connect` in the function log is not evidence that the pooler is unhealthy — check whether the instance was new before believing it. And the only lever that reliably helps is not invoking the function at all, because an ISR cache hit costs no instance and therefore pays no stall.
+
+A request-time retry looks like the obvious second lever and was written, then **reverted inside #1123**. The attempt straight after a stalled connect really does succeed in 327–340 ms, but that was two diagnostic samples and the effect could not be separated from the rethrow in the page-level A/B. Against it: retrying per read doubles the query count on pages that issue four of them, `PG_POOL_MAX=1` serialises those, and a saturated pooler then pushes the render toward the function ceiling — where the response is a bare platform 500 with no error boundary and no `Cache-Status` at all, strictly worse than a fast 500. Do not reinstate it without a per-render budget and fault-injected evidence.
+
+The function runs with `AWS_LAMBDA_FUNCTION_MEMORY_SIZE=1024`, a V8 heap limit of 1,018 MB and 675–795 MB RSS at rest, and Lambda scales CPU with memory. That the stall is cold-instance JS/GC work at that CPU share is the obvious reading but is **inferred**, not measured. `NODE_OPTIONS=--max-old-space-size=6144` from `netlify.toml` was suspected and ruled out: `process.env.NODE_OPTIONS` is `null` inside the function, so `[build.environment]` does not reach the runtime.
+
+Do not treat the platform ceiling as a backstop either. Netlify documents 10 s by default and 26 s maximum on paid plans, yet invocations of 26.4 s to 31.9 s were logged on this Pro account. The stall itself is tracked as issue #1124; #1120 is closed by #1123, which established that it is not a database problem.
+
+### Fail-open and a cacheable response are safe alone and dangerous together
+
+A fail-open path on an ISR route converts a transient database blip into a cached artefact. `fallbackOnTransientDbError` rethrew during `next build` but degraded at request time, and on `/explore/experts/[consultantId]` that produced HTTP 200 responses carrying the degraded shell at 66 KB against a healthy 104–118 KB. Ten of forty concurrent cold renders came back that way, each with `Cache-Status: "Netlify Durable"; fwd=uri-miss; stored`, and re-fetching them five minutes later returned the same broken page in 0.30–0.64 s with `"Netlify Durable"; hit` and `age: 318–350`. The broken page becomes the *fast* one, which is why nobody notices.
+
+Worse, the degrade is often cheap: several of those poisoned entries were produced in **0.47 s**, because a pooler that fails fast reaches the fallback fast. Do not assume a degraded render announces itself by being slow.
+
+The fix, shipped in #1123, is that degrading is now opt-in per call site (`perRequest`) in `lib/data/fail-open.ts`, and the default on anything with a `revalidate` export is to rethrow.
+
+**Both halves of the framework behaviour that makes rethrowing correct were verified by observation**, on a temporary ISR route shaped like the real one, on deploy preview 1123:
+
+- A render that throws returns **HTTP 500** with `Cache-Status: "Netlify Durable"; fwd=bypass, "Netlify Edge"; fwd=miss; fwd-status=500` — no `stored`, and three consecutive requests each re-rendered rather than hitting a cache. Nothing is persisted.
+- When a cached good copy already exists and the *revalidation* throws, the good copy keeps being served. Across 45 polls spanning several deliberately-throwing windows on a `revalidate = 10` route, every response was the good body with `age` climbing to 39–53 s, resetting only when a non-throwing window regenerated it. This matches Next's documented "Handling uncaught exceptions" paragraph.
+
+So on an ISR route, throwing is strictly better than degrading: the one unlucky visitor gets an error boundary, everyone else keeps the last good copy, and nothing bad is written down.
+
+## Options assessed and rejected — do not re-propose without new information
+
+Partial Prerendering and Cache Components are not merely "a Next 16 feature" — they are unreachable from our pinned version. At `next@15.5.15`, `packages/next/src/server/config.ts` throws `CanaryOnlyError` on a stable build for both `experimental.ppr` and `experimental.cacheComponents`, so even `experimental.ppr = "incremental"` fails at config load rather than degrading. Next's own [ppr-preview](https://nextjs.org/docs/messages/ppr-preview) page confirms a canary release is required. This matters because PPR is the textbook answer to "a static page with one dynamic hole", and on this version that answer simply does not exist — a `Suspense` boundary around a dynamic read does **not** rescue static rendering without PPR. Separately, `use cache` would not help a route whose every segment is auth-gated, and every dashboard route here is auth-gated.
+
+Caching a dynamic route at the CDN with `Netlify-CDN-Cache-Control` is technically sound and was considered for the public pages, but it still invokes the function on every cache miss, so it does not solve the cold start the way prerendering does. It remains the right tool when build-time data access is genuinely unacceptable.
+
+Verified clean and not worth re-investigating: `next/image` usage (there are zero raw `` tags), fonts (`next/font/google` with `display: swap`), `staleTimes`, `serverExternalPackages`, the Prisma singleton, and the bundle-analyzer tooling.
+
+## Project constraints that constrain every change here
+
+ESLint warnings are blocking, because SonarCloud fails the quality gate on unused variables. Never filter ESLint output for errors alone.
+
+Renaming a large file makes SonarCloud count every line as new code, which then fails `new_duplicated_lines_density` on long-standing duplication. Prefer adding a parent server layout over splitting a client layout into a separate shell file.
+
+The same trap fires without any rename. Several files on `dev` are already Prettier-dirty, so running `prettier --write` on one while editing it reformats hundreds of untouched lines and hands all of them to Sonar as new code. On #1116 this reformatted roughly 200 lines of `Navbar.tsx` that the change never touched. Revert the formatting churn and re-apply only the semantic edits; Prettier is `continue-on-error` in CI, so an unformatted file is not a failure, whereas a diff full of reformatting is a quality-gate risk.
+
+Never run `prisma db push` as part of a rendering change. There is deferred, unrelated schema drift that a push would apply to a database shared with production.
+
+## Sources
+
+These were opened as primary sources rather than summarised second-hand.
+
+- [Using a CDN with Next.js](https://nextjs.org/docs/app/guides/cdn-caching) — the exact per-strategy `Cache-Control` values.
+- [Incremental Static Regeneration](https://nextjs.org/docs/app/guides/incremental-static-regeneration) and [How Revalidation Works](https://nextjs.org/docs/app/guides/how-revalidation-works).
+- [generateStaticParams](https://nextjs.org/docs/app/api-reference/functions/generate-static-params), [revalidateTag](https://nextjs.org/docs/app/api-reference/functions/revalidateTag) and [Revalidating](https://nextjs.org/docs/app/getting-started/revalidating).
+- [Netlify caching overview](https://docs.netlify.com/build/caching/caching-overview/) — header precedence, `stale-while-revalidate`, cache tags and `purgeCache`.
+- [Next.js on Netlify](https://opennext.js.org/netlify) and [Netlify's Next.js setup guide](https://docs.netlify.com/build/frameworks/framework-setup-guides/nextjs/overview/) — adapter support matrix, durable cache, documented limitations.
+- [Durable Cache and the Quest for Fast, Fresh Content](https://www.netlify.com/blog/durable-cache-quest-for-fast-fresh-content/) — the Netlify Blobs backing store.
diff --git a/.claude/skills/prisma-seed-sync/SKILL.md b/.claude/skills/prisma-seed-sync/SKILL.md
index 65b5b891e..e2c1e2523 100644
--- a/.claude/skills/prisma-seed-sync/SKILL.md
+++ b/.claude/skills/prisma-seed-sync/SKILL.md
@@ -13,6 +13,7 @@ Keep `prisma/seed.ts` and `prisma/seedFiles/` compiling against the current sche
- **The configured DB is shared.** `DATABASE_URL`/`DIRECT_URL` in `.env` point at the remote Supabase pooler — NOT a local throwaway. Never run `prisma migrate reset`, `prisma db push --force-reset`, or a seed against it without the user explicitly confirming the target DB is disposable. Before any destructive command, print the masked host (`grep '^DATABASE_URL' .env | sed -E 's|//[^@]*@|//***@|'`) and ask. Prefer a Supabase branch DB or a local stack for seed runs.
- **The seed entry point is `npm run db:seed`** (`npx tsx prisma/seed.ts`). There is **no** `prisma.seed` key in package.json, so `npx prisma db seed` fails — don't use it. Sizes: `db:seed:small` / `db:seed:medium` / `db:seed:large` (SEED_MODE, parsed in `prisma/seedFiles/config.ts`); edge-case data: `db:seed:validation`.
- **No faker.** The suite uses its own helpers in `prisma/seedFiles/utils.ts` (random selection, date spreads, weighted choices) and quantity knobs in `config.ts`. Extend those; don't add a dependency.
+- **Never set human-readable primary keys** on `Appointment`, `Consultation`, `Subscription`, `Webinar`, or `Class` for rows that hit real allocate/validate APIs. Those routes validate UUID/CUID via `eventIdSchema` / `isEventIdFormat` — strings like `mock0801-appt-pending` or `appt-1` 400 on save. Always omit `id` and let Prisma generate `@default(uuid())` / `@default(cuid())`. Unit-test mocks that never call those routes may still use readable ids.
- **Schema conventions** (if the task includes schema edits): enums are declared *below* the model(s) that use them; no backfill migrations (a pre-MVP DB reset is planned); comments are terse and explain *why*, referencing issues as `#N`.
- **Don't renumber existing modules.** New models get a new file in the next number band; related sub-entities share the number with a letter suffix (`15a-`, `15b-`).
diff --git a/__tests__/booking-algorithm/event-id-format.test.ts b/__tests__/booking-algorithm/event-id-format.test.ts
new file mode 100644
index 000000000..18b9ebaf0
--- /dev/null
+++ b/__tests__/booking-algorithm/event-id-format.test.ts
@@ -0,0 +1,57 @@
+/**
+ * Allocate/validate routes reject non-UUID/CUID event ids via eventIdSchema.
+ * isEventIdFormat is the shared SSR/client gate so mock PKs fail closed
+ * before the Zod 400 toast.
+ */
+
+import {
+ EVENT_ID_INVALID_MESSAGE,
+ eventIdSchema,
+ isEventIdFormat,
+} from "@/schemas/slotAllocation/validationSchemas";
+
+describe("isEventIdFormat", () => {
+ it("accepts a UUID", () => {
+ expect(isEventIdFormat("329c0b89-0648-4f8e-82e4-25811cdea440")).toBe(true);
+ });
+
+ it("accepts a CUIDv1-shaped id", () => {
+ // 'c' + 24 alphanumeric = 25 chars
+ expect(isEventIdFormat("clxxxxxxxxxxxxxxxxxxxxxxx")).toBe(true);
+ });
+
+ it("accepts a CUIDv2-shaped id (24 chars)", () => {
+ expect(isEventIdFormat("a1b2c3d4e5f6g7h8i9j0k1l2")).toBe(true);
+ });
+
+ it("rejects mock0801-appt-pending", () => {
+ expect(isEventIdFormat("mock0801-appt-pending")).toBe(false);
+ });
+
+ it("rejects empty / nullish", () => {
+ expect(isEventIdFormat("")).toBe(false);
+ expect(isEventIdFormat(null)).toBe(false);
+ expect(isEventIdFormat(undefined)).toBe(false);
+ });
+
+ it("rejects short human-readable fixture ids", () => {
+ expect(isEventIdFormat("appt-1")).toBe(false);
+ expect(isEventIdFormat("consultation-1")).toBe(false);
+ });
+});
+
+describe("eventIdSchema", () => {
+ it("parses a valid UUID", () => {
+ expect(eventIdSchema.parse("329c0b89-0648-4f8e-82e4-25811cdea440")).toBe(
+ "329c0b89-0648-4f8e-82e4-25811cdea440",
+ );
+ });
+
+ it("surfaces the invalid-format message for mock ids", () => {
+ const result = eventIdSchema.safeParse("mock0801-appt-pending");
+ expect(result.success).toBe(false);
+ if (!result.success) {
+ expect(result.error.issues[0]?.message).toBe(EVENT_ID_INVALID_MESSAGE);
+ }
+ });
+});
diff --git a/__tests__/booking-algorithm/org-reschedule-affordance.test.ts b/__tests__/booking-algorithm/org-reschedule-affordance.test.ts
new file mode 100644
index 000000000..b7df5c1a6
--- /dev/null
+++ b/__tests__/booking-algorithm/org-reschedule-affordance.test.ts
@@ -0,0 +1,72 @@
+/**
+ * Org appointment detail reuses the consultee adapter but its URL has orgId,
+ * not consulteeId. Without an override the Reschedule overflow never appears.
+ */
+
+import { readFileSync } from "node:fs";
+import path from "node:path";
+
+const root = process.cwd();
+
+describe("org appointment detail Reschedule affordance", () => {
+ it("passes SSR consulteeId into the shared adapter", () => {
+ const client = readFileSync(
+ path.join(
+ root,
+ "app/dashboard/organization/[orgId]/appointments/[appointmentId]/DetailPageClient.tsx",
+ ),
+ "utf8",
+ );
+ const page = readFileSync(
+ path.join(
+ root,
+ "app/dashboard/organization/[orgId]/appointments/[appointmentId]/page.tsx",
+ ),
+ "utf8",
+ );
+ expect(client).toContain("useConsulteeAppointmentsAdapter({ consulteeId })");
+ expect(page).toContain("consulteeId={profile.id}");
+ // Stay under org for detail navigation; reschedule still deep-links out.
+ expect(client).toContain("detailHref");
+ expect(client).toContain("/dashboard/organization/${orgId}/appointments/");
+ });
+
+ it("adapter accepts an optional consulteeId and falls back to params/session", () => {
+ const src = readFileSync(
+ path.join(
+ root,
+ "components/appointments/consultee/ConsulteeAppointmentsAdapter.tsx",
+ ),
+ "utf8",
+ );
+ expect(src).toContain("options?.consulteeId");
+ expect(src).toContain("session?.user?.consulteeProfileId");
+ expect(src).toContain(
+ "`/dashboard/consultee/${consulteeId}/appointments/${vm.appointmentId}/reschedule`",
+ );
+ });
+});
+
+describe("consultant reschedule legend wiring", () => {
+ it("SlotPicker sets showConsultantLegend for consultant propose, not consultee", () => {
+ const src = readFileSync(
+ path.join(root, "components/scheduling/SlotPicker.tsx"),
+ "utf8",
+ );
+ expect(src).toContain('policy.kind === "RESCHEDULE_CONSULTANT"');
+ expect(src).toContain("showConsultantLegend");
+ // Must not key the consultant legend solely on eventId (consultee also has it).
+ expect(src).not.toMatch(
+ /showConsultantLegend=\{\s*Boolean\(subject\.eventId\)\s*\}/,
+ );
+ });
+
+ it("SafeUnifiedCalendar honours showConsultantLegend over mode alone", () => {
+ const src = readFileSync(
+ path.join(root, "components/scheduling/SafeUnifiedCalendar.tsx"),
+ "utf8",
+ );
+ expect(src).toContain("showConsultantLegend");
+ expect(src).toContain("CONSULTANT_LEGEND_KEYS");
+ });
+});
diff --git a/__tests__/booking-algorithm/reschedule-heatmap-algorithm.test.ts b/__tests__/booking-algorithm/reschedule-heatmap-algorithm.test.ts
new file mode 100644
index 000000000..6deb2e5d7
--- /dev/null
+++ b/__tests__/booking-algorithm/reschedule-heatmap-algorithm.test.ts
@@ -0,0 +1,88 @@
+/**
+ * Regression pins for the reschedule/timings heatmap selection rules:
+ * contiguous N×30 groups, same-day (ADR B9), and status precedence so
+ * "Being moved" does not paint as a foreign "Booked".
+ */
+
+import "./setup";
+
+import {
+ findConsecutiveGroupContaining,
+ isCompleteCall,
+} from "@/lib/scheduling/slotSelectionValidation";
+import { resolveSlotStatusKey } from "@/lib/scheduling/slot-status-tokens";
+import type { TimeSlot } from "@/hooks/scheduling/useCalendarData";
+
+const slot = (hour: number, minute = 0): TimeSlot => {
+ const start = new Date(Date.UTC(2026, 7, 7, hour, minute, 0));
+ return {
+ startTime: start,
+ endTime: new Date(start.getTime() + 30 * 60 * 1000),
+ isAvailable: true,
+ isBooked: false,
+ };
+};
+
+describe("contiguous session groups (heatmap selection)", () => {
+ it("treats four half-hour atoms as one complete 2h call", () => {
+ const day = [slot(15, 30), slot(16, 0), slot(16, 30), slot(17, 0)];
+ expect(isCompleteCall(day, 4)).toBe(true);
+ });
+
+ it("rejects a gap inside the run", () => {
+ const day = [slot(15, 30), slot(16, 0), slot(17, 0), slot(17, 30)];
+ expect(isCompleteCall(day, 4)).toBe(false);
+ });
+
+ it("deselection expands to the full consecutive group containing the click", () => {
+ const day = [slot(15, 30), slot(16, 0), slot(16, 30), slot(17, 0)];
+ const group = findConsecutiveGroupContaining(day[2], day);
+ expect(group).toHaveLength(4);
+ expect(group[0].startTime.toISOString()).toBe(day[0].startTime.toISOString());
+ expect(group[3].startTime.toISOString()).toBe(day[3].startTime.toISOString());
+ });
+});
+
+describe("status precedence for Being moved vs Booked vs Selected", () => {
+ const base = {
+ isSelected: false,
+ isThisEventSlot: false,
+ isRescheduling: false,
+ isBookedForDisplay: false,
+ isPartiallyBooked: false,
+ isAvailable: true,
+ isInPast: false,
+ };
+
+ it("Selected wins over Being moved and This booking", () => {
+ expect(
+ resolveSlotStatusKey({
+ ...base,
+ isSelected: true,
+ isRescheduling: true,
+ isThisEventSlot: true,
+ }),
+ ).toBe("selected");
+ });
+
+ it("Being moved wins over foreign Booked paint", () => {
+ // Tentative slots of THIS event must not fall through to fullyBooked.
+ expect(
+ resolveSlotStatusKey({
+ ...base,
+ isRescheduling: true,
+ isBookedForDisplay: true,
+ }),
+ ).toBe("rescheduling");
+ });
+
+ it("This booking wins over Booked", () => {
+ expect(
+ resolveSlotStatusKey({
+ ...base,
+ isThisEventSlot: true,
+ isBookedForDisplay: true,
+ }),
+ ).toBe("thisEvent");
+ });
+});
diff --git a/__tests__/booking-algorithm/reschedule-subject-event-id.test.ts b/__tests__/booking-algorithm/reschedule-subject-event-id.test.ts
new file mode 100644
index 000000000..848507c5a
--- /dev/null
+++ b/__tests__/booking-algorithm/reschedule-subject-event-id.test.ts
@@ -0,0 +1,134 @@
+/**
+ * Reschedule heatmaps need the real event id so fetchEventSlots can paint
+ * "This booking" / "Being moved". Omitting it left those cells looking like
+ * foreign bookings.
+ */
+
+import type { TAppointmentDetail } from "@/lib/data/appointment-detail";
+import { buildRescheduleSubject } from "@/lib/scheduling/slot-picker-subject";
+
+const futureStart = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
+const futureEnd = new Date(futureStart.getTime() + 60 * 60 * 1000);
+
+function consultationDetail(
+ overrides: Partial<{
+ consultationId: string;
+ consultantProfileId: string;
+ }> = {},
+): TAppointmentDetail {
+ const consultationId =
+ overrides.consultationId ?? "329c0b89-0648-4f8e-82e4-25811cdea440";
+ const consultantProfileId = overrides.consultantProfileId ?? "cp-uuid-1";
+ return {
+ appointment: {
+ id: "appt-uuid-1",
+ appointmentType: "CONSULTATION",
+ slotsOfAppointment: [
+ {
+ id: "slot-1",
+ startsAt: futureStart,
+ endsAt: futureEnd,
+ isTentative: false,
+ completionStatus: "SCHEDULED",
+ appointmentId: "appt-uuid-1",
+ user: [],
+ meetingSession: null,
+ },
+ ],
+ consultation: {
+ id: consultationId,
+ requestedBy: {
+ id: "consultee-1",
+ userId: "user-consultee-1",
+ user: { id: "user-consultee-1", name: "Buyer", image: null },
+ },
+ consultationPlan: {
+ title: "Career chat",
+ durationInHours: 2,
+ consultantProfile: {
+ id: consultantProfileId,
+ userId: "user-consultant-1",
+ user: { id: "user-consultant-1", name: "Expert", image: null },
+ },
+ },
+ },
+ subscription: null,
+ webinar: null,
+ class: null,
+ trialSession: null,
+ },
+ siblings: [],
+ } as unknown as TAppointmentDetail;
+}
+
+function subscriptionDetail(): TAppointmentDetail {
+ return {
+ appointment: {
+ id: "appt-sub-1",
+ appointmentType: "SUBSCRIPTION",
+ slotsOfAppointment: [
+ {
+ id: "slot-s1",
+ startsAt: futureStart,
+ endsAt: futureEnd,
+ isTentative: true,
+ completionStatus: "SCHEDULED",
+ appointmentId: "appt-sub-1",
+ user: [],
+ meetingSession: null,
+ },
+ ],
+ consultation: null,
+ subscription: {
+ id: "clxxxxxxxxxxxxxxxxxxxxxxx",
+ requestedBy: {
+ id: "consultee-1",
+ userId: "user-consultee-1",
+ user: { id: "user-consultee-1", name: "Buyer", image: null },
+ },
+ subscriptionPlan: {
+ title: "Weekly coaching",
+ sessionDurationInHours: 1,
+ consultantProfile: {
+ id: "cp-uuid-1",
+ userId: "user-consultant-1",
+ user: { id: "user-consultant-1", name: "Expert", image: null },
+ },
+ },
+ },
+ webinar: null,
+ class: null,
+ trialSession: null,
+ },
+ siblings: [],
+ } as unknown as TAppointmentDetail;
+}
+
+describe("buildRescheduleSubject event context", () => {
+ it("passes consultation eventId + eventType for paint", () => {
+ const subject = buildRescheduleSubject(consultationDetail());
+ expect(subject).not.toBeNull();
+ expect(subject!.subject.eventType).toBe("consultation");
+ expect(subject!.subject.eventId).toBe(
+ "329c0b89-0648-4f8e-82e4-25811cdea440",
+ );
+ expect(subject!.subject.durationInHours).toBe(2);
+ });
+
+ it("passes subscription eventId + eventType and hasReleasedSlots when tentative", () => {
+ const subject = buildRescheduleSubject(subscriptionDetail());
+ expect(subject).not.toBeNull();
+ expect(subject!.subject.eventType).toBe("subscription");
+ expect(subject!.subject.eventId).toBe("clxxxxxxxxxxxxxxxxxxxxxxx");
+ expect(subject!.subject.hasReleasedSlots).toBe(true);
+ });
+
+ it("returns null when there is no consultant to draw a grid for", () => {
+ const detail = consultationDetail({ consultantProfileId: "" });
+ // Empty consultant id → falsy → null
+ (detail.appointment.consultation!.consultationPlan!.consultantProfile as {
+ id: string;
+ }).id = "";
+ expect(buildRescheduleSubject(detail)).toBeNull();
+ });
+});
diff --git a/__tests__/dashboard/nav-targets-resolve.test.ts b/__tests__/dashboard/nav-targets-resolve.test.ts
index 0fc7f14c6..dd9d8aeab 100644
--- a/__tests__/dashboard/nav-targets-resolve.test.ts
+++ b/__tests__/dashboard/nav-targets-resolve.test.ts
@@ -85,9 +85,11 @@ describe("org nav targets resolve", () => {
// Guards the list above against drift: if someone adds a nav item and
// forgets to add it here, this fails rather than silently under-testing.
const layout = readFileSync(
- join(APP, "organization/[orgId]/layout.tsx"),
+ join(APP, "organization/[orgId]/OrgDashboardShell.tsx"),
"utf8",
) as string;
+ // Nav lives in OrgDashboardShell; layout.tsx is the server wrapper that seeds
+ // the org-details query. Same split org-workspace already uses.
// Loose on purpose: items are written both multi-line and inline
// (`{ name: "Overview", icon: Home, path: "home" }`), and MOBILE_TABS
// repeats a subset — dedupe handles the overlap.
@@ -163,7 +165,7 @@ describe("no redundant group nesting", () => {
it.each([
["consultant", "consultant/[consultantId]/layout.tsx"],
["consultee", "consultee/[consulteeId]/layout.tsx"],
- ["organization", "organization/[orgId]/layout.tsx"],
+ ["organization", "organization/[orgId]/OrgDashboardShell.tsx"],
])("%s sidebar has no label that equals a lone item name", (_name, rel) => {
const src = readFileSync(join(APP, rel), "utf8");
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..f49be693a
--- /dev/null
+++ b/__tests__/dashboards/consultant-home-read-shape.test.ts
@@ -0,0 +1,163 @@
+/**
+ * @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("issues no display read at all when there is nothing upcoming (#1121)", async () => {
+ // slotFindMany resolves [] from beforeEach, so homeAppointmentIds is empty —
+ // a new consultant, an entirely past book, or one whose upcoming work was
+ // cancelled. Prisma renders an empty `in` as `IN (NULL)`, and the display
+ // read's include graph then costs nine follow-up SELECTs on `users` for a
+ // result that is guaranteed to be empty.
+ await getConsultantDashboard("cp-1");
+
+ const displayReads = apptFindMany.mock.calls.filter((c) => c[0].include);
+ expect(displayReads).toEqual([]);
+
+ // The active-book read is id-independent and MUST still run — the guard is
+ // per-query, not an early return, or Financial Summary goes blank.
+ expect(apptFindMany.mock.calls.some((c) => c[0].select)).toBe(true);
+ });
+
+ it("still issues the display read when there IS something upcoming (#1121)", async () => {
+ // Non-vacuity anchor for the assertion above: prove the guard is keyed on
+ // emptiness and has not simply deleted the read.
+ slotFindMany.mockResolvedValue([{ appointmentId: "appt-1" }]);
+
+ await getConsultantDashboard("cp-1");
+
+ const displayReads = apptFindMany.mock.calls.filter((c) => c[0].include);
+ expect(displayReads).toHaveLength(1);
+ expect(displayReads[0][0].where.id.in).toEqual(["appt-1"]);
+ });
+
+ 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/__tests__/dashboards/shell-overflow-contract.test.ts b/__tests__/dashboards/shell-overflow-contract.test.ts
new file mode 100644
index 000000000..af8277dc1
--- /dev/null
+++ b/__tests__/dashboards/shell-overflow-contract.test.ts
@@ -0,0 +1,142 @@
+/**
+ * Dashboard shells must clip document scroll and keep overflow inside .
+ * Without overflow-hidden + min-h-0 on the flex chain, tall pages expand the
+ * document past the shell into empty body white space.
+ */
+
+import { readFileSync } from "fs";
+import { join } from "path";
+
+const read = (rel: string) => readFileSync(join(process.cwd(), rel), "utf8");
+
+const SHELL_SOURCES = [
+ "components/dashboard/PersonalDashboardShell.tsx",
+ "components/dashboard/OperatorDashboardShell.tsx",
+ // The org shell is the client component; layout.tsx is the server wrapper
+ // that only seeds the org-details query and renders no chrome.
+ "app/dashboard/organization/[orgId]/OrgDashboardShell.tsx",
+ "app/dashboard/org-workspace/[orgWorkspaceId]/OrgWorkspaceShell.tsx",
+ "app/dashboard/organization/(switcher)/layout.tsx",
+] as const;
+
+/** Extract a balanced `{ ... }` block starting at `from` (index of `{`). */
+function extractBlock(src: string, from: number): string {
+ if (from < 0 || src[from] !== "{") return "";
+ let depth = 0;
+ for (let i = from; i < src.length; i++) {
+ if (src[i] === "{") depth++;
+ else if (src[i] === "}") {
+ depth--;
+ if (depth === 0) return src.slice(from, i + 1);
+ }
+ }
+ return src.slice(from);
+}
+
+function extractFunction(src: string, name: string): string {
+ const start = src.indexOf(`export function ${name}`);
+ if (start < 0) return "";
+ const brace = src.indexOf("{", start);
+ return src.slice(start, brace) + extractBlock(src, brace);
+}
+
+function extractCssRule(css: string, selector: string): string {
+ const start = css.indexOf(selector);
+ if (start < 0) return "";
+ const brace = css.indexOf("{", start);
+ return css.slice(start, brace) + extractBlock(css, brace);
+}
+
+/** Outer shell root: first `h-screen-maintenance` className in the file. */
+function extractShellRoot(src: string): string {
+ const marker = "h-screen-maintenance";
+ const idx = src.indexOf(marker);
+ if (idx < 0) return "";
+ const open = src.lastIndexOf("
", idx);
+ return open >= 0 && close > open ? src.slice(open, close + 1) : "";
+}
+
+function extractMainTags(src: string): string[] {
+ return Array.from(src.matchAll(/]*>/g), (m) => m[0]);
+}
+
+describe("dashboard shell overflow contract", () => {
+ it.each(SHELL_SOURCES)(
+ "%s locks the viewport and scrolls inside main",
+ (rel) => {
+ const src = read(rel);
+ const root = extractShellRoot(src);
+ // extractShellRoot returns "" on a miss, so a shell that moved to another
+ // file would otherwise fail as a confusing string mismatch rather than a
+ // missing root. Fail loudly on the real cause instead.
+ if (root === "") {
+ throw new Error(`no h-screen-maintenance shell root found in ${rel}`);
+ }
+ expect(root).toContain("h-screen-maintenance");
+ expect(root).toContain("overflow-hidden");
+
+ const mains = extractMainTags(src);
+ const scrollMains = mains.filter(
+ (tag) =>
+ /\bmin-h-0\b/.test(tag) && /\boverflow-y-auto\b/.test(tag),
+ );
+ expect(scrollMains.length).toBe(1);
+ // Flex chain between shell and main must allow shrinking.
+ expect(src).toContain("min-h-0");
+ },
+ );
+
+ it(".h-screen-maintenance uses 100dvh", () => {
+ const rule = extractCssRule(read("app/globals.css"), ".h-screen-maintenance");
+ expect(rule).toContain("100dvh");
+ expect(rule).not.toMatch(/100vh(?![\w-])/);
+ });
+
+ it("dashboard layout mounts a document scroll lock", () => {
+ const layout = read("app/dashboard/layout.tsx");
+ const lock = read("components/dashboard/DashboardScrollLock.tsx");
+ const css = read("app/globals.css");
+ expect(layout).toContain("DashboardScrollLock");
+ expect(lock).toContain("dashboard-scroll-locked");
+ expect(extractCssRule(css, "html.dashboard-scroll-locked")).toContain(
+ "overflow: hidden",
+ );
+ });
+
+ it("HelpSkeleton does not nest min-h-screen inside the shell", () => {
+ const fn = extractFunction(
+ read("components/dashboard/DashboardSkeletons.tsx"),
+ "HelpSkeleton",
+ );
+ expect(fn.length).toBeGreaterThan(0);
+ expect(fn).not.toContain("min-h-screen");
+ });
+
+ it("ProfileSection summary column does not use h-full / % height", () => {
+ const src = read(
+ "app/dashboard/consultant/[consultantId]/(features)/settings/sections/ProfileSection.tsx",
+ );
+ const summaryIdx = src.indexOf("Professional Summary");
+ expect(summaryIdx).toBeGreaterThan(0);
+ // Card opens shortly before the label; bound the check to that card.
+ const cardStart = src.lastIndexOf("
", src.indexOf("min-h-[16rem]", summaryIdx));
+ const card = src.slice(cardStart, cardEnd + 6);
+ expect(card).not.toMatch(/\bh-full\b/);
+ expect(card).not.toContain("h-[calc(100%-6rem)]");
+ expect(card).toContain("min-h-[16rem]");
+ });
+
+ it("org-workspace loading uses a content skeleton, not a nested full shell", () => {
+ const loading = read(
+ "app/dashboard/org-workspace/[orgWorkspaceId]/loading.tsx",
+ );
+ expect(loading).not.toMatch(
+ /import\s*\{[^}]*CollapsibleSidebarSkeleton/,
+ );
+ expect(loading).not.toMatch(/ ({
+ __esModule: true,
+ default: { user: { updateMany: jest.fn() } },
+}));
+
+jest.mock("../../lib/auth-server", () => ({
+ getSession: jest.fn(),
+}));
+
+// The action module also exports the full onboarding writer; stubbing it keeps
+// jest away from that import chain, which these tests never exercise.
+jest.mock("../../utils/onboarding-server", () => ({
+ processOnboardingData: jest.fn(),
+}));
+
+import { resetOnboardingRoleAction } from "../../actions/forms/onboarding.action";
+import { getSession } from "../../lib/auth-server";
+import prisma from "../../lib/prisma";
+
+const mockGetSession = getSession as unknown as jest.Mock;
+const mockUpdateMany = prisma.user.updateMany as unknown as jest.Mock;
+
+const USER_ID = "user-1";
+
+describe("resetOnboardingRoleAction", () => {
+ beforeEach(() => {
+ mockGetSession.mockResolvedValue({ user: { id: USER_ID } });
+ mockUpdateMany.mockResolvedValue({ count: 1 });
+ });
+
+ it("rejects an unauthenticated caller", async () => {
+ mockGetSession.mockResolvedValue(null);
+
+ await expect(resetOnboardingRoleAction(USER_ID)).resolves.toEqual({
+ success: false,
+ error: "Unauthorized",
+ });
+ expect(mockUpdateMany).not.toHaveBeenCalled();
+ });
+
+ it("refuses to reset a different user's role", async () => {
+ mockGetSession.mockResolvedValue({ user: { id: "someone-else" } });
+
+ await expect(resetOnboardingRoleAction(USER_ID)).resolves.toEqual({
+ success: false,
+ error: "Forbidden",
+ });
+ expect(mockUpdateMany).not.toHaveBeenCalled();
+ });
+
+ it("reverts to the signup default only while the handoff is provisional", async () => {
+ await expect(resetOnboardingRoleAction(USER_ID)).resolves.toEqual({
+ success: true,
+ reverted: true,
+ });
+
+ expect(mockUpdateMany).toHaveBeenCalledWith({
+ where: {
+ id: USER_ID,
+ role: "ORG_WORKSPACE",
+ onboardingCompleted: { not: true },
+ memberships: { none: {} },
+ },
+ data: { role: "CONSULTEE" },
+ });
+ });
+
+ it("reports no revert when the guard matches nothing (real org owner)", async () => {
+ mockUpdateMany.mockResolvedValue({ count: 0 });
+
+ await expect(resetOnboardingRoleAction(USER_ID)).resolves.toEqual({
+ success: true,
+ reverted: false,
+ });
+ });
+});
diff --git a/__tests__/explore/fail-open-build-phase.test.ts b/__tests__/explore/fail-open-build-phase.test.ts
new file mode 100644
index 000000000..d9c04fa06
--- /dev/null
+++ b/__tests__/explore/fail-open-build-phase.test.ts
@@ -0,0 +1,141 @@
+/**
+ * Fail-open is only safe where the degraded result reaches the ONE request that
+ * hit the failure. Build output and the ISR/durable cache are both persisted and
+ * replayed to every later visitor, so degrading is opt-in per call site
+ * (`perRequest`) and everything else fails CLOSED. (#932, #1119)
+ */
+import { PHASE_PRODUCTION_BUILD } from "next/constants";
+
+import {
+ emptyOnTransientDbError,
+ fallbackOnTransientDbError,
+ isTransientDbError,
+ withBuildTimeRetry,
+} from "@/lib/data/fail-open";
+
+const transient = Object.assign(new Error("pool timeout"), { code: "P2024" });
+const real = new Error("Cannot read properties of undefined (reading 'map')");
+
+const originalPhase = process.env.NEXT_PHASE;
+
+afterEach(() => {
+ if (originalPhase === undefined) delete process.env.NEXT_PHASE;
+ else process.env.NEXT_PHASE = originalPhase;
+});
+
+function setBuildPhase(on: boolean) {
+ if (on) process.env.NEXT_PHASE = PHASE_PRODUCTION_BUILD;
+ else delete process.env.NEXT_PHASE;
+}
+
+describe("fail-open transient classification", () => {
+ it("treats pool timeouts as transient and mapper bugs as real", () => {
+ expect(isTransientDbError(transient)).toBe(true);
+ expect(isTransientDbError(real)).toBe(false);
+ });
+});
+
+describe("cacheable render (fail closed by default)", () => {
+ beforeEach(() => setBuildPhase(false));
+
+ // The #1119 regression guard: without `perRequest`, a transient failure must
+ // NOT become a 200 that Netlify writes into the durable cache.
+ it("emptyOnTransientDbError rethrows when the call site is not per-request", () => {
+ expect(() => emptyOnTransientDbError("ctx")(transient)).toThrow(transient);
+ });
+
+ it("fallbackOnTransientDbError rethrows when the call site is not per-request", () => {
+ expect(() => fallbackOnTransientDbError("ctx", null)(transient)).toThrow(
+ transient,
+ );
+ });
+});
+
+describe("per-request render (fail open)", () => {
+ beforeEach(() => setBuildPhase(false));
+
+ it("emptyOnTransientDbError degrades a transient error to []", () => {
+ expect(
+ emptyOnTransientDbError("ctx", { perRequest: true })(transient),
+ ).toEqual([]);
+ });
+
+ it("fallbackOnTransientDbError degrades a transient error to the fallback", () => {
+ expect(
+ fallbackOnTransientDbError(
+ "ctx",
+ { total: 0 },
+ { perRequest: true },
+ )(transient),
+ ).toEqual({ total: 0 });
+ });
+
+ it("still rethrows non-transient errors", () => {
+ expect(() =>
+ emptyOnTransientDbError("ctx", { perRequest: true })(real),
+ ).toThrow(real);
+ expect(() =>
+ fallbackOnTransientDbError("ctx", null, { perRequest: true })(real),
+ ).toThrow(real);
+ });
+});
+
+describe("production build phase (fail closed even when opted in)", () => {
+ beforeEach(() => setBuildPhase(true));
+
+ it("emptyOnTransientDbError rethrows rather than baking an empty page", () => {
+ expect(() =>
+ emptyOnTransientDbError("ctx", { perRequest: true })(transient),
+ ).toThrow(transient);
+ });
+
+ it("fallbackOnTransientDbError rethrows rather than baking a fallback page", () => {
+ expect(() =>
+ fallbackOnTransientDbError("ctx", null, { perRequest: true })(transient),
+ ).toThrow(transient);
+ });
+});
+
+describe("withBuildTimeRetry", () => {
+ // A request-time retry was tried and reverted in #1123: it doubles the query
+ // count per render under PG_POOL_MAX=1 and can push a failing render past the
+ // Netlify function ceiling, where the response is a bare platform 500 with no
+ // error boundary at all. Pin the build-only shape so it does not creep back.
+ it("does not retry at request time — one attempt, error propagates", async () => {
+ setBuildPhase(false);
+ const read = jest.fn().mockRejectedValue(transient);
+ await expect(withBuildTimeRetry(read)).rejects.toThrow(transient);
+ expect(read).toHaveBeenCalledTimes(1);
+ });
+
+ it("retries a transient failure during the build and succeeds", async () => {
+ setBuildPhase(true);
+ const read = jest
+ .fn()
+ .mockRejectedValueOnce(transient)
+ .mockResolvedValue("ok");
+ await expect(withBuildTimeRetry(read)).resolves.toBe("ok");
+ expect(read).toHaveBeenCalledTimes(2);
+ });
+
+ it("gives up after the configured build attempts and fails the build", async () => {
+ setBuildPhase(true);
+ const read = jest.fn().mockRejectedValue(transient);
+ await expect(withBuildTimeRetry(read)).rejects.toThrow(transient);
+ expect(read).toHaveBeenCalledTimes(3);
+ });
+
+ it("does not retry a non-transient error during the build", async () => {
+ setBuildPhase(true);
+ const read = jest.fn().mockRejectedValue(real);
+ await expect(withBuildTimeRetry(read)).rejects.toThrow(real);
+ expect(read).toHaveBeenCalledTimes(1);
+ });
+
+ it("does not retry a non-transient error at request time", async () => {
+ setBuildPhase(false);
+ const read = jest.fn().mockRejectedValue(real);
+ await expect(withBuildTimeRetry(read)).rejects.toThrow(real);
+ expect(read).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/__tests__/explore/isr-routes-never-fail-open.test.ts b/__tests__/explore/isr-routes-never-fail-open.test.ts
new file mode 100644
index 000000000..3a4078902
--- /dev/null
+++ b/__tests__/explore/isr-routes-never-fail-open.test.ts
@@ -0,0 +1,138 @@
+/**
+ * #1119 was never a bug in the fail-open helper's default. It was a bug in
+ * call-site WIRING: a route that had become ISR was still degrading, so a
+ * transient pooler timeout became a 200 that Netlify wrote into the durable cache
+ * and replayed to every visitor for the whole revalidate window.
+ *
+ * The helper's unit tests cannot catch that class — they pass whatever the routes
+ * do. This one reads the route sources directly and pins the invariant that
+ * actually matters: degrading and being cacheable are mutually exclusive.
+ */
+import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
+import path from "node:path";
+
+const APP_DIR = path.join(process.cwd(), "app");
+
+function routeFiles(dir: string): string[] {
+ const out: string[] = [];
+ for (const entry of readdirSync(dir)) {
+ const full = path.join(dir, entry);
+ if (statSync(full).isDirectory()) {
+ out.push(...routeFiles(full));
+ } else if (entry === "page.tsx" || entry === "route.ts") {
+ out.push(full);
+ }
+ }
+ return out;
+}
+
+const files = routeFiles(APP_DIR).map((file) => ({
+ rel: path.relative(process.cwd(), file),
+ src: readFileSync(file, "utf8"),
+}));
+
+// A `revalidate` export is what makes a render persistable: Next stores the HTML
+// and Netlify's durable cache replays it. `dynamic = "force-dynamic"` is the only
+// thing that guarantees a render reaches exactly one visitor.
+const isCacheable = (src: string) =>
+ /export\s+const\s+revalidate\s*=/.test(src) &&
+ !/export\s+const\s+dynamic\s*=\s*["']force-dynamic["']/.test(src);
+const isForceDynamic = (src: string) =>
+ /export\s+const\s+dynamic\s*=\s*["']force-dynamic["']/.test(src);
+const degrades = (src: string) => /perRequest/.test(src);
+
+// `degrades` above only sees degradation that goes through lib/data/fail-open.ts,
+// because `perRequest` is the helper's opt-in flag. A hand-written
+// `catch { return [] }` is the same hazard on a cacheable route and contains no
+// such token — that blind spot is #1125, and two live examples were found in
+// /explore/programs while reviewing #1123.
+//
+// The invariant pinned instead is narrower and sharper than "returns a
+// placeholder", which is not reliably detectable from source: a catch that binds
+// NOTHING cannot report what it swallowed, to Sentry or anywhere else. Binding
+// the error is the floor. `catch (e) {` passes; `catch {` does not. `.catch(fn)`
+// is untouched — that is a call, not a clause.
+//
+// Comments are matched between `catch` and `{` because `\s` alone does not, and
+// `catch /* why */ {` is both legal and exactly what someone reaches for when
+// explaining why a swallow is fine. A guard a comment can switch off is worse
+// than no guard, because it still reads as coverage.
+const hasBareCatch = (src: string) =>
+ /(?:^|[^.\w])catch\s*(?:\/\/[^\n]*\n|\/\*[\s\S]*?\*\/|\s)*\{/.test(src);
+
+describe("#1119 — a cacheable route must never fail open", () => {
+ it("finds the route files it is supposed to be guarding", () => {
+ // Guards against the walk silently matching nothing and passing vacuously.
+ expect(files.length).toBeGreaterThan(50);
+ expect(files.some((f) => isCacheable(f.src))).toBe(true);
+ expect(files.some((f) => degrades(f.src))).toBe(true);
+ });
+
+ it("no route with a revalidate export opts into degrading", () => {
+ const offenders = files
+ .filter((f) => isCacheable(f.src) && degrades(f.src))
+ .map((f) => f.rel);
+ expect(offenders).toEqual([]);
+ });
+
+ it("every route that opts into degrading is force-dynamic or a Route Handler", () => {
+ const offenders = files
+ .filter(
+ (f) =>
+ degrades(f.src) &&
+ !isForceDynamic(f.src) &&
+ !f.rel.endsWith("route.ts"),
+ )
+ .map((f) => f.rel);
+ expect(offenders).toEqual([]);
+ });
+
+ it("the bare-catch detector matches the shape it claims to (#1125)", () => {
+ // Anchored on fixtures rather than on real files on purpose. The obvious
+ // anchor — "some route in the repo has a bare catch" — decays into a vacuous
+ // pass the moment the sweep in #1125 removes the last one, and it would do
+ // so silently, which is the exact failure mode this whole file exists for.
+ expect(hasBareCatch("try { x() } catch { return [] }")).toBe(true);
+ expect(hasBareCatch("try { x() } catch{return null}")).toBe(true);
+ // A comment between the clause and its body must not hide it.
+ expect(
+ hasBareCatch("try { x() } catch /* transient only */ { return [] }"),
+ ).toBe(true);
+ expect(
+ hasBareCatch("try { x() } catch\n // safe, see #123\n{ return [] }"),
+ ).toBe(true);
+ expect(
+ hasBareCatch("try { x() } catch (err) { report(err); return [] }"),
+ ).toBe(false);
+ // A `.catch(handler)` call is the sanctioned form and must not be flagged.
+ expect(hasBareCatch("read().catch(emptyOnTransientDbError('x'))")).toBe(
+ false,
+ );
+ });
+
+ it("no cacheable route swallows an error it cannot report (#1125)", () => {
+ const offenders = files
+ .filter((f) => isCacheable(f.src) && hasBareCatch(f.src))
+ .map((f) => f.rel);
+ expect(offenders).toEqual([]);
+ });
+
+ it("the degraded consultant shell is deleted, not merely unreferenced", () => {
+ // Rendered, not just mentioned — the fix's own comment cites the component by
+ // name, and a comment is not a regression.
+ const renders = files
+ .filter((f) =>
+ / f.rel);
+ expect(renders).toEqual([]);
+ expect(
+ existsSync(
+ path.join(
+ APP_DIR,
+ "explore/experts/[consultantId]/components/ConsultantUnavailable.tsx",
+ ),
+ ),
+ ).toBe(false);
+ });
+});
diff --git a/__tests__/lib/auth-client-signout-clears-identity.test.ts b/__tests__/lib/auth-client-signout-clears-identity.test.ts
new file mode 100644
index 000000000..136654905
--- /dev/null
+++ b/__tests__/lib/auth-client-signout-clears-identity.test.ts
@@ -0,0 +1,65 @@
+/**
+ * Every sign-out path in the app imports `signOut` from `@/lib/auth-client`
+ * (`grep -rn "signOut" app components lib providers` — nothing calls
+ * `authClient.signOut` directly). That makes the wrapper the one place the
+ * cached display identity has to be cleared, so this test pins it: without it,
+ * a shared or public device keeps the previous user's name and avatar in
+ * localStorage until someone else's session happens to resolve. #636
+ */
+jest.mock("better-auth/react", () => ({
+ createAuthClient: () => ({
+ signIn: {},
+ signUp: {},
+ // Declared inside the factory: `jest.mock` is hoisted above the imports,
+ // so a module-scope const would still be in its TDZ here.
+ signOut: jest.fn(() => Promise.resolve({ data: null })),
+ useSession: jest.fn(),
+ getSession: jest.fn(),
+ sendVerificationEmail: jest.fn(),
+ }),
+}));
+jest.mock("better-auth/client/plugins", () => ({
+ customSessionClient: () => ({}),
+}));
+jest.mock("@better-auth/sso/client", () => ({ ssoClient: () => ({}) }));
+
+import { authClient, signOut } from "@/lib/auth-client";
+import {
+ readAuthedFlag,
+ readAuthedIdentity,
+ writeAuthedFlag,
+} from "@/lib/auth-broadcast";
+
+const mockSignOut = authClient.signOut as unknown as jest.Mock;
+
+describe("signOut", () => {
+ beforeEach(() => {
+ localStorage.clear();
+ mockSignOut.mockImplementation(() => Promise.resolve({ data: null }));
+ });
+
+ it("forgets the remembered identity and still delegates", () => {
+ writeAuthedFlag(true, {
+ name: "Zara Brown",
+ image: "https://cdn.test/z.png",
+ });
+
+ const options = { fetchOptions: { onSuccess: jest.fn() } };
+ void signOut(options);
+
+ expect(readAuthedIdentity()).toBeNull();
+ expect(readAuthedFlag()).toBe(false);
+ expect(mockSignOut).toHaveBeenCalledWith(options);
+ });
+
+ it("clears before the request, so a failed sign-out still forgets", () => {
+ writeAuthedFlag(true, { name: "Zara Brown", image: null });
+ mockSignOut.mockImplementationOnce(() =>
+ Promise.reject(new Error("network")),
+ );
+
+ void signOut().catch(() => {});
+
+ expect(readAuthedIdentity()).toBeNull();
+ });
+});
diff --git a/__tests__/lib/auth-remembered-shape.test.tsx b/__tests__/lib/auth-remembered-shape.test.tsx
new file mode 100644
index 000000000..34d3a94dc
--- /dev/null
+++ b/__tests__/lib/auth-remembered-shape.test.tsx
@@ -0,0 +1,205 @@
+/**
+ * The navbar's optimistic first paint (#636).
+ *
+ * Two properties are load-bearing and neither is obvious from reading the code:
+ * 1. `useRememberedAuth` MUST return null on the very first render, or the
+ * server HTML and the first client render disagree and React discards the
+ * tree. Asserted by recording every render's value.
+ * 2. Once the layout effect has run, the branch must render the remembered
+ * shape instead of the skeleton.
+ *
+ * Paint ordering (layout effect lands before the browser paints) is a browser
+ * guarantee and is deliberately NOT asserted here — it cannot be.
+ */
+import { act } from "react";
+import { createRoot, type Root } from "react-dom/client";
+import {
+ forgetAuthState,
+ readAuthedFlag,
+ readAuthedIdentity,
+ writeAuthedFlag,
+} from "@/lib/auth-broadcast";
+import {
+ resolveAuthView,
+ useRememberedAuth,
+ type RememberedAuth,
+} from "@/hooks/useRememberedAuth";
+
+const IDENTITY = { name: "Zara Brown", image: "https://cdn.test/zara.png" };
+
+describe("auth-broadcast remembered state", () => {
+ beforeEach(() => localStorage.clear());
+
+ it("round-trips the flag and the display identity", () => {
+ writeAuthedFlag(true, IDENTITY);
+ expect(readAuthedFlag()).toBe(true);
+ expect(readAuthedIdentity()).toEqual(IDENTITY);
+ });
+
+ it("returns null for a visitor with nothing remembered", () => {
+ expect(readAuthedFlag()).toBeNull();
+ expect(readAuthedIdentity()).toBeNull();
+ });
+
+ it("drops the cached identity whenever the flag goes false", () => {
+ writeAuthedFlag(true, IDENTITY);
+ writeAuthedFlag(false);
+ expect(readAuthedFlag()).toBe(false);
+ // The privacy invariant: a shared device must not retain the previous
+ // user's name or avatar.
+ expect(readAuthedIdentity()).toBeNull();
+ expect(localStorage.getItem("familiarise.auth_identity")).toBeNull();
+ });
+
+ it("drops the cached identity when authed is written without one", () => {
+ writeAuthedFlag(true, IDENTITY);
+ writeAuthedFlag(true);
+ expect(readAuthedIdentity()).toBeNull();
+ });
+
+ it("forgetAuthState clears both halves", () => {
+ writeAuthedFlag(true, IDENTITY);
+ forgetAuthState();
+ expect(readAuthedFlag()).toBe(false);
+ expect(readAuthedIdentity()).toBeNull();
+ });
+
+ it("clears the previous identity when the replacement write throws", () => {
+ writeAuthedFlag(true, IDENTITY);
+ const original = Storage.prototype.setItem;
+ const spy = jest
+ .spyOn(Storage.prototype, "setItem")
+ .mockImplementation(function (this: Storage, key: string, value: string) {
+ if (key === "familiarise.auth_identity") {
+ throw new Error("QuotaExceededError");
+ }
+ original.call(this, key, value);
+ });
+ try {
+ writeAuthedFlag(true, { name: "Someone Else", image: null });
+ } finally {
+ spy.mockRestore();
+ }
+ // A failed write must not leave the previous account's identity behind.
+ expect(readAuthedIdentity()).toBeNull();
+ expect(readAuthedFlag()).toBe(false);
+ });
+
+ it("tolerates a corrupt or partial identity payload", () => {
+ localStorage.setItem("familiarise.auth_authed", "true");
+ localStorage.setItem("familiarise.auth_identity", "{not json");
+ expect(readAuthedIdentity()).toBeNull();
+
+ localStorage.setItem(
+ "familiarise.auth_identity",
+ JSON.stringify({ name: 7 }),
+ );
+ expect(readAuthedIdentity()).toEqual({ name: null, image: null });
+ });
+});
+
+describe("resolveAuthView", () => {
+ const remembered = (authed: boolean): RememberedAuth => ({
+ authed,
+ identity: authed ? IDENTITY : null,
+ });
+
+ it("shows the skeleton only when nothing is known", () => {
+ expect(
+ resolveAuthView({ isPending: true, user: null, remembered: null }).mode,
+ ).toBe("unknown");
+ });
+
+ it("adopts the remembered anonymous shape while pending", () => {
+ expect(
+ resolveAuthView({
+ isPending: true,
+ user: null,
+ remembered: remembered(false),
+ }),
+ ).toEqual({ mode: "anonymous", name: null, image: null });
+ });
+
+ it("adopts the remembered identity while pending", () => {
+ expect(
+ resolveAuthView({
+ isPending: true,
+ user: null,
+ remembered: remembered(true),
+ }),
+ ).toEqual({ mode: "authed", ...IDENTITY });
+ });
+
+ it("lets the resolved session override a stale remembered flag", () => {
+ // Expired session: remembered says authed, truth says otherwise.
+ expect(
+ resolveAuthView({
+ isPending: false,
+ user: null,
+ remembered: remembered(true),
+ }).mode,
+ ).toBe("anonymous");
+ // And the other direction — signed in on another tab.
+ expect(
+ resolveAuthView({
+ isPending: false,
+ user: { name: "Real", image: null },
+ remembered: remembered(false),
+ }),
+ ).toEqual({ mode: "authed", name: "Real", image: null });
+ });
+});
+
+describe("useRememberedAuth in a component", () => {
+ let container: HTMLDivElement;
+ let root: Root;
+ let renders: Array;
+
+ function Harness() {
+ const value = useRememberedAuth();
+ renders.push(value);
+ const view = resolveAuthView({
+ isPending: true,
+ user: null,
+ remembered: value,
+ });
+ if (view.mode === "unknown") return skeleton;
+ return {`${view.mode}:${view.name ?? ""}`};
+ }
+
+ beforeEach(() => {
+ localStorage.clear();
+ renders = [];
+ container = document.createElement("div");
+ document.body.appendChild(container);
+ root = createRoot(container);
+ });
+
+ afterEach(() => {
+ act(() => root.unmount());
+ container.remove();
+ });
+
+ it("renders the remembered identity instead of the skeleton", () => {
+ writeAuthedFlag(true, IDENTITY);
+ act(() => root.render());
+
+ // Hydration safety: nothing was read from localStorage during the first
+ // render, so the server markup and the first client render match.
+ expect(renders[0]).toBeNull();
+ expect(renders.at(-1)).toEqual({ authed: true, identity: IDENTITY });
+ expect(container.textContent).toBe("authed:Zara Brown");
+ });
+
+ it("renders the signed-out shape for a remembered anonymous visitor", () => {
+ writeAuthedFlag(false);
+ act(() => root.render());
+ expect(container.textContent).toBe("anonymous:");
+ });
+
+ it("keeps the skeleton when nothing is remembered", () => {
+ act(() => root.render());
+ expect(renders).toEqual([null]);
+ expect(container.textContent).toBe("skeleton");
+ });
+});
diff --git a/__tests__/lib/remove-console-config.test.ts b/__tests__/lib/remove-console-config.test.ts
new file mode 100644
index 000000000..9c7be3cc4
--- /dev/null
+++ b/__tests__/lib/remove-console-config.test.ts
@@ -0,0 +1,53 @@
+/**
+ * #1122 — `compiler.removeConsole: true` deleted every server-side diagnostic
+ * from the deployed function, because SWC applies the transform to the server
+ * layer as well as the client and Next offers no client-only scoping. It was
+ * unnoticeable for months: the code compiles, `next dev` prints normally, and
+ * production simply says nothing.
+ *
+ * That failure mode is exactly what makes it likely to be "simplified" back to a
+ * bare `true` in a later cleanup, so the exclude list is pinned here. This reads
+ * the config as SOURCE rather than importing it — `next.config.mjs` uses
+ * top-level await and wraps its export in `withSentryConfig`, so importing it
+ * under Jest would drag in the Sentry webpack plugin to assert one literal. Same
+ * technique as __tests__/explore/isr-routes-never-fail-open.test.ts.
+ */
+import { readFileSync } from "node:fs";
+import path from "node:path";
+
+const src = readFileSync(
+ path.join(process.cwd(), "next.config.mjs"),
+ "utf8",
+);
+
+// The `compiler: { ... }` block, isolated so a `removeConsole` mentioned in a
+// comment elsewhere in the file cannot satisfy or break these assertions.
+const compilerBlock = /compiler:\s*\{([\s\S]*?)\n\s{2}\},/.exec(src)?.[1] ?? "";
+
+describe("#1122 — removeConsole must not strip server diagnostics", () => {
+ it("finds the compiler block it is supposed to be guarding", () => {
+ // Guards against the regex silently matching nothing and passing vacuously.
+ expect(compilerBlock).toMatch(/removeConsole/);
+ });
+
+ it("never assigns removeConsole a bare boolean true", () => {
+ expect(compilerBlock).not.toMatch(/removeConsole\s*:\s*true/);
+ // The original bug's exact shape: `removeConsole: ` with no
+ // exclude list, which evaluates to `true` in production.
+ expect(compilerBlock).not.toMatch(
+ /removeConsole\s*:\s*process\.env\.NODE_ENV\s*===\s*"production"\s*,/,
+ );
+ });
+
+ it("excludes both error and warn so deliberate diagnostics survive", () => {
+ const exclude = /exclude:\s*\[([^\]]*)\]/.exec(compilerBlock)?.[1];
+ expect(exclude).toBeDefined();
+ expect(exclude).toMatch(/"error"/);
+ expect(exclude).toMatch(/"warn"/);
+ });
+
+ it("still strips console.log, which is the bundle saving the option is for", () => {
+ const exclude = /exclude:\s*\[([^\]]*)\]/.exec(compilerBlock)?.[1] ?? "";
+ expect(exclude).not.toMatch(/"log"/);
+ });
+});
diff --git a/__tests__/schedule/format-slots-for-api-throws.test.ts b/__tests__/schedule/format-slots-for-api-throws.test.ts
new file mode 100644
index 000000000..7fb63c168
--- /dev/null
+++ b/__tests__/schedule/format-slots-for-api-throws.test.ts
@@ -0,0 +1,110 @@
+/**
+ * @jest-environment node
+ */
+
+/**
+ * #1125 — `formatSlotsForApi` feeds a PUT body (SettingsTab.tsx:457), so
+ * "degrade" here does not mean render less, it means SAVE less. It used to carry
+ * three nested catches: a per-slot one dropped the offending slot from the
+ * payload, and an outer one returned `[]` for the entire schedule — after which
+ * SettingsTab refetched and displayed the wiped availability as "what was
+ * actually saved". These pin the replacement contract: all of it, or an error
+ * the caller can show the consultant.
+ */
+import { formatSlotsForApi } from "@/utils/schedule/formatting";
+import type { SlotsType } from "@/utils/schedule/types";
+
+const slot = (startTime: string, endTime: string) => ({
+ id: `${startTime}-${endTime}`,
+ startTime,
+ endTime,
+ isValid: true,
+});
+
+describe("formatSlotsForApi is all-or-nothing (#1125)", () => {
+ it("formats every weekly slot it is given", () => {
+ const slots: SlotsType = {
+ monday: [slot("09:00", "10:00"), slot("14:00", "15:00")],
+ tuesday: [slot("11:00", "12:00")],
+ };
+
+ const result = formatSlotsForApi(slots, true, "UTC");
+
+ expect(result).toHaveLength(3);
+ });
+
+ it("formats every custom slot it is given", () => {
+ const slots: SlotsType = {
+ "2026-09-01": [slot("09:00", "10:00")],
+ "2026-09-02": [slot("13:00", "14:30")],
+ };
+
+ const result = formatSlotsForApi(slots, false, "UTC");
+
+ expect(result).toHaveLength(2);
+ });
+
+ it("throws rather than dropping a custom slot whose date key is unusable", () => {
+ // The old behaviour returned the OTHER day's slots and silently omitted
+ // this one, so the consultant saved a schedule missing a day they had set.
+ const slots: SlotsType = {
+ "2026-09-01": [slot("09:00", "10:00")],
+ "not-a-date": [slot("09:00", "10:00")],
+ };
+
+ expect(() => formatSlotsForApi(slots, false, "UTC")).toThrow(
+ /Invalid date format/,
+ );
+ });
+
+ it("throws rather than wiping the whole schedule when a day key is unusable", () => {
+ // The outer catch turned this into `[]`, which is indistinguishable from
+ // "this consultant has no availability" once it reaches the API.
+ const slots: SlotsType = {
+ notaday: [slot("09:00", "10:00")],
+ };
+
+ expect(() => formatSlotsForApi(slots, true, "UTC")).toThrow();
+ });
+
+ it("throws rather than dropping a WEEKLY slot whose UTC conversion fails", () => {
+ // The weekly twin of the custom-slot case, and the worse half: weekly is the
+ // default schedule type. formatWeeklySlot returned [] here and the caller's
+ // flatMap absorbed it, so the slot vanished from the PUT body silently.
+ // An unusable timezone is how a VALID slot reaches that path —
+ // convertTimezoneToUtc catches the failure and returns "".
+ const slots: SlotsType = {
+ monday: [slot("09:00", "10:00")],
+ };
+
+ expect(() => formatSlotsForApi(slots, true, "Not/AZone")).toThrow(
+ /Could not convert weekly slot/,
+ );
+ });
+
+ it("throws rather than dropping a CUSTOM slot whose UTC conversion fails", () => {
+ const slots: SlotsType = {
+ "2026-09-01": [slot("09:00", "10:00")],
+ };
+
+ expect(() => formatSlotsForApi(slots, false, "Not/AZone")).toThrow(
+ /Could not convert slot/,
+ );
+ });
+
+ it("still skips slots the caller already marked invalid", () => {
+ // Distinct from a formatting FAILURE: `isValid: false` is the editor saying
+ // the row is incomplete, which is an answer, not an error. Those must keep
+ // being filtered rather than throwing.
+ const slots: SlotsType = {
+ monday: [
+ slot("09:00", "10:00"),
+ { ...slot("bad", "worse"), isValid: false },
+ ],
+ };
+
+ const result = formatSlotsForApi(slots, true, "UTC");
+
+ expect(result).toHaveLength(1);
+ });
+});
diff --git a/actions/forms/onboarding.action.ts b/actions/forms/onboarding.action.ts
index 938cc703d..8d7110768 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
@@ -136,6 +140,49 @@ export async function setOnboardingRoleAction(
return { success: true };
}
+/**
+ * Undo a provisional ORG_WORKSPACE handoff when the user backs out of the
+ * create-org wizard. `setOnboardingRoleAction` has to commit the role before
+ * the wizard runs (`POST /api/organizations` gates on it), so without this a
+ * user who changed their mind stayed on ORG_WORKSPACE with
+ * `onboardingCompleted: false` — org-creation rights for someone who never
+ * finished onboarding.
+ *
+ * The revert target is CONSULTEE because that is the `User.role` schema
+ * default every account starts on; the real role is written by
+ * `processOnboardingData` when onboarding completes.
+ *
+ * The `where` clause is the safety guard, applied in a single statement so
+ * there is no read-then-write race: only a row that is still ORG_WORKSPACE,
+ * still un-onboarded, and holds no membership is provisional. A real org owner
+ * (who has at least the owner Membership created with their org) is never
+ * touched.
+ */
+export async function resetOnboardingRoleAction(
+ userId: string,
+): Promise<{ success: boolean; reverted?: boolean; error?: string }> {
+ const session = await getSession(true);
+ if (!session?.user?.id) {
+ return { success: false, error: "Unauthorized" };
+ }
+ if (session.user.id !== userId) {
+ return { success: false, error: "Forbidden" };
+ }
+
+ const { count } = await prisma.user.updateMany({
+ where: {
+ id: userId,
+ role: UserRole.ORG_WORKSPACE,
+ // The column is nullable, so `false` alone would skip null rows.
+ onboardingCompleted: { not: true },
+ memberships: { none: {} },
+ },
+ data: { role: UserRole.CONSULTEE },
+ });
+
+ return { success: true, reverted: count > 0 };
+}
+
/**
* Flip `user.onboardingCompleted = true` after the ORG_WORKSPACE wizard
* finishes launching their first org. Role + personal info were already
diff --git a/app/api/admin/organizations/[orgId]/verify/route.ts b/app/api/admin/organizations/[orgId]/verify/route.ts
index eb3460f24..cb9d0f31a 100644
--- a/app/api/admin/organizations/[orgId]/verify/route.ts
+++ b/app/api/admin/organizations/[orgId]/verify/route.ts
@@ -14,6 +14,7 @@ import { NextResponse, type NextRequest } from "next/server";
import { z } from "zod";
import prisma from "@/lib/prisma";
import { requireAdminAuth } from "@/lib/auth-helpers";
+import { purgeOrgSurfaces } from "@/lib/data/public-cache";
import { AUDIT_ACTIONS } from "@/lib/enterprise/audit-actions";
import {
IllegalTransitionError,
@@ -178,6 +179,11 @@ export async function POST(
return tx.organization.findUniqueOrThrow({ where: { id: orgId } });
});
+ // ACTIVE is half the public gate: VERIFY/REACTIVATE put the org into the
+ // directory, SUSPEND/DEACTIVATE take it out. (REJECT only stamps sub-state
+ // and leaves the org PENDING, so it never reaches here.)
+ purgeOrgSurfaces(updated.slug);
+
return NextResponse.json({ organization: updated });
} catch (err) {
if (err instanceof Error && "httpStatus" in err) {
diff --git a/app/api/admin/verification/[verificationId]/route.ts b/app/api/admin/verification/[verificationId]/route.ts
index 38158e90b..62a5d6e39 100644
--- a/app/api/admin/verification/[verificationId]/route.ts
+++ b/app/api/admin/verification/[verificationId]/route.ts
@@ -11,6 +11,7 @@ import { ConsultantVerificationStatus } from "@prisma/client";
import { notifyVerificationStatusChanged } from "@/lib/novu";
import { ReviewVerificationSchema } from "@/schemas/verifications";
import { requirePrivilegedAuth } from "@/lib/auth-helpers";
+import { purgeExpertSurfaces } from "@/lib/data/public-cache";
interface RouteParams {
params: Promise<{ verificationId: string }>;
@@ -196,6 +197,11 @@ export async function PATCH(req: NextRequest, { params }: RouteParams) {
: []),
]);
+ // Verification is the de-facto publish switch for a consultant: VERIFIED is
+ // what puts them on the landing page and the experts directory, and any other
+ // status takes them off. Purge now rather than leave the ISR window to expire.
+ purgeExpertSurfaces(verification.consultantProfileId);
+
// Fire-and-forget: notify consultant of verification status change
const consultantUserId = verification.consultantProfile?.user?.id;
if (consultantUserId) {
diff --git a/app/api/announcements/route.ts b/app/api/announcements/route.ts
index f348b95ac..8ce04dad2 100644
--- a/app/api/announcements/route.ts
+++ b/app/api/announcements/route.ts
@@ -59,7 +59,17 @@ export async function GET() {
reportTransient("announcements read", error, {
subsystem: "notifications",
});
- return NextResponse.json({ success: true, data: [] });
+ // `no-store` on the degraded branch only, matching
+ // app/api/user/consultants/route.ts. Not load-bearing today — the success
+ // path sets no cache header and Next 15 leaves Route Handlers uncached —
+ // but the two siblings disagreed and this is the safe half of the
+ // disagreement. Whoever adds an s-maxage to the success path should not
+ // have to also remember that a cached empty banner outlives the outage
+ // that caused it. (#1125)
+ return NextResponse.json(
+ { success: true, data: [] },
+ { headers: { "Cache-Control": "no-store" } },
+ );
}
Sentry.captureException(
error instanceof Error ? error : new Error(String(error)),
diff --git a/app/api/organizations/[orgId]/route.ts b/app/api/organizations/[orgId]/route.ts
index a1d9feb89..1786686ec 100644
--- a/app/api/organizations/[orgId]/route.ts
+++ b/app/api/organizations/[orgId]/route.ts
@@ -15,11 +15,13 @@ import { NextResponse, type NextRequest } from "next/server";
import { z } from "zod";
import { Prisma } from "@prisma/client";
import prisma from "@/lib/prisma";
+import { orgDetailsInclude } from "@/lib/data/org-details-include";
import { requireOrgAccess, requireOrgOwner } from "@/lib/auth-helpers";
import { isAtLeastRole } from "@/lib/auth/role-ranks";
import { AUDIT_ACTIONS } from "@/lib/enterprise/audit-actions";
import { transitionOrganization } from "@/lib/enterprise/transitions";
import { withSerializableRetry } from "@/lib/db/serializable-retry";
+import { purgeOrgSurfaces } from "@/lib/data/public-cache";
import { encryptPAN } from "@/lib/payments/tax/pan-crypto";
const SizeBucketSchema = z.enum([
@@ -96,34 +98,9 @@ export async function GET(
const org = await prisma.organization.findUnique({
where: { id: orgId },
- include: {
- billingAccount: {
- select: {
- id: true,
- fundingSource: true,
- currency: true,
- walletBalance: true,
- creditLimit: true,
- },
- },
- payoutAccount: {
- select: {
- id: true,
- status: true,
- accountNumberLast4: true,
- bankName: true,
- },
- },
- _count: {
- select: {
- memberships: true,
- contracts: true,
- invoices: true,
- purchaseOrders: true,
- auditLogs: true,
- },
- },
- },
+ // Shared with the server-side seed in lib/data/org-details-server.ts so
+ // the route and the prefetch cannot drift apart.
+ include: orgDetailsInclude,
});
if (!org) {
return NextResponse.json(
@@ -205,6 +182,11 @@ export async function PATCH(
}
}
+ // Captured inside the transaction so a slug rename can purge the OLD public
+ // path too — otherwise its cached document keeps being served under a URL the
+ // org no longer answers to.
+ let previousSlug: string | undefined;
+
try {
// Serializable closes the TOCTOU between the wind-down COUNT checks below
// and the UPDATE (S2 in the state audit): a concurrent invoice/assignment
@@ -216,6 +198,7 @@ export async function PATCH(
where: { id: orgId },
include: { billingAccount: { select: { id: true, walletBalance: true } } },
});
+ previousSlug = current?.slug;
if (!current) {
throw Object.assign(new Error("Organization not found"), {
httpStatus: 404,
@@ -507,6 +490,11 @@ export async function PATCH(
),
);
+ // isPublic, slug, name and the whole brandingProfile upsert are all rendered
+ // on the public directory and org profile, so publish the change now instead
+ // of leaving it behind the ISR window.
+ purgeOrgSurfaces(updated.slug, previousSlug);
+
return NextResponse.json({ organization: updated });
} catch (err) {
if (err instanceof Error && "httpStatus" in err) {
diff --git a/app/api/staff/moderation/profiles/[verificationId]/route.ts b/app/api/staff/moderation/profiles/[verificationId]/route.ts
index e5af62f7b..84e8dbb6f 100644
--- a/app/api/staff/moderation/profiles/[verificationId]/route.ts
+++ b/app/api/staff/moderation/profiles/[verificationId]/route.ts
@@ -9,6 +9,7 @@ import { notifyVerificationStatusChanged } from "@/lib/novu";
import { ReviewVerificationSchema } from "@/schemas/verifications";
import { requirePrivilegedAuth } from "@/lib/auth-helpers";
+import { purgeExpertSurfaces } from "@/lib/data/public-cache";
import * as Sentry from "@sentry/nextjs";
interface RouteParams {
params: Promise<{ verificationId: string }>;
@@ -18,7 +19,7 @@ interface RouteParams {
* GET /api/staff/moderation/profiles/[verificationId]
* Get verification request details
*/
-export async function GET(req: NextRequest, { params }: RouteParams) {
+export async function GET(_req: NextRequest, { params }: RouteParams) {
try {
const auth = await requirePrivilegedAuth();
if (auth.error) return auth.error;
@@ -189,6 +190,10 @@ export async function PATCH(req: NextRequest, { params }: RouteParams) {
: []),
]);
+ // Same publish switch as the admin verification route: VERIFIED puts the
+ // consultant on the public surfaces, anything else takes them off.
+ purgeExpertSurfaces(verification.consultantProfileId);
+
// Fire-and-forget: notify consultant of verification status change
const consultantUserId = verification.consultantProfile?.user?.id;
if (consultantUserId) {
diff --git a/app/api/trials/[trialId]/route.ts b/app/api/trials/[trialId]/route.ts
index a6211b37c..17ea4439e 100644
--- a/app/api/trials/[trialId]/route.ts
+++ b/app/api/trials/[trialId]/route.ts
@@ -33,6 +33,7 @@ import { UpdateTrialSchema } from "@/schemas/trials";
import { requireApiAuth, isPrivileged } from "@/lib/auth-helpers";
import { buildOccupiedAppointmentFilter } from "@/utils/slotAllocation/occupancyPolicy";
import { consultantPublicScalars } from "@/lib/data/consultant-public";
+import { reportSentryError } from "@/lib/observability/report";
interface RouteContext {
params: Promise<{ trialId: string }>;
@@ -739,6 +740,14 @@ export async function PATCH(request: NextRequest, context: RouteContext) {
});
} catch (error) {
console.error("Error updating trial session:", error);
+ // The refund above runs after the trial writes commit, so a failure here
+ // can leave a cancelled-but-unrefunded trial — alert on it (#1125).
+ reportSentryError(error, {
+ subsystem: "trials",
+ op: "PATCH /api/trials/[trialId]",
+ expected: false,
+ extra: { trialId },
+ });
return NextResponse.json(
{ error: "An error occurred while updating trial session" },
{ status: 500 },
@@ -852,6 +861,14 @@ export async function DELETE(request: NextRequest, context: RouteContext) {
});
} catch (error) {
console.error("Error cancelling trial session:", error);
+ // Same money-alert gap as PATCH: the refund runs after the trial writes
+ // commit, so a failure here can leave a cancelled-but-unrefunded trial (#1125).
+ reportSentryError(error, {
+ subsystem: "trials",
+ op: "DELETE /api/trials/[trialId]",
+ expected: false,
+ extra: { trialId },
+ });
return NextResponse.json(
{ error: "An error occurred while cancelling trial session" },
{ status: 500 },
diff --git a/app/api/user/[id]/route.ts b/app/api/user/[id]/route.ts
index 7e0fe4910..9841466c7 100644
--- a/app/api/user/[id]/route.ts
+++ b/app/api/user/[id]/route.ts
@@ -1,5 +1,6 @@
import * as Sentry from "@sentry/nextjs";
import prisma from "@/lib/prisma";
+import { getUserDetails } from "@/lib/data/user-details";
import { NextRequest, NextResponse } from "next/server";
import { UserRole, Gender } from "@prisma/client";
@@ -37,67 +38,7 @@ export async function GET(
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
- const user = await prisma.user.findUnique({
- where: { id: id },
- include: {
- // Professional background at User level
- workExperiences: {
- orderBy: [{ isCurrent: "desc" }, { startDate: "desc" }],
- },
- education: {
- orderBy: { endYear: "desc" },
- },
- certifications: {
- orderBy: { issueDate: "desc" },
- },
- consultantProfile: {
- select: {
- id: true,
- description: true,
- experience: true,
- rating: true,
- domainId: true,
- // New fields
- headline: true,
- websiteUrl: true,
- twitterUrl: true,
- githubUrl: true,
- videoIntroUrl: true,
- languages: true,
- toolsAndTechnologies: true,
- mentoringStyle: true,
- sessionTypes: true,
- profileCompletionPercentage: true,
- isVerified: true,
- totalMenteesHelped: true,
- },
- },
- consulteeProfile: {
- select: {
- id: true,
- aboutMe: true,
- preferredLanguage: true,
- goals: true,
- careerStage: true,
- skillsToDevelop: true,
- budgetPreference: true,
- },
- },
- staffProfile: {
- select: {
- id: true,
- department: true,
- position: true,
- },
- },
- adminProfile: {
- select: {
- id: true,
- notes: true,
- },
- },
- },
- });
+ const user = await getUserDetails(id);
if (!user) {
return NextResponse.json({ error: "User not found" }, { status: 404 });
diff --git a/app/api/user/consultants/[id]/route.ts b/app/api/user/consultants/[id]/route.ts
index bf00916ef..f4dcd17ea 100644
--- a/app/api/user/consultants/[id]/route.ts
+++ b/app/api/user/consultants/[id]/route.ts
@@ -15,6 +15,7 @@ import { z } from "zod";
import { experienceValidation } from "@/schemas/shared";
import { checkActiveAppointments } from "../utils/consultant-appointments";
import { getSession } from "@/lib/auth-server";
+import { purgeExpertSurfaces } from "@/lib/data/public-cache";
import { apiError } from "@/lib/errors";
import * as Sentry from "@sentry/nextjs";
import {
@@ -562,6 +563,11 @@ export async function PUT(
},
});
+ // Headline, description, experience, domain and tags are all rendered on the
+ // public profile and the directory cards, so an expert editing their profile
+ // should see it live rather than wait out the ISR window.
+ purgeExpertSurfaces(id);
+
return NextResponse.json({ data: updatedConsultant });
} catch (error) {
Sentry.captureException(error instanceof Error ? error : new Error(String(error)), { tags: { subsystem: "auth" } });
@@ -624,6 +630,9 @@ export async function DELETE(
data: { deletedAt: new Date() },
}),
]);
+ // deletedAt is one of the two public gates — the profile has just left
+ // both public surfaces.
+ purgeExpertSurfaces(id);
return NextResponse.json({
message: "Consultant deactivated (financial history retained)",
softDeleted: true,
@@ -665,6 +674,7 @@ export async function DELETE(
}),
]);
+ purgeExpertSurfaces(id);
return NextResponse.json({ message: "Consultant deleted successfully" });
} catch (error) {
Sentry.captureException(error instanceof Error ? error : new Error(String(error)), { tags: { subsystem: "auth" } });
diff --git a/app/api/user/reviews/[id]/route.ts b/app/api/user/reviews/[id]/route.ts
index cb961ce71..4259877c6 100644
--- a/app/api/user/reviews/[id]/route.ts
+++ b/app/api/user/reviews/[id]/route.ts
@@ -10,6 +10,7 @@ import {
forbiddenResponse,
} from "@/lib/auth-helpers";
import { recomputeConsultantRating } from "@/lib/reviews";
+import { purgeReviewSurfaces } from "@/lib/data/public-cache";
import { withSerializableRetry } from "@/lib/db/serializable-retry";
import { UpdateReviewSchema } from "@/schemas/feedbacks";
@@ -122,6 +123,8 @@ export async function PUT(
),
);
+ purgeReviewSurfaces(review.consultantProfileId);
+
return NextResponse.json(updatedReview, { status: 200 });
} catch (error) {
Sentry.captureException(error instanceof Error ? error : new Error(String(error)), { tags: { subsystem: "auth" } });
@@ -179,6 +182,8 @@ export async function DELETE(
),
);
+ purgeReviewSurfaces(review.consultantProfileId);
+
return NextResponse.json(
{ message: "Review deleted successfully" },
{ status: 200 },
diff --git a/app/api/user/reviews/route.ts b/app/api/user/reviews/route.ts
index 4348808de..ccb4f461a 100644
--- a/app/api/user/reviews/route.ts
+++ b/app/api/user/reviews/route.ts
@@ -7,6 +7,7 @@ import { notifyNewReview } from "@/lib/novu";
import { CreateReviewSchema } from "@/schemas/feedbacks";
import { apiError } from "@/lib/errors";
import { getSession } from "@/lib/auth-server";
+import { purgeReviewSurfaces } from "@/lib/data/public-cache";
import { spamLimiter, applyRateLimit } from "@/lib/rate-limit";
import {
hasCompletedBookingWith,
@@ -197,6 +198,11 @@ export async function POST(req: NextRequest) {
dashboardUrl: "/dashboard/consultant/reviews",
});
+ // Reviews are the landing page's testimonials and they move the expert's
+ // denormalized rating, which orders the directory — both surfaces are stale
+ // until purged, and the landing page's window is an hour.
+ purgeReviewSurfaces(newReview.consultantProfileId);
+
return NextResponse.json(newReview, { status: 201 });
} catch (error) {
// @@unique([consultantProfileId, consulteeProfileId]) — one review per pair.
diff --git a/app/api/webhooks/utils.ts b/app/api/webhooks/utils.ts
index f10027099..cd17e5b46 100644
--- a/app/api/webhooks/utils.ts
+++ b/app/api/webhooks/utils.ts
@@ -41,6 +41,7 @@ import { AUDIT_ACTIONS } from "@/lib/enterprise/audit-actions";
import { recordSystemError } from "@/lib/enterprise/system-events";
import { withSerializableRetry } from "@/lib/db/serializable-retry";
import { mapGatewayRefundStatus } from "@/lib/payments/refund-status";
+import { reportSentryError } from "@/lib/observability/report";
// Re-export payment handlers from lib (architectural fix)
export {
@@ -528,7 +529,15 @@ export async function isDbHealthy(): Promise {
// ORM connectivity probe (no raw SQL): a LIMIT 1 read proves the connection.
await prisma.user.findFirst({ select: { id: true } });
return true;
- } catch {
+ } catch (error) {
+ // Handlers 503 on false so gateways retry — correct for a transient
+ // outage, but a persistent non-connectivity fault (e.g. schema drift)
+ // would 503 every webhook forever with no signal. Report it (#1125).
+ reportSentryError(error, {
+ subsystem: "webhooks",
+ op: "isDbHealthy",
+ expected: false,
+ });
return false;
}
}
@@ -774,8 +783,22 @@ export async function handleRefundCreated(
err,
),
);
- // Opportunistic: if wallet-based funding was used, credit the
- // refund amount back. Swallow if no wallet flow applies.
+ // If wallet-based funding was used, credit the refund amount back.
+ //
+ // "Swallow if no wallet flow applies" is what the comment here used to
+ // say, and it described a case the `fundingSource === "WALLET"` guard
+ // already handles structurally — so the catch only ever fired on a REAL
+ // failure, and only ever wrote a console.warn that production deleted
+ // (#1122). The note twenty lines above calls this credit "the guaranteed
+ // bookkeeping" for an invoice refund. It was not guaranteed and nobody
+ // could have known.
+ //
+ // Still not rethrown, and that is deliberate: the dispatcher stamps
+ // error=true on a throw and the stuck-event sweeper only re-drives
+ // error=null, so rethrowing would roll back the whole refund booking
+ // AND retire the event permanently — strictly worse than a booked
+ // refund missing its wallet credit. Reported loudly instead, so the
+ // credit can be applied by hand. #1128 tracks making it durable.
try {
const ba = await tx.billingAccount.findFirst({
where: { ownerOrgId: invoice.organizationId },
@@ -791,8 +814,19 @@ export async function handleRefundCreated(
});
}
} catch (err) {
+ reportSentryError(err, {
+ subsystem: "enterprise",
+ op: "handleRefundCreated.walletCredit",
+ extra: {
+ refundId,
+ invoiceId: invoice.id,
+ organizationId: invoice.organizationId,
+ amountPaise: amount,
+ providerPaymentId,
+ },
+ });
console.warn(
- `⚠️ Wallet credit for invoice refund ${refundId} skipped:`,
+ `⚠️ Wallet credit for invoice refund ${refundId} FAILED — org not credited:`,
err,
);
}
diff --git a/app/dashboard/consultant/[consultantId]/(features)/appointments/[appointmentId]/timings/page.tsx b/app/dashboard/consultant/[consultantId]/(features)/appointments/[appointmentId]/timings/page.tsx
index ff02573f7..ae87a5366 100644
--- a/app/dashboard/consultant/[consultantId]/(features)/appointments/[appointmentId]/timings/page.tsx
+++ b/app/dashboard/consultant/[consultantId]/(features)/appointments/[appointmentId]/timings/page.tsx
@@ -13,6 +13,7 @@ import {
} from "@/lib/data/manage-timings-target";
import { requirePersonalProfileAccess } from "@/lib/auth/personal-dashboard-access";
import { buildManageTimingsSubject } from "@/lib/scheduling/manage-timings-subject";
+import { isEventIdFormat } from "@/schemas/slotAllocation/validationSchemas";
import { ManageTimingsClient } from "./ManageTimingsClient";
@@ -119,6 +120,11 @@ export default async function ManageTimingsPage({
target.groupTotalSessions,
);
+ // Allocate APIs require UUID/CUID event ids. Hand-crafted mock PKs (legal
+ // Prisma String @ids) would 400 on save — fail closed here instead of
+ // mounting a picker that cannot submit.
+ if (!isEventIdFormat(resolved.subject.eventId)) notFound();
+
const backHref = `/dashboard/consultant/${consultantId}/appointments`;
return (
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..f3d699bcf 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,17 +13,15 @@ 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
+ // The factory's staleTime (2 min) is deliberately NOT overridden here. This
+ // used to force `staleTime: 0` under a comment about showing stale data
+ // immediately, which is not what staleTime does: it marks the server-prefetched
+ // cache entry stale on mount, so the client refetched
+ // GET /api/dashboard/consultant/[id] straight after hydration and recomputed
+ // the identical payload the page had just dehydrated — doubling every query
+ // behind it. Harmless before #890 seeded the cache; pure waste after. (#1121)
const dashboardQuery = {
...createConsultantQueries(consultantId).dashboard,
- // Show stale data immediately while fetching in background
- staleTime: 0,
refetchOnWindowFocus: false,
};
const {
@@ -37,7 +35,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,8 +82,7 @@ export default function HomePageClient({
diff --git a/app/dashboard/consultant/[consultantId]/(features)/home/HomeTab.tsx b/app/dashboard/consultant/[consultantId]/(features)/home/HomeTab.tsx
index 5b03514c7..a743d239b 100644
--- a/app/dashboard/consultant/[consultantId]/(features)/home/HomeTab.tsx
+++ b/app/dashboard/consultant/[consultantId]/(features)/home/HomeTab.tsx
@@ -13,7 +13,6 @@ import Link from "next/link";
import { useRouter } from "next/navigation";
import { motion } from "framer-motion";
import {
- DashboardHeader,
DashboardContent,
} from "@/components/dashboard/PageScaffold";
import { DataCard, EmptyState } from "@/components/dashboard/DataCard";
@@ -71,7 +70,6 @@ import type {
interface HomeTabProps {
appointments: TAppointment[];
consultantId: string;
- consultantName?: string;
pendingRequestsCount?: number;
performanceSnapshot?: TPerformanceSnapshot;
financialSummary?: TFinancialSummary;
@@ -93,7 +91,6 @@ const fadeInUp = {
export function HomeTab({
appointments,
consultantId,
- consultantName,
pendingRequestsCount = 0,
performanceSnapshot,
financialSummary,
@@ -164,7 +161,6 @@ export function HomeTab({
.slice(0, 5);
}, [allUpcomingAppointments]);
- const firstName = consultantName?.split(" ")[0];
// "Needs you now" — derived from data already on the page, so no extra
// fetch. The rows go over whole, ids and ends included: these are raw
@@ -190,11 +186,9 @@ export function HomeTab({
return (
<>
-
-
+ {/* 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.
- let needsYou: NeedsYouSummary | null = null;
- if (!access.isInspecting) {
- needsYou = await 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.
- await Promise.allSettled([
- queryClient.prefetchQuery({
+ // 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/app/dashboard/consultant/[consultantId]/(features)/settings/SettingsTab.tsx b/app/dashboard/consultant/[consultantId]/(features)/settings/SettingsTab.tsx
index 49100a876..886b94ae2 100644
--- a/app/dashboard/consultant/[consultantId]/(features)/settings/SettingsTab.tsx
+++ b/app/dashboard/consultant/[consultantId]/(features)/settings/SettingsTab.tsx
@@ -26,6 +26,7 @@ import {
validateAllSlotsDetailed,
} from "@/utils/timeSlotValidation";
import { formatSlotsForApi } from "@/utils/schedule/formatting";
+import { reportSentryError } from "@/lib/observability/report";
import type { SlotsType } from "@/utils/schedule/types";
import { ProfileSection, type Option } from "./sections/ProfileSection";
import { AvailabilitySection } from "./sections/AvailabilitySection";
@@ -508,6 +509,15 @@ export function SettingsTab({ consultant }: Readonly) {
description: "Your profile settings have been successfully updated.",
});
} catch (error) {
+ // formatSlotsForApi now throws rather than degrading, so a slot-formatting
+ // regression lands here instead of silently shipping a short or empty
+ // availability payload. Worth capturing: the consultant sees a retry toast
+ // and would otherwise be the only one who ever knew. (#1125)
+ reportSentryError(error, {
+ subsystem: "consultants",
+ op: "SettingsTab.save",
+ extra: { consultantId: consultant.id, scheduleType },
+ });
console.error("Error updating settings:", error);
toast({
title: "Error",
diff --git a/app/dashboard/consultant/[consultantId]/(features)/settings/sections/ProfileSection.tsx b/app/dashboard/consultant/[consultantId]/(features)/settings/sections/ProfileSection.tsx
index 342bf7f02..756f12ea8 100644
--- a/app/dashboard/consultant/[consultantId]/(features)/settings/sections/ProfileSection.tsx
+++ b/app/dashboard/consultant/[consultantId]/(features)/settings/sections/ProfileSection.tsx
@@ -163,11 +163,11 @@ export function ProfileSection({
{/* Right Column - Description */}
-
-
diff --git a/app/dashboard/consultant/[consultantId]/layout.tsx b/app/dashboard/consultant/[consultantId]/layout.tsx
index 8101e97df..d9f2a0d35 100644
--- a/app/dashboard/consultant/[consultantId]/layout.tsx
+++ b/app/dashboard/consultant/[consultantId]/layout.tsx
@@ -45,6 +45,7 @@ import { useChatUnreadCount } from "@/hooks/useChatUnreadCount";
import { useSession } from "@/lib/auth-client";
import { signOutEverywhere } from "@/lib/auth/sign-out";
import { getEffectiveUserId } from "@/utils/auth";
+import { useServerUserId } from "@/components/dashboard/ServerUserId";
import { consultantFetchers, schedulePrefetch } from "@/lib/dashboard-queries";
import { verificationStatusBadge } from "@/lib/labels/session-labels";
import {
@@ -384,7 +385,11 @@ function ConsultantLayoutInner({ children, params }: Readonly) {
const { data: session, isPending: isSessionLoading } = useSession();
const router = useRouter();
- const userId = getEffectiveUserId(session);
+ // Fall back to the server-resolved id: useSession() is still pending during
+ // SSR, so without this the query key below is ["user-details", undefined] and
+ // the server seed in app/dashboard/layout.tsx can never be read (#1105).
+ const serverUserId = useServerUserId();
+ const userId = getEffectiveUserId(session) ?? serverUserId;
// Sync user as Novu subscriber (once per session)
useNovuSubscriberSync();
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..1c39a1758 100644
--- a/app/dashboard/consultee/[consulteeId]/(features)/home/page.tsx
+++ b/app/dashboard/consultee/[consulteeId]/(features)/home/page.tsx
@@ -6,31 +6,48 @@ 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 {
+ resolveDefaultScopeKey,
+ scopeFromKey,
+} from "@/lib/api/scope/server-default";
type PageProps = {
params: Promise<{ consulteeId: string }>;
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
};
-export default async function HomePage({ params }: Readonly) {
+export default async function HomePage({
+ params,
+ searchParams,
+}: Readonly) {
const { consulteeId } = await params;
+ const orgScope = (await searchParams)?.orgScope;
// 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();
+ // Shared with useOrgScope's default rule so the two cannot drift, and it
+ // honours ?orgScope= — which the local mirror did not, so every scope toggle
+ // prefetched the wrong scope and threw the result away.
+ const scopeKey = resolveDefaultScopeKey(session, {
+ defaultForOrgMember: "all",
+ orgScopeParam: typeof orgScope === "string" ? orgScope : null,
+ });
+ const scope = scopeFromKey(scopeKey);
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/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/consultee/[consulteeId]/layout.tsx b/app/dashboard/consultee/[consulteeId]/layout.tsx
index bf472a997..1d810b517 100644
--- a/app/dashboard/consultee/[consulteeId]/layout.tsx
+++ b/app/dashboard/consultee/[consulteeId]/layout.tsx
@@ -39,6 +39,7 @@ import { useNovuSubscriberSync } from "@/hooks/useNovuSubscriberSync";
import { useSession } from "@/lib/auth-client";
import { signOutEverywhere } from "@/lib/auth/sign-out";
import { getEffectiveUserId } from "@/utils/auth";
+import { useServerUserId } from "@/components/dashboard/ServerUserId";
import { fetchConsulteeDetails, fetchUserDetails } from "@/lib/user";
import { schedulePrefetch } from "@/lib/dashboard-queries";
import { UserProvider } from "./UserContext";
@@ -179,7 +180,11 @@ function ConsulteeLayoutInner({ children, params }: Readonly) {
const { data: session, isPending: isSessionLoading } = useSession();
const router = useRouter();
- const userId = getEffectiveUserId(session);
+ // Fall back to the server-resolved id: useSession() is still pending during
+ // SSR, so without this the query key below is ["user-details", undefined] and
+ // the server seed in app/dashboard/layout.tsx can never be read (#1105).
+ const serverUserId = useServerUserId();
+ const userId = getEffectiveUserId(session) ?? serverUserId;
// Sync user as Novu subscriber (once per session)
useNovuSubscriberSync();
diff --git a/app/dashboard/layout.tsx b/app/dashboard/layout.tsx
index 85df9a750..56c8bb12f 100644
--- a/app/dashboard/layout.tsx
+++ b/app/dashboard/layout.tsx
@@ -1,10 +1,76 @@
+import {
+ HydrationBoundary,
+ QueryClient,
+ dehydrate,
+} from "@tanstack/react-query";
import { requireOnboarded } from "@/lib/auth-guard";
+import { getUserDetails } from "@/lib/data/user-details";
+import { toPlain } from "@/lib/data/serialize";
+import { ServerUserIdProvider } from "@/components/dashboard/ServerUserId";
+import { DashboardScrollLock } from "@/components/dashboard/DashboardScrollLock";
+/**
+ * Seeds the query that both personal dashboard layouts gate their render on.
+ *
+ * The consultant and consultee layouts are client components that return a
+ * shell skeleton instead of `children` whenever their queries are loading —
+ * always true during SSR, because nothing prefetched them. So no dashboard
+ * markup reached the HTML at all: measured on #1103, the server response had
+ * no `
{children}>;
+ const session = await requireOnboarded();
+ const queryClient = new QueryClient();
+
+ // Swallow: a failed seed should degrade to the client fetch, not 500 every
+ // dashboard route. Losing it only costs the server-rendered shell.
+ await queryClient
+ .prefetchQuery({
+ queryKey: ["user-details", session.user.id],
+ // The route responds `{ data: user }` and the client fetcher unwraps
+ // `.data`, so the cached value is the user row itself.
+ queryFn: async () => toPlain(await getUserDetails(session.user.id)),
+ })
+ .catch(() => undefined);
+
+ return (
+
+
+
+ {children}
+
+
+ );
}
diff --git a/app/dashboard/org-workspace/[orgWorkspaceId]/OrgWorkspaceShell.tsx b/app/dashboard/org-workspace/[orgWorkspaceId]/OrgWorkspaceShell.tsx
index e574356ee..2e1c1dc21 100644
--- a/app/dashboard/org-workspace/[orgWorkspaceId]/OrgWorkspaceShell.tsx
+++ b/app/dashboard/org-workspace/[orgWorkspaceId]/OrgWorkspaceShell.tsx
@@ -115,9 +115,9 @@ export function OrgWorkspaceShell({
const pageLabel = PAGE_LABELS[segment] ?? "Overview";
return (
-
+
{/* Sidebar — hidden on mobile, visible md+ */}
-
+
{/* Right panel: context bar + page content + mobile tabs */}
-
+
-
+
{children}
diff --git a/app/dashboard/org-workspace/[orgWorkspaceId]/loading.tsx b/app/dashboard/org-workspace/[orgWorkspaceId]/loading.tsx
index d1436a671..8318bf9e8 100644
--- a/app/dashboard/org-workspace/[orgWorkspaceId]/loading.tsx
+++ b/app/dashboard/org-workspace/[orgWorkspaceId]/loading.tsx
@@ -1,5 +1,25 @@
-import { CollapsibleSidebarSkeleton } from "@/components/dashboard/CollapsibleSidebar";
+import { Skeleton } from "@/components/ui/skeleton";
+/**
+ * Content-only fallback. This segment's layout already mounts
+ * `OrgWorkspaceShell`, and `loading.tsx` renders as that shell's
+ * `children` — a full-page `CollapsibleSidebarSkeleton` here would nest a
+ * second viewport + sidebar inside the scrollable main.
+ *
+ * No outer padding: the shell's `` already wraps children in `p-6`.
+ */
export default function Loading() {
- return ;
+ return (
+
+ );
+}
+
+export default function OrgDashboardShell({
+ children,
+ params,
+}: {
+ children: React.ReactNode;
+ params: Promise<{ orgId: string }>;
+}) {
+ const { orgId } = use(params);
+ const pathname = usePathname();
+ const router = useRouter();
+ const { data: session, isPending: isSessionLoading } = useSession();
+
+ // ADR 23 — the personal dashboards did this and the org tree did not, so a
+ // user onboarded straight into an org by invite was never POSTed to
+ // /api/novu/subscriber. Their Novu record stayed bare and any template
+ // interpolating subscriber.firstName / email degraded.
+ useNovuSubscriberSync();
+
+ const {
+ data: org,
+ error,
+ isLoading,
+ } = useQuery({
+ queryKey: orgDetailsQueryKey(orgId),
+ queryFn: () => fetchOrgDetails(orgId),
+ enabled: !!orgId && !!session?.user?.id,
+ staleTime: 60_000,
+ });
+
+ // Compute grouped sidebar items from capabilities + fundingSource + role.
+ // Visibility comes from the org permission matrix
+ // (lib/auth/org-permissions.ts) — the SAME source the page guards and
+ // API routes check, so a tab can't drift into "shown but rejected".
+ // Capability gates (canSponsor/canHost/requiresPO/fundingSource) remain
+ // separate structural conditions combined per item. Five clusters
+ // (People / Commerce / Resources / Insights / Configuration) plus an
+ // ungrouped Overview block; groups with zero remaining items drop out.
+ const sidebarGroups: CollapsibleSidebarGroup[] = useMemo(() => {
+ if (!org) return [];
+ const { canSponsor, canHost, fundingSource, requiresPO } = org.organization;
+ const membership = org.membership;
+ const role = membership.role;
+ const can = (surface: OrgSurface) => hasOrgPermission(role, surface);
+
+ // Resources group defaults collapsed for OWNER + MAINTAINER — their
+ // primary job isn't document triage. Open by default for MANAGER +
+ // SUPPORT who live in those tabs.
+ const resourcesCollapsedDefault = role === "OWNER" || role === "MAINTAINER";
+
+ type ItemSpec = {
+ name: string;
+ icon: LucideIcon;
+ path: string;
+ show?: boolean;
+ };
+
+ // Top (no label) — Overview + consumer-role landing pages. The
+ // LEARNER / EXPERT cases are the only sidebar items those roles
+ // ever see. Operators (MANAGER+) get the richer per-tab views.
+ //
+ // My Program stays separate from Commerce's Programs on purpose: they are
+ // different objects, not two scopes of one. `my-program` is a LEARNER's
+ // own assignment and coverage detail; `programs` is the sponsor's catalog
+ // CRUD. Appointments was the only genuine same-object scope split, and it
+ // is now one entry with a Mine/Everyone toggle.
+ const topItems: ItemSpec[] = [
+ { name: "Overview", icon: Home, path: "home" },
+ {
+ name: "My Program",
+ icon: GraduationCap,
+ path: "my-program",
+ show: can("myProgram.read") && canSponsor,
+ },
+ {
+ name: "Compensation",
+ icon: UserCog,
+ path: "compensation",
+ show: can("myArrangement.read") && canHost,
+ },
+ {
+ // Any ACTIVE member — learners who ATTEND org sessions and experts
+ // who DELIVER them both need their own per-org appointments surface.
+ // Deliberately NOT gated on canHost (that would exclude pure
+ // learners): requireOrgAccess already floors this at active
+ // membership, so show it to everyone who reaches the org dashboard.
+ // Operators additionally get the "Everyone" scope inside the page.
+ name: "Appointments",
+ icon: CalendarCheck,
+ path: "appointments",
+ },
+ {
+ // Participant surface, same floor as Appointments. Chat is scoped to
+ // this org purely by living on this route — `useOrgScope` pins under
+ // /dashboard/organization/[orgId]/ — so a member of several orgs gets
+ // one clean inbox per org with no picker.
+ //
+ // Not an operator surface: Stream only returns channels the viewer is a
+ // member of, and there is no org-wide chat query behind it. ADR 20
+ // keeps session content with the participants.
+ name: "Messages",
+ icon: MessageSquare,
+ path: "messages",
+ },
+ {
+ // Delivery surface: allocating slots is something only the person
+ // delivering the session can do, so it shows for members who hold a
+ // consultant profile. The page itself redirects anyone else — gating on
+ // the profile rather than on MemberRole.EXPERT means an OWNER who also
+ // delivers still gets it.
+ name: "Requests",
+ icon: ClipboardCheck,
+ path: "requests",
+ // The comment above described this gate for months but the code did
+ // not implement it: `myArrangement.read` is exact-role EXPERT, so an
+ // OWNER or MANAGER who also delivers got no nav entry even though the
+ // page admits anyone holding a consultantProfileId. That is ADR 19's
+ // "gate and page disagree" failure inverted — a reachable page with no
+ // way to reach it. The profile is the real predicate; the role check
+ // stays as the cheap path for the common EXPERT case.
+ show:
+ (can("myArrangement.read") || membership.consultantProfileId !== null) &&
+ canHost,
+ },
+ ];
+
+ // People — governance + roster surfaces (BILLING_ADMIN is
+ // operator-blind; SUPPORT reads Members for ticket investigation).
+ //
+ // Learners, Experts and Invitations are tabs on Members rather than
+ // sidebar entries: the first two were `?role=` filters on the very
+ // endpoint Members already reads, and splitting one roster across four
+ // nav slots made the group harder to scan than the data warranted.
+ const peopleItems: ItemSpec[] = [
+ {
+ // members.read is the widest of the four tab grants
+ // (OPERATIONS_READERS, vs GOVERNANCE for invitations and OPERATORS
+ // for learners/experts), so it alone decides the nav entry.
+ name: "Members",
+ icon: Users,
+ path: "members",
+ show: can("members.read"),
+ },
+ {
+ // #org-appts / #1025 — collaborators on THIS org's hosted webinar/class
+ // plans. Mirrors Compensation's gate: only host-capable orgs have
+ // collaborator-bearing plans, and inviting/managing collaborators is
+ // the plan-owning EXPERT's own surface.
+ name: "Collaborations",
+ icon: Users,
+ path: "collaborations",
+ show: can("myArrangement.read") && canHost,
+ },
+ ];
+
+ // Commerce — money + entitlement surfaces, ordered to match the
+ // setup flow: Contract first (commercial frame), then optional PO
+ // (India AP 3-way-match), then Programs (the entitlement the
+ // contract authorizes), then Billing (the invoices that result).
+ // Payouts + Reimbursements appear at the bottom for HOST orgs and
+ // for SPONSOR+PERSONAL orgs respectively — they're money-OUT
+ // outcomes, not setup. Mutation gates stay on the route handlers;
+ // the sidebar entries are visibility-only.
+ const commerceItems: ItemSpec[] = [
+ {
+ // Contract terms are org-structural (spec: MAINTAINER floor) —
+ // the old `≥MAINTAINER || finance` expression showed a dead tab
+ // to MANAGER + BILLING_ADMIN, whose page guard rejected them.
+ name: "Contracts",
+ icon: FileText,
+ path: "contracts",
+ show: canSponsor && can("contracts.read"),
+ },
+ {
+ // Only orgs running India AP 3-way-match (requiresPO=true) need
+ // the PO tab in their primary nav. The PO surface itself stays
+ // reachable by URL for orgs that opt in later — this is sidebar
+ // visibility, not authz. See docs/enterprise/30-programs-and-lifecycle/04-dashboard-pages.md.
+ name: "Purchase Orders",
+ icon: Receipt,
+ path: "purchase-orders",
+ show: canSponsor && requiresPO && can("purchaseOrders.read"),
+ },
+ {
+ // What the org SELLS, above what it SPONSORS — the two are different
+ // objects, not two scopes of one, so this is a separate entry rather
+ // than a toggle on Programs (ADR 19's my-program/programs precedent).
+ // Catalog is the host-side offering the org owns; Programs is the
+ // sponsor-side entitlement that funds bookings of anyone's plans.
+ name: "Catalog",
+ icon: Library,
+ path: "catalog",
+ show: canHost && can("catalog.manage"),
+ },
+ {
+ name: "Programs",
+ icon: Briefcase,
+ path: "programs",
+ show: canSponsor && can("programs.manage"),
+ },
+ {
+ // canSponsor only — the old extra `fundingSource === "WALLET"`
+ // branch was unreachable-by-construction (fundingSource lives on
+ // BillingAccount, which only exists when canSponsor=true) and
+ // produced a dead tab on any org where it could have fired.
+ name: "Billing",
+ icon: CreditCard,
+ path: "billing",
+ show: canSponsor && can("billing.read"),
+ },
+ {
+ name: "Payouts",
+ icon: Wallet,
+ path: "payouts",
+ show: canHost && can("payouts.read"),
+ },
+ {
+ name: "Reimbursements",
+ icon: Wallet,
+ path: "reimbursements",
+ show:
+ canSponsor &&
+ fundingSource === "PERSONAL" &&
+ can("reimbursements.read"),
+ },
+ {
+ // #776 §C — per-org dispute/chargeback surface. Finance-only; the
+ // money-path (org-wallet-first clawback) settles server-side.
+ name: "Disputes",
+ icon: ShieldAlert,
+ path: "disputes",
+ show: can("disputes.read"),
+ },
+ ];
+
+ // Resources — the artefacts a session leaves behind. MANAGER + SUPPORT
+ // live here. OWNER + MAINTAINER have access but the group is collapsed
+ // by default (see resourcesCollapsedDefault). BILLING_ADMIN is excluded
+ // — no booking-side remit.
+ //
+ // Two entries, not one tabbed "Resources" page: a group labelled
+ // Resources holding a single item also called Resources is redundant
+ // nesting, and the two lists answer different questions — Documents is a
+ // review queue, Recordings is an archive.
+ //
+ // Trials are deliberately absent: a trial IS an appointment, so it
+ // belongs on Appointments rather than in a list of its own.
+ const resourcesItems: ItemSpec[] = [
+ {
+ name: "Documents",
+ icon: FileText,
+ path: "documents",
+ show: can("operations.read"),
+ },
+ {
+ name: "Recordings",
+ icon: Video,
+ path: "recordings",
+ show: can("operations.read"),
+ },
+ ];
+
+ // Insights — analytics + compliance + audit trail. SUPPORT
+ // gets Audit + Analytics for ticket investigation; the bulk
+ // gate (`isOperationsReader`) covers both. Consent is MANAGER+
+ // only (DPDP grant/withdraw is a governance surface).
+ const insightsItems: ItemSpec[] = [
+ {
+ name: "Analytics",
+ icon: BarChart3,
+ path: "analytics",
+ show: can("operations.read"),
+ },
+ {
+ name: "Audit",
+ icon: ClipboardList,
+ path: "audit",
+ show: can("audit.read"),
+ },
+ {
+ name: "Consent",
+ icon: ShieldCheck,
+ path: "consent",
+ show: can("consent.read"),
+ },
+ ];
+
+ // Configuration — settings + outbound/inbound integrations.
+ // Settings stays MAINTAINER+ (org-config is sensitive). Webhooks +
+ // SCIM + Data exports are BILLING_ADMIN-reachable for finance
+ // integrations.
+ // Webhooks, SCIM and Data exports are tabs on Settings, alongside SSO —
+ // which had no sidebar entry at all and was reachable only via a link
+ // buried inside the settings page. One Configuration destination, five
+ // tabs, each still gated on its own matrix key.
+ const configurationItems: ItemSpec[] = [
+ {
+ name: "Settings",
+ icon: Settings,
+ path: "settings",
+ // Ungated as of ADR 23. The PAGE has always floored at active
+ // membership — each tab carries its own gate and UrlTabs renders
+ // nothing when none apply — but the nav entry demanded an operator
+ // grant, so a LEARNER or EXPERT could reach Settings only by typing the
+ // URL. That gap became user-visible once the member-level Notifications
+ // tab landed there. Non-operators now see Settings with that one tab.
+ },
+ ];
+
+ const filterItems = (items: ItemSpec[]) =>
+ items
+ .filter((it) => it.show !== false)
+ .map(({ show: _show, ...rest }) => rest);
+
+ const groups: CollapsibleSidebarGroup[] = [
+ { items: filterItems(topItems) },
+ { label: "People", items: filterItems(peopleItems) },
+ { label: "Commerce", items: filterItems(commerceItems) },
+ {
+ label: "Resources",
+ items: filterItems(resourcesItems),
+ defaultCollapsed: resourcesCollapsedDefault,
+ },
+ { label: "Insights", items: filterItems(insightsItems) },
+ { label: "Configuration", items: filterItems(configurationItems) },
+ ];
+
+ // Drop empty groups — e.g. a LEARNER's sidebar has nothing in
+ // People/Commerce/Operations/Insights/Configuration after
+ // filtering, so they see only the top "Overview + My Program"
+ // block without ghost headers.
+ return groups.filter((g) => g.items.length > 0);
+ }, [org]);
+
+ // Redirect to /home when landing on the bare /[orgId] route.
+ useEffect(() => {
+ if (org && pathname === `/dashboard/organization/${orgId}`) {
+ router.replace(`/dashboard/organization/${orgId}/home`);
+ }
+ }, [org, pathname, orgId, router]);
+
+ const handleSignOut = () => {
+ void signOutEverywhere();
+ };
+
+ if (!session?.user?.id && !isSessionLoading) {
+ return (
+
+ );
+ }
+
+ if ((isLoading || isSessionLoading) && !org) {
+ return ;
+ }
+
+ if (error) {
+ return (
+
+ );
+ }
+
+ // Split the context-switching surface across TWO dropdowns:
+ // - Top header (org identity) → switch between orgs / personal dashboard
+ // - Bottom user chip (personal) → user identity + sign out
+ // This mirrors the Linear / Agentstack pattern: the top answers "which
+ // context am I in?", the bottom answers "who am I?".
+ const userExt = session?.user as
+ | (NonNullable["user"] & {
+ orgWorkspaceProfileId?: string | null;
+ consultantProfileId?: string | null;
+ consulteeProfileId?: string | null;
+ organizationMemberships?: Array<{
+ organizationId: string;
+ organizationName: string;
+ organizationLogo: string | null;
+ role: string;
+ }>;
+ })
+ | undefined;
+
+ const personalHref = resolvePersonalDashboardHref({
+ orgWorkspaceProfileId: userExt?.orgWorkspaceProfileId,
+ consultantProfileId: userExt?.consultantProfileId,
+ consulteeProfileId: userExt?.consulteeProfileId,
+ });
+
+ // Other orgs the user belongs to (excluding the current one)
+ const otherOrgs = (userExt?.organizationMemberships ?? []).filter(
+ (m) => m.organizationId !== orgId,
+ );
+
+ // Bottom chip dropdown — context switching only.
+ // Top header stays static (org identity + collapse arrow). Single dropdown
+ // at the bottom keeps the "which dropdown has what" confusion at zero.
+ //
+ // No "Organization settings" entry here: it pointed at the very href the
+ // Configuration → Settings sidebar item already owns, so the same
+ // destination appeared twice in one sidebar.
+ const bottomUserChipActions: NonNullable<
+ React.ComponentProps["bottomUserChipActions"]
+ > = [
+ ...(personalHref
+ ? [
+ {
+ type: "item" as const,
+ label: "Personal Dashboard",
+ href: personalHref,
+ icon: LayoutDashboard,
+ },
+ ]
+ : []),
+ ...(otherOrgs.length > 0
+ ? [
+ { type: "separator" as const },
+ { type: "label" as const, label: "Switch organization" },
+ ...otherOrgs.map((m) => ({
+ type: "item" as const,
+ label: m.organizationName,
+ href: `/dashboard/organization/${m.organizationId}/home`,
+ icon: Building2,
+ })),
+ ]
+ : []),
+ ];
+
+ // Subtitle under the org name: the user's role in THIS org. Capability
+ // badges (Sponsor/Host/Hybrid) + funding source live in the top-bar —
+ // sidebar subtitle is user-specific, top-bar badges are org-specific.
+ const topSubtitle = org ? MEMBER_ROLE_LABEL[org.membership.role] : null;
+
+ // Map URL segments to human-readable page names so the breadcrumbs match
+ // the heading the user actually sees on the page.
+ const PAGE_LABELS: Record = {
+ home: "Overview",
+ "my-program": "My Program",
+ compensation: "Compensation",
+ collaborations: "Collaborations",
+ appointments: "Appointments",
+ messages: "Messages",
+ requests: "Requests",
+ members: "Members",
+ catalog: "Catalog",
+ programs: "Programs",
+ contracts: "Contracts",
+ "purchase-orders": "Purchase Orders",
+ documents: "Documents",
+ recordings: "Recordings",
+ billing: "Billing",
+ payouts: "Payouts",
+ reimbursements: "Reimbursements",
+ disputes: "Disputes",
+ analytics: "Analytics",
+ audit: "Audit",
+ consent: "Consent",
+ settings: "Settings",
+ };
+
+ // Full breadcrumb trail — every URL segment after /organization/{orgId}
+ // becomes a crumb. Forward-compatible with nested routes.
+ const breadcrumbs = pathname
+ .replace(`/dashboard/organization/${orgId}`, "")
+ .split("/")
+ .filter(Boolean)
+ .map((seg) => PAGE_LABELS[seg] ?? seg);
+
+ return (
+
+ {/* Collapsible sidebar — hidden on mobile, visible on md+ */}
+
+
+
+
+ {/* Right panel: context bar + page content */}
+
+
+
+ {/* Mobile bottom tab bar — only visible below md breakpoint */}
+
+
+
+ );
+}
diff --git a/app/dashboard/organization/[orgId]/appointments/[appointmentId]/DetailPageClient.tsx b/app/dashboard/organization/[orgId]/appointments/[appointmentId]/DetailPageClient.tsx
index f853772df..e34175c31 100644
--- a/app/dashboard/organization/[orgId]/appointments/[appointmentId]/DetailPageClient.tsx
+++ b/app/dashboard/organization/[orgId]/appointments/[appointmentId]/DetailPageClient.tsx
@@ -27,6 +27,11 @@ const DOCUMENT_KINDS = new Set(["CONSULTATION", "TRIAL", "SUBSCRIPTION"]);
* overridden, so navigating within the detail view keeps the member inside the
* org context instead of bouncing them to `/dashboard/consultee/...`.
*
+ * Reschedule still deep-links to the personal consultee reschedule heatmap
+ * (no org-native picker yet). `consulteeId` is passed from the SSR page
+ * (already loaded for the participation check) because this URL has `orgId`,
+ * not `consulteeId`.
+ *
* `role="consultee"` because this page is the ATTENDING side. An EXPERT
* delivering org sessions manages them from Requests and their own tree; the
* two roles want different actions on the same row, and conflating them behind
@@ -35,8 +40,13 @@ const DOCUMENT_KINDS = new Set(["CONSULTATION", "TRIAL", "SUBSCRIPTION"]);
export default function DetailPageClient({
orgId,
appointmentId,
-}: Readonly<{ orgId: string; appointmentId: string }>) {
- const base = useConsulteeAppointmentsAdapter();
+ consulteeId,
+}: Readonly<{
+ orgId: string;
+ appointmentId: string;
+ consulteeId: string;
+}>) {
+ const base = useConsulteeAppointmentsAdapter({ consulteeId });
const adapter = useMemo(
() => ({
diff --git a/app/dashboard/organization/[orgId]/appointments/[appointmentId]/page.tsx b/app/dashboard/organization/[orgId]/appointments/[appointmentId]/page.tsx
index 27a61486f..f41454b11 100644
--- a/app/dashboard/organization/[orgId]/appointments/[appointmentId]/page.tsx
+++ b/app/dashboard/organization/[orgId]/appointments/[appointmentId]/page.tsx
@@ -66,6 +66,10 @@ export default async function OrgAppointmentDetailPage({
if (!owns) notFound();
return (
-
+
);
}
diff --git a/app/dashboard/organization/[orgId]/layout.tsx b/app/dashboard/organization/[orgId]/layout.tsx
index 2f7d20d54..a3238aa37 100644
--- a/app/dashboard/organization/[orgId]/layout.tsx
+++ b/app/dashboard/organization/[orgId]/layout.tsx
@@ -1,750 +1,74 @@
-"use client";
-
-import Link from "next/link";
-import { usePathname, useRouter } from "next/navigation";
-import React, { use, useEffect, useMemo } from "react";
-import { useQuery } from "@tanstack/react-query";
-import { motion } from "framer-motion";
+import * as Sentry from "@sentry/nextjs";
import {
- Home,
- Users,
- GraduationCap,
- Briefcase,
- CreditCard,
- BarChart3,
- ClipboardList,
- Settings,
- Wallet,
- UserCog,
- Building2,
- LayoutDashboard,
- Clock,
- FileText,
- CalendarCheck,
- MessageSquare,
- ClipboardCheck,
- Video,
- Receipt,
- ShieldCheck,
- ShieldAlert,
- Library,
- type LucideIcon,
-} from "lucide-react";
-
-// Mobile bottom-tab configuration — 5 most-accessed org pages. Gated by
-// the same permission matrix as the desktop sidebar (a LEARNER previously
-// saw all five and got redirected by four of them).
-const MOBILE_TABS: {
- label: string;
- path: string;
- Icon: LucideIcon;
- surface?: OrgSurface;
- needsSponsor?: boolean;
-}[] = [
- { label: "Overview", path: "home", Icon: Home },
- { label: "Members", path: "members", Icon: Users, surface: "members.read" },
- {
- label: "Billing",
- path: "billing",
- Icon: CreditCard,
- surface: "billing.read",
- needsSponsor: true,
- },
- {
- label: "Analytics",
- path: "analytics",
- Icon: BarChart3,
- surface: "operations.read",
- },
- {
- // No surface gate, matching the desktop entry (ADR 23). The Settings page
- // floors at active membership and each tab carries its own gate, so an
- // ordinary member reaches it for the Notifications tab and nothing else.
- // Gating only the desktop sidebar would have left mobile LEARNER/EXPERT
- // users with no route to their own notification preferences.
- label: "Settings",
- path: "settings",
- Icon: Settings,
- },
-];
-
-import {
- CollapsibleSidebar,
- CollapsibleSidebarSkeleton,
- type CollapsibleSidebarGroup,
-} from "@/components/dashboard/CollapsibleSidebar";
-import { DashboardContextBar } from "@/components/dashboard/DashboardContextBar";
-import { LinkPendingIcon } from "@/components/ui/NavLink";
-import { DashboardErrorBoundary } from "@/components/DashboardErrorBoundary";
-import { useSession } from "@/lib/auth-client";
-import { useNovuSubscriberSync } from "@/hooks/useNovuSubscriberSync";
-import { signOutEverywhere } from "@/lib/auth/sign-out";
-import { hasOrgPermission, type OrgSurface } from "@/lib/auth/org-permissions";
-import {
- MEMBER_ROLE_LABEL,
- deriveCapabilityKind,
- CAPABILITY_LABEL,
- CAPABILITY_BADGE_CLASS,
- FUNDING_SOURCE_LABEL,
- FUNDING_SOURCE_BADGE_CLASS,
-} from "@/lib/labels/org-labels";
-import { resolvePersonalDashboardHref } from "@/lib/labels/personal-dashboard";
-import type { OrgStatus } from "@prisma/client";
-import {
- fetchOrgDetails,
- orgDetailsQueryKey,
-} from "@/lib/api/organizations/org-details";
+ HydrationBoundary,
+ QueryClient,
+ dehydrate,
+} from "@tanstack/react-query";
+import OrgDashboardShell from "./OrgDashboardShell";
+import { getOrgDetailsForSeed } from "@/lib/data/org-details-server";
+import { orgDetailsQueryKey } from "@/lib/api/organizations/org-details";
+import { toPlain } from "@/lib/data/serialize";
/**
- * Banner rendered across the org dashboard when `Organization.status !== ACTIVE`.
- * A newly created org sits in PENDING_VERIFICATION until a platform admin
- * runs the verify action. OWNER can still configure branding, draft programs,
- * and explore the product — but invitations, wallet top-ups, and contracts
- * are paused server-side. The banner explains why the write surfaces are
- * returning 409 ORG_NOT_VERIFIED.
+ * Server shell that seeds the query the whole org tree gates on.
+ *
+ * `useOrgRole` reads `data?.membership.role ?? "LEARNER"` and
+ * `orgDetailsQueryKey(orgId)` was never prefetched anywhere, so during SSR
+ * `data` was undefined, the role failed closed to LEARNER, every operator
+ * permission check failed, and ~15 components under this tree hit
+ * `if (!allowed) return null`. The org dashboard emitted no markup at all.
+ *
+ * Two prefetches that already existed were dead on arrival behind that gate:
+ * `analytics/page.tsx` seeds `["org-analytics", orgId]` with a comment
+ * correctly insisting the key must match — it does — and
+ * `AnalyticsPageClient` still returned null before reading it. Same for
+ * `members/page.tsx`. Seeding here is what makes those two pay off.
+ *
+ * Worse than a blank shell: org home reads `isOperator` from that role, so the
+ * server rendered the CONSUMER view and swapped to the operator dashboard after
+ * hydration — a view-identity change, not a fill-in.
+ *
+ * Authorization is not weakened. `getOrgDetailsForSeed` runs the same
+ * `requireOrgAccess(orgId, "LEARNER")` the API route runs, and returns null
+ * rather than throwing so a non-member simply gets no seed.
*/
-function OrgStatusBanner({ status }: { status: OrgStatus }) {
- const copy: Record<
- OrgStatus,
- { title: string; body: string; tone: string } | null
- > = {
- PENDING_VERIFICATION: {
- title: "Awaiting platform review",
- body: "You can set up branding and draft programs now. Inviting members and moving money unlocks as soon as an admin verifies your organization.",
- tone: "bg-amber-50 border-amber-200 text-amber-900",
- },
- SUSPENDED: {
- title: "Organization suspended",
- body: "Invitations and payments are paused. Contact support to restore access.",
- tone: "bg-rose-50 border-rose-200 text-rose-900",
- },
- ACTIVE: null,
- DEACTIVATED: {
- title: "Organization deactivated",
- body: "This organization is no longer operational.",
- tone: "bg-zinc-100 border-zinc-300 text-zinc-800",
- },
- };
- const message = copy[status];
- if (!message) return null;
- return (
-
- );
-}
-
-export default function OrgLayout({
+export default async function OrgDashboardLayout({
children,
params,
-}: {
+}: Readonly<{
children: React.ReactNode;
params: Promise<{ orgId: string }>;
-}) {
- const { orgId } = use(params);
- const pathname = usePathname();
- const router = useRouter();
- const { data: session, isPending: isSessionLoading } = useSession();
-
- // ADR 23 — the personal dashboards did this and the org tree did not, so a
- // user onboarded straight into an org by invite was never POSTed to
- // /api/novu/subscriber. Their Novu record stayed bare and any template
- // interpolating subscriber.firstName / email degraded.
- useNovuSubscriberSync();
-
- const {
- data: org,
- error,
- isLoading,
- } = useQuery({
- queryKey: orgDetailsQueryKey(orgId),
- queryFn: () => fetchOrgDetails(orgId),
- enabled: !!orgId && !!session?.user?.id,
- staleTime: 60_000,
- });
-
- // Compute grouped sidebar items from capabilities + fundingSource + role.
- // Visibility comes from the org permission matrix
- // (lib/auth/org-permissions.ts) — the SAME source the page guards and
- // API routes check, so a tab can't drift into "shown but rejected".
- // Capability gates (canSponsor/canHost/requiresPO/fundingSource) remain
- // separate structural conditions combined per item. Five clusters
- // (People / Commerce / Resources / Insights / Configuration) plus an
- // ungrouped Overview block; groups with zero remaining items drop out.
- const sidebarGroups: CollapsibleSidebarGroup[] = useMemo(() => {
- if (!org) return [];
- const { canSponsor, canHost, fundingSource, requiresPO } = org.organization;
- const membership = org.membership;
- const role = membership.role;
- const can = (surface: OrgSurface) => hasOrgPermission(role, surface);
-
- // Resources group defaults collapsed for OWNER + MAINTAINER — their
- // primary job isn't document triage. Open by default for MANAGER +
- // SUPPORT who live in those tabs.
- const resourcesCollapsedDefault = role === "OWNER" || role === "MAINTAINER";
-
- type ItemSpec = {
- name: string;
- icon: LucideIcon;
- path: string;
- show?: boolean;
- };
-
- // Top (no label) — Overview + consumer-role landing pages. The
- // LEARNER / EXPERT cases are the only sidebar items those roles
- // ever see. Operators (MANAGER+) get the richer per-tab views.
- //
- // My Program stays separate from Commerce's Programs on purpose: they are
- // different objects, not two scopes of one. `my-program` is a LEARNER's
- // own assignment and coverage detail; `programs` is the sponsor's catalog
- // CRUD. Appointments was the only genuine same-object scope split, and it
- // is now one entry with a Mine/Everyone toggle.
- const topItems: ItemSpec[] = [
- { name: "Overview", icon: Home, path: "home" },
- {
- name: "My Program",
- icon: GraduationCap,
- path: "my-program",
- show: can("myProgram.read") && canSponsor,
- },
- {
- name: "Compensation",
- icon: UserCog,
- path: "compensation",
- show: can("myArrangement.read") && canHost,
- },
- {
- // Any ACTIVE member — learners who ATTEND org sessions and experts
- // who DELIVER them both need their own per-org appointments surface.
- // Deliberately NOT gated on canHost (that would exclude pure
- // learners): requireOrgAccess already floors this at active
- // membership, so show it to everyone who reaches the org dashboard.
- // Operators additionally get the "Everyone" scope inside the page.
- name: "Appointments",
- icon: CalendarCheck,
- path: "appointments",
- },
- {
- // Participant surface, same floor as Appointments. Chat is scoped to
- // this org purely by living on this route — `useOrgScope` pins under
- // /dashboard/organization/[orgId]/ — so a member of several orgs gets
- // one clean inbox per org with no picker.
- //
- // Not an operator surface: Stream only returns channels the viewer is a
- // member of, and there is no org-wide chat query behind it. ADR 20
- // keeps session content with the participants.
- name: "Messages",
- icon: MessageSquare,
- path: "messages",
- },
- {
- // Delivery surface: allocating slots is something only the person
- // delivering the session can do, so it shows for members who hold a
- // consultant profile. The page itself redirects anyone else — gating on
- // the profile rather than on MemberRole.EXPERT means an OWNER who also
- // delivers still gets it.
- name: "Requests",
- icon: ClipboardCheck,
- path: "requests",
- // The comment above described this gate for months but the code did
- // not implement it: `myArrangement.read` is exact-role EXPERT, so an
- // OWNER or MANAGER who also delivers got no nav entry even though the
- // page admits anyone holding a consultantProfileId. That is ADR 19's
- // "gate and page disagree" failure inverted — a reachable page with no
- // way to reach it. The profile is the real predicate; the role check
- // stays as the cheap path for the common EXPERT case.
- show:
- (can("myArrangement.read") || membership.consultantProfileId !== null) &&
- canHost,
- },
- ];
-
- // People — governance + roster surfaces (BILLING_ADMIN is
- // operator-blind; SUPPORT reads Members for ticket investigation).
- //
- // Learners, Experts and Invitations are tabs on Members rather than
- // sidebar entries: the first two were `?role=` filters on the very
- // endpoint Members already reads, and splitting one roster across four
- // nav slots made the group harder to scan than the data warranted.
- const peopleItems: ItemSpec[] = [
- {
- // members.read is the widest of the four tab grants
- // (OPERATIONS_READERS, vs GOVERNANCE for invitations and OPERATORS
- // for learners/experts), so it alone decides the nav entry.
- name: "Members",
- icon: Users,
- path: "members",
- show: can("members.read"),
- },
- {
- // #org-appts / #1025 — collaborators on THIS org's hosted webinar/class
- // plans. Mirrors Compensation's gate: only host-capable orgs have
- // collaborator-bearing plans, and inviting/managing collaborators is
- // the plan-owning EXPERT's own surface.
- name: "Collaborations",
- icon: Users,
- path: "collaborations",
- show: can("myArrangement.read") && canHost,
- },
- ];
-
- // Commerce — money + entitlement surfaces, ordered to match the
- // setup flow: Contract first (commercial frame), then optional PO
- // (India AP 3-way-match), then Programs (the entitlement the
- // contract authorizes), then Billing (the invoices that result).
- // Payouts + Reimbursements appear at the bottom for HOST orgs and
- // for SPONSOR+PERSONAL orgs respectively — they're money-OUT
- // outcomes, not setup. Mutation gates stay on the route handlers;
- // the sidebar entries are visibility-only.
- const commerceItems: ItemSpec[] = [
- {
- // Contract terms are org-structural (spec: MAINTAINER floor) —
- // the old `≥MAINTAINER || finance` expression showed a dead tab
- // to MANAGER + BILLING_ADMIN, whose page guard rejected them.
- name: "Contracts",
- icon: FileText,
- path: "contracts",
- show: canSponsor && can("contracts.read"),
- },
- {
- // Only orgs running India AP 3-way-match (requiresPO=true) need
- // the PO tab in their primary nav. The PO surface itself stays
- // reachable by URL for orgs that opt in later — this is sidebar
- // visibility, not authz. See docs/enterprise/30-programs-and-lifecycle/04-dashboard-pages.md.
- name: "Purchase Orders",
- icon: Receipt,
- path: "purchase-orders",
- show: canSponsor && requiresPO && can("purchaseOrders.read"),
- },
- {
- // What the org SELLS, above what it SPONSORS — the two are different
- // objects, not two scopes of one, so this is a separate entry rather
- // than a toggle on Programs (ADR 19's my-program/programs precedent).
- // Catalog is the host-side offering the org owns; Programs is the
- // sponsor-side entitlement that funds bookings of anyone's plans.
- name: "Catalog",
- icon: Library,
- path: "catalog",
- show: canHost && can("catalog.manage"),
- },
- {
- name: "Programs",
- icon: Briefcase,
- path: "programs",
- show: canSponsor && can("programs.manage"),
- },
- {
- // canSponsor only — the old extra `fundingSource === "WALLET"`
- // branch was unreachable-by-construction (fundingSource lives on
- // BillingAccount, which only exists when canSponsor=true) and
- // produced a dead tab on any org where it could have fired.
- name: "Billing",
- icon: CreditCard,
- path: "billing",
- show: canSponsor && can("billing.read"),
- },
- {
- name: "Payouts",
- icon: Wallet,
- path: "payouts",
- show: canHost && can("payouts.read"),
- },
- {
- name: "Reimbursements",
- icon: Wallet,
- path: "reimbursements",
- show:
- canSponsor &&
- fundingSource === "PERSONAL" &&
- can("reimbursements.read"),
- },
- {
- // #776 §C — per-org dispute/chargeback surface. Finance-only; the
- // money-path (org-wallet-first clawback) settles server-side.
- name: "Disputes",
- icon: ShieldAlert,
- path: "disputes",
- show: can("disputes.read"),
- },
- ];
-
- // Resources — the artefacts a session leaves behind. MANAGER + SUPPORT
- // live here. OWNER + MAINTAINER have access but the group is collapsed
- // by default (see resourcesCollapsedDefault). BILLING_ADMIN is excluded
- // — no booking-side remit.
- //
- // Two entries, not one tabbed "Resources" page: a group labelled
- // Resources holding a single item also called Resources is redundant
- // nesting, and the two lists answer different questions — Documents is a
- // review queue, Recordings is an archive.
- //
- // Trials are deliberately absent: a trial IS an appointment, so it
- // belongs on Appointments rather than in a list of its own.
- const resourcesItems: ItemSpec[] = [
- {
- name: "Documents",
- icon: FileText,
- path: "documents",
- show: can("operations.read"),
- },
- {
- name: "Recordings",
- icon: Video,
- path: "recordings",
- show: can("operations.read"),
- },
- ];
-
- // Insights — analytics + compliance + audit trail. SUPPORT
- // gets Audit + Analytics for ticket investigation; the bulk
- // gate (`isOperationsReader`) covers both. Consent is MANAGER+
- // only (DPDP grant/withdraw is a governance surface).
- const insightsItems: ItemSpec[] = [
- {
- name: "Analytics",
- icon: BarChart3,
- path: "analytics",
- show: can("operations.read"),
- },
- {
- name: "Audit",
- icon: ClipboardList,
- path: "audit",
- show: can("audit.read"),
- },
- {
- name: "Consent",
- icon: ShieldCheck,
- path: "consent",
- show: can("consent.read"),
- },
- ];
-
- // Configuration — settings + outbound/inbound integrations.
- // Settings stays MAINTAINER+ (org-config is sensitive). Webhooks +
- // SCIM + Data exports are BILLING_ADMIN-reachable for finance
- // integrations.
- // Webhooks, SCIM and Data exports are tabs on Settings, alongside SSO —
- // which had no sidebar entry at all and was reachable only via a link
- // buried inside the settings page. One Configuration destination, five
- // tabs, each still gated on its own matrix key.
- const configurationItems: ItemSpec[] = [
- {
- name: "Settings",
- icon: Settings,
- path: "settings",
- // Ungated as of ADR 23. The PAGE has always floored at active
- // membership — each tab carries its own gate and UrlTabs renders
- // nothing when none apply — but the nav entry demanded an operator
- // grant, so a LEARNER or EXPERT could reach Settings only by typing the
- // URL. That gap became user-visible once the member-level Notifications
- // tab landed there. Non-operators now see Settings with that one tab.
- },
- ];
-
- const filterItems = (items: ItemSpec[]) =>
- items
- .filter((it) => it.show !== false)
- .map(({ show: _show, ...rest }) => rest);
-
- const groups: CollapsibleSidebarGroup[] = [
- { items: filterItems(topItems) },
- { label: "People", items: filterItems(peopleItems) },
- { label: "Commerce", items: filterItems(commerceItems) },
- {
- label: "Resources",
- items: filterItems(resourcesItems),
- defaultCollapsed: resourcesCollapsedDefault,
- },
- { label: "Insights", items: filterItems(insightsItems) },
- { label: "Configuration", items: filterItems(configurationItems) },
- ];
-
- // Drop empty groups — e.g. a LEARNER's sidebar has nothing in
- // People/Commerce/Operations/Insights/Configuration after
- // filtering, so they see only the top "Overview + My Program"
- // block without ghost headers.
- return groups.filter((g) => g.items.length > 0);
- }, [org]);
-
- // Redirect to /home when landing on the bare /[orgId] route.
- useEffect(() => {
- if (org && pathname === `/dashboard/organization/${orgId}`) {
- router.replace(`/dashboard/organization/${orgId}/home`);
- }
- }, [org, pathname, orgId, router]);
-
- const handleSignOut = () => {
- void signOutEverywhere();
- };
-
- if (!session?.user?.id && !isSessionLoading) {
- return (
-
+}>) {
+ const { orgId } = await params;
+ const queryClient = new QueryClient();
+
+ // Swallow: a failed seed must degrade to the client fetch, never 500 the
+ // whole org tree. Losing it only costs the server-rendered shell.
+ // Reported, not rethrown: a silent swallow makes a transient read failure and
+ // a sustained auth/database outage look identical from the outside, since both
+ // just degrade to the client fetch.
+ const details = await getOrgDetailsForSeed(orgId).catch((err: unknown) => {
+ Sentry.captureException(
+ err instanceof Error ? err : new Error(String(err)),
+ { tags: { subsystem: "org-dashboard-seed" }, extra: { orgId } },
);
- }
-
- if ((isLoading || isSessionLoading) && !org) {
- return ;
- }
-
- if (error) {
- return (
-
- );
- }
-
- // Split the context-switching surface across TWO dropdowns:
- // - Top header (org identity) → switch between orgs / personal dashboard
- // - Bottom user chip (personal) → user identity + sign out
- // This mirrors the Linear / Agentstack pattern: the top answers "which
- // context am I in?", the bottom answers "who am I?".
- const userExt = session?.user as
- | (NonNullable["user"] & {
- orgWorkspaceProfileId?: string | null;
- consultantProfileId?: string | null;
- consulteeProfileId?: string | null;
- organizationMemberships?: Array<{
- organizationId: string;
- organizationName: string;
- organizationLogo: string | null;
- role: string;
- }>;
- })
- | undefined;
-
- const personalHref = resolvePersonalDashboardHref({
- orgWorkspaceProfileId: userExt?.orgWorkspaceProfileId,
- consultantProfileId: userExt?.consultantProfileId,
- consulteeProfileId: userExt?.consulteeProfileId,
+ return null;
});
- // Other orgs the user belongs to (excluding the current one)
- const otherOrgs = (userExt?.organizationMemberships ?? []).filter(
- (m) => m.organizationId !== orgId,
- );
-
- // Bottom chip dropdown — context switching only.
- // Top header stays static (org identity + collapse arrow). Single dropdown
- // at the bottom keeps the "which dropdown has what" confusion at zero.
- //
- // No "Organization settings" entry here: it pointed at the very href the
- // Configuration → Settings sidebar item already owns, so the same
- // destination appeared twice in one sidebar.
- const bottomUserChipActions: NonNullable<
- React.ComponentProps["bottomUserChipActions"]
- > = [
- ...(personalHref
- ? [
- {
- type: "item" as const,
- label: "Personal Dashboard",
- href: personalHref,
- icon: LayoutDashboard,
- },
- ]
- : []),
- ...(otherOrgs.length > 0
- ? [
- { type: "separator" as const },
- { type: "label" as const, label: "Switch organization" },
- ...otherOrgs.map((m) => ({
- type: "item" as const,
- label: m.organizationName,
- href: `/dashboard/organization/${m.organizationId}/home`,
- icon: Building2,
- })),
- ]
- : []),
- ];
-
- // Subtitle under the org name: the user's role in THIS org. Capability
- // badges (Sponsor/Host/Hybrid) + funding source live in the top-bar —
- // sidebar subtitle is user-specific, top-bar badges are org-specific.
- const topSubtitle = org ? MEMBER_ROLE_LABEL[org.membership.role] : null;
-
- // Map URL segments to human-readable page names so the breadcrumbs match
- // the heading the user actually sees on the page.
- const PAGE_LABELS: Record = {
- home: "Overview",
- "my-program": "My Program",
- compensation: "Compensation",
- collaborations: "Collaborations",
- appointments: "Appointments",
- messages: "Messages",
- requests: "Requests",
- members: "Members",
- catalog: "Catalog",
- programs: "Programs",
- contracts: "Contracts",
- "purchase-orders": "Purchase Orders",
- documents: "Documents",
- recordings: "Recordings",
- billing: "Billing",
- payouts: "Payouts",
- reimbursements: "Reimbursements",
- disputes: "Disputes",
- analytics: "Analytics",
- audit: "Audit",
- consent: "Consent",
- settings: "Settings",
- };
-
- // Full breadcrumb trail — every URL segment after /organization/{orgId}
- // becomes a crumb. Forward-compatible with nested routes.
- const breadcrumbs = pathname
- .replace(`/dashboard/organization/${orgId}`, "")
- .split("/")
- .filter(Boolean)
- .map((seg) => PAGE_LABELS[seg] ?? seg);
+ // Only seed a real read. Caching `null` is worse than not seeding: the client
+ // gate reads `!org` either way, but a null cache entry also suppresses the
+ // client's own fetch, so a non-member or a transient read failure would leave
+ // the tree permanently empty instead of self-healing on hydration.
+ if (details) {
+ await queryClient.prefetchQuery({
+ queryKey: orgDetailsQueryKey(orgId),
+ queryFn: async () => toPlain(details),
+ });
+ }
return (
-
- {/* Collapsible sidebar — hidden on mobile, visible on md+ */}
-
-
-
-
- {/* Right panel: context bar + page content */}
-
Failed to load settings.
@@ -299,7 +299,7 @@ export default function StaffSettingsPage({ params }: Readonly) {
}
if (!staffData) {
return (
-
+
Staff member not found.
);
diff --git a/app/explore/enterprise/organisations/OrganisationsInteractiveContent.tsx b/app/explore/enterprise/organisations/OrganisationsInteractiveContent.tsx
index 4949f6d96..550ce9118 100644
--- a/app/explore/enterprise/organisations/OrganisationsInteractiveContent.tsx
+++ b/app/explore/enterprise/organisations/OrganisationsInteractiveContent.tsx
@@ -85,14 +85,13 @@ export default function OrganisationsInteractiveContent({
},
// The RSC already rendered the unfiltered first page; don't refetch it.
//
- // Seeded ONLY when the server actually returned rows. The RSC read is
- // wrapped in fallbackOnTransientDbError, so a cold-connect timeout (#932)
- // degrades to an EMPTY page rather than throwing — and seeding that as
- // initialData marks it fresh for staleTime, so the client never refetches
- // and the user is stuck on "No organisations match these filters"
- // indefinitely, even though the API answers correctly on the next call.
- // Falling through to a normal fetch costs one request in the genuinely
- // empty case and repairs the degraded case.
+ // Seeded ONLY when the server actually returned rows. The RSC read no longer
+ // degrades to an empty page on a cold-connect timeout — since #1119 it throws
+ // and this component never renders — so the guard no longer has a degraded
+ // case to repair. It is kept because seeding an empty list as initialData
+ // marks it fresh for staleTime, so the client would never refetch and a
+ // genuinely-empty-right-now directory would stay stuck on "No organisations
+ // match these filters". Falling through to a normal fetch costs one request.
initialData:
isDefaultView && initialItems.length > 0
? {
diff --git a/app/explore/enterprise/organisations/[orgSlug]/page.tsx b/app/explore/enterprise/organisations/[orgSlug]/page.tsx
index c9ebfcfa0..77bb428bc 100644
--- a/app/explore/enterprise/organisations/[orgSlug]/page.tsx
+++ b/app/explore/enterprise/organisations/[orgSlug]/page.tsx
@@ -14,7 +14,6 @@ import {
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import prisma from "@/lib/prisma";
-import { fallbackOnTransientDbError } from "@/lib/data/fail-open";
import { eventPlanDiscoverableWhere } from "@/lib/api/plans/visibility";
import {
@@ -36,9 +35,28 @@ const PUBLIC_PLAN_CARD_SELECT = {
} as const;
-// Stream behind the static layout's instant skeleton; don't prerender at build (#932).
-// (Replaces the prior `revalidate = 60`, inert while the layout forced dynamic.)
-export const dynamic = "force-dynamic";
+// ISR per orgSlug, not force-dynamic. The cache key is the org being viewed,
+// never the viewer: no session is read here or in any layout above, and the
+// query is already scoped to `isPublic` ACTIVE orgs, so the HTML is public by
+// construction.
+//
+// 5 minutes. This surface links straight into checkout-bound plan pages and the
+// plan lists are gated by eventPlanDiscoverableWhere(), so a withdrawn plan
+// lingering in cached HTML is a dead-end click — which is why archiving or
+// hiding a plan purges this path on demand rather than waiting out the window.
+export const revalidate = 300;
+
+// Required for `revalidate` above to be anything other than dead config: Next
+// renders a dynamic segment dynamically unless generateStaticParams exists, and
+// silently ignores the interval. The empty array is the documented "all paths at
+// runtime" shape — nothing is prerendered during `next build`, so org pages stay
+// off the build-time cross-region pooler connect (#932) and each renders on its
+// first request instead. dynamicParams defaults to true, so a slug not in the
+// array still renders on demand rather than 404ing.
+// https://nextjs.org/docs/15/app/api-reference/functions/generate-static-params
+export function generateStaticParams() {
+ return [];
+}
// React.cache so generateMetadata() and the page body share one query per request
// instead of running this heavy org read twice (more visible now it's per-request).
@@ -180,15 +198,12 @@ export async function generateMetadata({
params: Promise<{ orgSlug: string }>;
}): Promise {
const { orgSlug } = await params;
- // Metadata runs before render with no error boundary — a transient timeout
- // would 500. Fall back to null (generic title); the page body re-reads and
- // surfaces any real error. (#925)
- const org = await fetchOrgBySlug(orgSlug).catch(
- fallbackOnTransientDbError>>(
- "org metadata",
- null,
- ),
- );
+ // The fallback used to be a generic title on a transient timeout, which was
+ // right while this route was dynamic. It is ISR now, so that degraded head
+ // would be cached and replayed to everyone; a 500 that caches nothing is the
+ // better trade. Left bare so it fails the same way the page body below does —
+ // they share one `React.cache`d read per request (#1119).
+ const org = await fetchOrgBySlug(orgSlug);
if (!org) return { title: "Organisation not found" };
return {
title: `${org.name} — Familiarise`,
diff --git a/app/explore/enterprise/organisations/page.tsx b/app/explore/enterprise/organisations/page.tsx
index 694e84ab4..fa1afe204 100644
--- a/app/explore/enterprise/organisations/page.tsx
+++ b/app/explore/enterprise/organisations/page.tsx
@@ -6,16 +6,29 @@ import {
DEFAULT_ORGANISATION_FILTERS,
getOrganisationsMetadata,
getOrganisationsPage,
- type OrganisationsMetadata,
} from "@/lib/data/explore-organisations";
-import { fallbackOnTransientDbError } from "@/lib/data/fail-open";
+import { withBuildTimeRetry } from "@/lib/data/fail-open";
import OrganisationsInteractiveContent, {
OrganisationsGridSkeleton,
} from "./OrganisationsInteractiveContent";
-// Stream behind the static layout's instant skeleton; don't prerender at build (#932).
-export const dynamic = "force-dynamic";
+// ISR, not force-dynamic. The directory reads no session and no searchParams
+// (filtering is client-side in OrganisationsInteractiveContent), and it lists
+// only `isPublic` ACTIVE orgs, so every visitor is entitled to the same HTML.
+//
+// force-dynamic made this uncacheable at the CDN (Next sends dynamic pages
+// `private, no-store`), so every visit paid a cross-region DB round trip for a
+// list that turns over on the order of days.
+//
+// This route IS prerendered during `next build` (#932): the reads run in the
+// build environment and no longer degrade at all (#1119), so a transient pooler
+// failure fails the build instead of baking an empty directory.
+//
+// 5 minutes: unlike the expert reads this one has no unstable_cache layer, so
+// the interval is the only thing between visitors and the DB. Orgs going public
+// purge this path on demand at the write sites.
+export const revalidate = 300;
export const metadata: Metadata = {
title: "Explore Organisations | Familiarise",
@@ -23,29 +36,14 @@ export const metadata: Metadata = {
"Discover expert networks, consulting agencies, and learning institutions on Familiarise. Browse their curated experts and programs.",
};
-const EMPTY_METADATA: OrganisationsMetadata = {
- industries: [],
- types: [],
- sizes: [],
- capabilities: [],
- total: 0,
-};
-
async function OrganisationsDirectory() {
- // Both reads degrade rather than crash the page on a transient pooler
- // timeout (cross-region cold connect, #932).
+ // Both reads throw on a transient pooler timeout (cross-region cold connect,
+ // #932). They used to degrade, which was safe while this route was dynamic and
+ // is not now that it is ISR — a degraded directory would be cached and replayed
+ // to everyone (#1119).
const [meta, firstPage] = await Promise.all([
- getOrganisationsMetadata().catch(
- fallbackOnTransientDbError("org directory metadata", EMPTY_METADATA),
- ),
- getOrganisationsPage(DEFAULT_ORGANISATION_FILTERS).catch(
- fallbackOnTransientDbError("org directory page", {
- items: [],
- total: 0,
- page: 1,
- totalPages: 1,
- }),
- ),
+ withBuildTimeRetry(getOrganisationsMetadata),
+ withBuildTimeRetry(() => getOrganisationsPage(DEFAULT_ORGANISATION_FILTERS)),
]);
return (
diff --git a/app/explore/experts/[consultantId]/components/ConsultantUnavailable.tsx b/app/explore/experts/[consultantId]/components/ConsultantUnavailable.tsx
deleted file mode 100644
index 989e17d48..000000000
--- a/app/explore/experts/[consultantId]/components/ConsultantUnavailable.tsx
+++ /dev/null
@@ -1,43 +0,0 @@
-"use client";
-
-import Link from "next/link";
-
-/**
- * Degraded state for the expert detail page when a transient cross-region DB
- * timeout (#932) stops the profile loading — shown in place of a hard crash so
- * the visitor gets a retriable surface, not a 500. (FAMILIARISE_WEB-A)
- */
-export function ConsultantUnavailable() {
- return (
-
-
-
-
- ← Back to experts
-
-
-
-
-
-
- This profile is taking a moment to load
-
-
- We couldn't reach this profile just now. It's usually
- temporary — please try again in a few seconds.
-
-
-
-
-
- );
-}
diff --git a/app/explore/experts/[consultantId]/error.tsx b/app/explore/experts/[consultantId]/error.tsx
index 0153fc5ca..342e28013 100644
--- a/app/explore/experts/[consultantId]/error.tsx
+++ b/app/explore/experts/[consultantId]/error.tsx
@@ -5,10 +5,11 @@ import { useEffect } from "react";
import Link from "next/link";
/**
- * Segment error boundary for the expert detail page. The known cross-region
- * transient (#932) is caught upstream in page.tsx and never reaches here, so
- * anything that lands in this boundary is a genuine defect — report it and give
- * the visitor a retry instead of falling through to the global crash page.
+ * Segment error boundary for the expert detail page. Since #1119 the known
+ * cross-region transient (#932) is NO LONGER swallowed upstream — degrading gave
+ * a 200 that Netlify wrote into the durable cache and replayed to everyone — so
+ * this boundary is exactly where a pooler timeout now lands. Give the visitor a
+ * retry rather than falling through to the global crash page.
*/
export default function ExpertProfileError({
error,
@@ -17,8 +18,13 @@ export default function ExpertProfileError({
error: Error & { digest?: string };
reset: () => void;
}) {
+ // A server render error already reached Sentry through onRequestError
+ // (instrumentation.ts) and arrives here carrying its digest. Capturing it again
+ // would file a second issue for every one of those — and since #1119 that is now
+ // the common path, not the rare one. Only report errors that originated on the
+ // client, which have no digest.
useEffect(() => {
- Sentry.captureException(error);
+ if (!error.digest) Sentry.captureException(error);
}, [error]);
return (
diff --git a/app/explore/experts/[consultantId]/page.tsx b/app/explore/experts/[consultantId]/page.tsx
index e6822f526..f0656f750 100644
--- a/app/explore/experts/[consultantId]/page.tsx
+++ b/app/explore/experts/[consultantId]/page.tsx
@@ -1,5 +1,5 @@
import { Suspense } from "react";
-import { notFound, unstable_rethrow } from "next/navigation";
+import { notFound } from "next/navigation";
import {
getConsultantDetail,
getConsultantReviews,
@@ -7,12 +7,31 @@ import {
import { TUserWithProfessionalBackground } from "@/types/user";
import { ExpertProfileClient } from "./ExpertProfileClient";
import { ConsultantSkeletonLoader } from "./components/ConsultantSkeletonLoader";
-import { ConsultantUnavailable } from "./components/ConsultantUnavailable";
-import { isTransientDbError, reportTransient } from "@/lib/data/fail-open";
-// Per-visitor detail page: stream behind the static layout's instant skeleton,
-// never prerender at build (#932).
-export const dynamic = "force-dynamic";
+// ISR per consultantId, not force-dynamic. The cache key is the expert being
+// viewed, never the viewer: this page reads no session, and the layout above it
+// reads none either. Real-time bookability is NOT in this HTML — the client
+// fetches /api/slots/availability-with-allocation on mount, so a cached
+// document can't show a stale "free" slot.
+//
+// 5 minutes, and the read underneath is uncached (lib/data/consultant-detail.ts
+// is React.cache, i.e. per-request only), so this is the largest saving in the
+// change. An expert's own edits purge this path on demand at the write site, so
+// they don't watch their changes sit stale.
+export const revalidate = 300;
+
+// Required for `revalidate` above to be anything other than dead config: Next
+// renders a dynamic segment dynamically unless generateStaticParams exists, and
+// silently ignores the interval. The empty array is the documented "all paths at
+// runtime" shape — nothing is prerendered during `next build`, which is what we
+// want here twice over: consultant cardinality is unbounded (prerendering every
+// profile would bloat the build) and it keeps these reads off the build-time
+// cross-region pooler connect (#932). dynamicParams defaults to true, so a slug
+// not in the array still renders on demand rather than 404ing.
+// https://nextjs.org/docs/15/app/api-reference/functions/generate-static-params
+export function generateStaticParams() {
+ return [];
+}
type Params = Promise<{ consultantId: string }>;
@@ -21,35 +40,31 @@ export default async function ExpertProfile({
}: Readonly<{ params: Params }>) {
const { consultantId } = await params;
- try {
- // Parallel fetch — eliminates the previous waterfall
- const [consultant, reviews] = await Promise.all([
- getConsultantDetail(consultantId),
- getConsultantReviews(consultantId),
- ]);
+ // This render is cacheable, so it must never swallow a failure: a 200 carrying
+ // the old "taking a moment to load" shell was written to the Netlify durable
+ // cache and replayed to everyone for the rest of the window (#1119). A transient
+ // pooler timeout now reaches error.tsx instead, which caches nothing.
+ //
+ // Note what that costs, because it is not free: `generateStaticParams` returns
+ // [], so a parameter being rendered for the first time has NO cached copy to
+ // fall back on and its visitor gets the error boundary. Only a *revalidation*
+ // of an already-cached profile keeps serving the last good copy.
+ const [consultant, reviews] = await Promise.all([
+ getConsultantDetail(consultantId),
+ getConsultantReviews(consultantId),
+ ]);
- if (!consultant || !consultant.user) {
- notFound();
- }
-
- return (
- }>
-
-
- );
- } catch (error) {
- // Re-throw Next.js internal control-flow signals (notFound/redirect) FIRST via
- // the official guard, so the timeout regex can never accidentally swallow one.
- // Then degrade the known cross-region cold-connect transient (#932) inside
- // render so it never escapes to onRequestError; any real defect rethrows to
- // error.tsx. (FAMILIARISE_WEB-A, #945 review)
- unstable_rethrow(error);
- if (!isTransientDbError(error)) throw error;
- reportTransient("consultant detail page", error, { consultantId });
- return ;
+ if (!consultant || !consultant.user) {
+ notFound();
}
+
+ return (
+ }>
+
+
+ );
}
diff --git a/app/explore/experts/page.tsx b/app/explore/experts/page.tsx
index 06efb386d..b635b245e 100644
--- a/app/explore/experts/page.tsx
+++ b/app/explore/experts/page.tsx
@@ -5,15 +5,29 @@ import ExpertsInteractiveContent from "./ExpertsInteractiveContent";
import {
getExpertsMetadata,
getCuratedExperts,
- EMPTY_EXPERTS_METADATA,
} from "@/lib/data/explore-experts";
-import {
- emptyOnTransientDbError,
- fallbackOnTransientDbError,
-} from "@/lib/data/fail-open";
+import { withBuildTimeRetry } from "@/lib/data/fail-open";
-// Stream behind the static layout's instant skeleton; don't prerender at build (#932).
-export const dynamic = "force-dynamic";
+// ISR, not force-dynamic. This listing reads no session and takes no
+// searchParams (filtering happens in the client component below), so the
+// rendered HTML is identical for every visitor and safe to share.
+//
+// force-dynamic made this route uncacheable at the CDN (Next sends dynamic pages
+// `private, no-store`), so every visitor paid a cross-region cold DB round trip.
+// Prerendered HTML is served off the CDN with no function invocation.
+//
+// This route IS prerendered during `next build`, which is exactly the read #932
+// saw fail on a cold cross-region pooler connect. That is guarded rather than
+// avoided: these reads no longer degrade at all (#1119), so a flaky build fails
+// loudly instead of shipping an empty experts directory. `withBuildTimeRetry`
+// gives the build two extra attempts before it gives up.
+//
+// 5 minutes, matched by the unstable_cache windows on the reads below so the
+// declared interval is the effective one — Next resolves a route's revalidate to
+// the MINIMUM of the segment value and every data-cache entry read during the
+// render, so a shorter window underneath would silently win. New and updated
+// profiles purge this path on demand at the write sites.
+export const revalidate = 300;
function HeroSection({
totalConsultants,
@@ -82,22 +96,15 @@ function HeroSection({
}
export default async function ExploreExperts() {
- // Degrade gracefully: a heavy curated read that times out (cold query brushing
- // the pg query budget) renders an empty row instead of erroring the whole page.
+ // These used to degrade to empty rows on a transient timeout. This route is ISR,
+ // so that empty page would be cached and served to everyone until the window
+ // expired; retry once and otherwise throw, which caches nothing (#1119).
const [metadata, featuredExperts, trendingExperts, newestExperts] =
await Promise.all([
- getExpertsMetadata().catch(
- fallbackOnTransientDbError("experts metadata", EMPTY_EXPERTS_METADATA),
- ),
- getCuratedExperts("rating", 5).catch(
- emptyOnTransientDbError("featured experts"),
- ),
- getCuratedExperts("trending", 8).catch(
- emptyOnTransientDbError("trending experts"),
- ),
- getCuratedExperts("newest", 8).catch(
- emptyOnTransientDbError("newest experts"),
- ),
+ withBuildTimeRetry(getExpertsMetadata),
+ withBuildTimeRetry(() => getCuratedExperts("rating", 5)),
+ withBuildTimeRetry(() => getCuratedExperts("trending", 8)),
+ withBuildTimeRetry(() => getCuratedExperts("newest", 8)),
]);
return (
diff --git a/app/explore/programs/page.tsx b/app/explore/programs/page.tsx
index c2a30c153..9f0e03646 100644
--- a/app/explore/programs/page.tsx
+++ b/app/explore/programs/page.tsx
@@ -2,7 +2,10 @@ import {
getCuratedPrograms,
getTopicsWithCount,
} from "@/lib/data/explore-programs";
-import { emptyOnTransientDbError } from "@/lib/data/fail-open";
+import {
+ emptyOnTransientDbError,
+ fallbackOnTransientDbError,
+} from "@/lib/data/fail-open";
import { sortPlanLevels } from "@/lib/labels/plan-labels";
import { unstable_cache } from "next/cache";
import prisma from "@/lib/prisma";
@@ -13,22 +16,23 @@ import ProgramsInteractiveContent from "./ProgramsInteractiveContent";
// specific, so it must NOT enter the shared curated cache — it's fetched
// per-request here and prop-drilled to the card, which badges a plan
// "Recommended by " when the viewer's org sponsors that plan's program.
-// Fail-open to an empty map (signed-out or a transient read → no badges).
+//
+// Signed-out returns {} here because that is an ANSWER, not a failure. Failure
+// is handled at the call site by the same fail-open helper the four sibling
+// reads use — this used to be a bare `catch { return {} }`, which swallowed
+// every error class and would have silently dropped every org badge on a mapper
+// or schema regression, indefinitely and with no signal. (#1125)
async function getViewerOrgs(): Promise> {
- try {
- const session = await getSession();
- const userId = session?.user?.id;
- if (!userId) return {};
- const memberships = await prisma.membership.findMany({
- where: { userId, status: "ACTIVE" },
- select: { organization: { select: { id: true, name: true } } },
- });
- return Object.fromEntries(
- memberships.map((m) => [m.organization.id, m.organization.name]),
- );
- } catch {
- return {};
- }
+ const session = await getSession();
+ const userId = session?.user?.id;
+ if (!userId) return {};
+ const memberships = await prisma.membership.findMany({
+ where: { userId, status: "ACTIVE" },
+ select: { organization: { select: { id: true, name: true } } },
+ });
+ return Object.fromEntries(
+ memberships.map((m) => [m.organization.id, m.organization.name]),
+ );
}
// Stream behind the static layout's instant skeleton; don't prerender at build (#932).
@@ -56,15 +60,32 @@ export default async function ExplorePrograms() {
levels,
] = await Promise.all([
getCuratedPrograms("all", "trending", 8).catch(
- emptyOnTransientDbError("trending programs"),
+ emptyOnTransientDbError("trending programs", { perRequest: true }),
),
getCuratedPrograms("all", "newest", 8).catch(
- emptyOnTransientDbError("newest programs"),
+ emptyOnTransientDbError("newest programs", { perRequest: true }),
+ ),
+ getTopicsWithCount("all").catch(
+ emptyOnTransientDbError("topics", { perRequest: true }),
+ ),
+ // `null` here means "show the marketing numbers instead", which the client
+ // already handles. Routed through the helper rather than a local catch so a
+ // real defect surfaces instead of quietly pinning the hero to placeholders.
+ getCachedProgramCounts().catch(
+ fallbackOnTransientDbError("program stats", null, { perRequest: true }),
+ ),
+ getViewerOrgs().catch(
+ fallbackOnTransientDbError>(
+ "viewer orgs",
+ {},
+ {
+ perRequest: true,
+ },
+ ),
+ ),
+ getCachedProgramLevels().catch(
+ emptyOnTransientDbError("program levels", { perRequest: true }),
),
- getTopicsWithCount("all").catch(emptyOnTransientDbError("topics")),
- fetchProgramStats(),
- getViewerOrgs(),
- getCachedProgramLevels().catch(emptyOnTransientDbError("program levels")),
]);
return (
@@ -122,17 +143,3 @@ const getCachedProgramLevels = unstable_cache(
["program-levels"],
{ revalidate: 3600, tags: ["programs"] },
);
-
-/** Counts of class plans + webinar plans for the hero stats strip.
- * Returns null on failure so the client falls back to the marketing
- * numbers — same fail-open behavior the old client `useEffect` had. */
-async function fetchProgramStats(): Promise<{
- classCount: number;
- webinarCount: number;
-} | null> {
- try {
- return await getCachedProgramCounts();
- } catch {
- return null;
- }
-}
diff --git a/app/form/onboarding/page.tsx b/app/form/onboarding/page.tsx
index 8c7c29309..a1a47c1fa 100644
--- a/app/form/onboarding/page.tsx
+++ b/app/form/onboarding/page.tsx
@@ -3,6 +3,7 @@
import {
updateOnboardingInformationAction,
setOnboardingRoleAction,
+ resetOnboardingRoleAction,
completeOrgWorkspaceOnboardingAction,
} from "@/actions/forms/onboarding.action";
import {
@@ -16,43 +17,287 @@ 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 type { z } from "zod";
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";
-// Step labels for progress indicator
-const STEP_LABELS = {
+// Later steps + org wizard are code-split so the initial onboarding chunk
+// does not pay for schedule UI, review forms, or the create-org wizard.
+//
+// Every split step needs `loading` — next/dynamic renders null while the chunk
+// downloads, so without it pressing Next collapses the card to zero height and
+// reads as a frozen app on a slow connection. The options object is repeated
+// inline rather than hoisted to a shared const because SWC statically analyses
+// it: a variable fails the build with "next/dynamic options must be an object
+// literal".
+function StepLoading() {
+ return (
+
+
+ Loading this step…
+
+ );
+}
+
+const ConsultantPreferredScheduleForm = dynamic(
+ () => import("./components/ConsultantPreferredScheduleForm"),
+ { ssr: false, loading: () => },
+);
+const ConsultantProfessionalStep = dynamic(
+ () => import("./components/ConsultantProfessionalStep"),
+ { ssr: false, loading: () => },
+);
+const ConsultantAgreementAndVerificationStep = dynamic(
+ () => import("./components/ConsultantAgreementAndVerificationStep"),
+ { ssr: false, loading: () => },
+);
+const ConsultantReviewForm = dynamic(
+ () => import("./components/ConsultantReviewForm"),
+ { ssr: false, loading: () => },
+);
+const ConsulteeAgreementForm = dynamic(
+ () => import("./components/ConsulteeAgreementForm"),
+ { ssr: false, loading: () => },
+);
+const ConsulteeProfileForm = dynamic(
+ () => import("./components/ConsulteeProfileForm"),
+ { ssr: false, loading: () => },
+);
+const ConsulteeReviewForm = dynamic(
+ () => import("./components/ConsulteeReviewForm"),
+ { ssr: false, loading: () => },
+);
+const StaffAgreementForm = dynamic(
+ () => import("./components/StaffAgreementForm"),
+ { ssr: false, loading: () => },
+);
+const StaffProfileForm = dynamic(
+ () => import("./components/StaffProfileForm"),
+ { ssr: false, loading: () => },
+);
+const StaffReviewForm = dynamic(
+ () => import("./components/StaffReviewForm"),
+ { ssr: false, loading: () => },
+);
+const CreateOrganizationWizard = dynamic(
+ () =>
+ import("@/components/organization/create-wizard/Wizard").then((m) => ({
+ default: m.CreateOrganizationWizard,
+ })),
+ { ssr: false, loading: () => },
+);
+
+// ---------------------------------------------------------------------------
+// Step registry
+// ---------------------------------------------------------------------------
+
+/**
+ * Everything a step can need from the shell. The five step forms were written
+ * independently and take different props (`onNext`/`initialData` vs
+ * `onSubmit`/`formData`/`onGoToStep`), so each entry adapts this context to its
+ * own component instead of a single prop contract being forced on all of them.
+ */
+interface OnboardingStepContext {
+ formData: Partial;
+ userId?: string;
+ onNext: (data: Partial) => Promise;
+ onBack: () => void;
+ onSubmit: (data: Partial) => Promise;
+ onGoToStep: (targetStep: number) => void;
+ onExitOrgWizard: () => void;
+}
+
+interface OnboardingStep {
+ /** Shown in the progress stepper and as the card title. */
+ label: string;
+ render: (ctx: OnboardingStepContext) => React.ReactNode;
+ /** Needs more horizontal room than the default 3xl card. */
+ wide?: boolean;
+ /**
+ * Step paints its own full-page chrome, so the shell (header, stepper, card)
+ * steps aside entirely rather than nesting one stepper inside another.
+ */
+ fullBleed?: boolean;
+}
+
+// Roles come from the onboarding schema's discriminated union rather than a
+// hand-written list, so adding a branch there fails to compile here until it
+// declares its steps.
+type OnboardingRole = z.infer["role"];
+
+// Shared across every role: step 0 is where the role is picked, so it cannot be
+// role-specific.
+const personalInfoStep: OnboardingStep = {
+ label: "Personal Info",
+ render: (ctx) => (
+
+ ),
+};
+
+/**
+ * The onboarding flow, per role. Order in the array IS the step order — indices
+ * are never written down anywhere, which is what lets ORG_WORKSPACE be a normal
+ * two-entry flow instead of a special case bolted onto the step machine.
+ */
+const ONBOARDING_STEPS: Record = {
CONSULTANT: [
- "Personal Info",
- "Professional Profile",
- "Availability",
- "Agreement & Verification",
- "Review",
+ personalInfoStep,
+ {
+ label: "Professional Profile",
+ render: (ctx) => (
+
+ ),
+ },
+ {
+ label: "Availability",
+ // The weekly slot grid does not fit the default card width.
+ wide: true,
+ render: (ctx) => (
+
+ ),
+ },
+ {
+ label: "Agreement & Verification",
+ render: (ctx) => (
+
+ ),
+ },
+ {
+ label: "Review",
+ render: (ctx) => (
+
+ ),
+ },
+ ],
+ CONSULTEE: [
+ personalInfoStep,
+ {
+ label: "Profile",
+ render: (ctx) => (
+
+ ),
+ },
+ {
+ label: "Agreement",
+ render: (ctx) => (
+
+ ),
+ },
+ {
+ label: "Review",
+ render: (ctx) => (
+
+ ),
+ },
],
- CONSULTEE: ["Personal Info", "Profile", "Agreement", "Review"],
STAFF: [
- "Personal Info",
- "Role Details",
- "Agreement",
- "Review",
+ personalInfoStep,
+ {
+ label: "Role Details",
+ render: (ctx) => (
+
+ ),
+ },
+ {
+ label: "Agreement",
+ render: (ctx) => (
+
+ ),
+ },
+ {
+ label: "Review",
+ render: (ctx) => (
+
+ ),
+ },
+ ],
+ ORG_WORKSPACE: [
+ personalInfoStep,
+ {
+ label: "Create Organization",
+ // The shared wizard owns the remaining 5-6 screens (Org Info → Review)
+ // and ships its own stepper and cards, so the onboarding shell would
+ // otherwise render a stepper inside a stepper.
+ fullBleed: true,
+ render: (ctx) => (
+ // #863 — hostOrgsEnabled defaults to false here (host capability
+ // hidden), which is the honest state while ENABLE_HOST_ORGS is off.
+ // This is a client component with no server parent to read the flag;
+ // TODO(#863): thread the server flag when host orgs launch (e.g. a
+ // server action or a server shell).
+ {
+ const userId = ctx.userId;
+ if (!userId) return;
+ await completeOrgWorkspaceOnboardingAction(userId);
+ }}
+ />
+ ),
+ },
],
- // ORG_WORKSPACE: the onboarding shell only owns "Personal Info". Once the
- // role is committed, the shared CreateOrganizationWizard (stepper +
- // cards) takes over the remainder — its own progress bar shows the
- // 5-6 wizard steps, so we don't duplicate them here.
- ORG_WORKSPACE: ["Personal Info"],
+ // ADMIN exists in the onboarding schema union but is not self-selectable
+ // (the role picker does not offer it and `setOnboardingRoleAction`'s
+ // allowlist rejects it), so there are no admin step forms to register.
+ ADMIN: [personalInfoStep],
};
const MultiStepForm: React.FC = () => {
@@ -85,17 +330,6 @@ const MultiStepForm: React.FC = () => {
.catch(() => {});
}, [session?.user?.id]);
- const methods = useForm({
- mode: "onChange",
- defaultValues: {
- name: "",
- email: "",
- onlineStatus: false,
- onboardingCompleted: false,
- role: "CONSULTEE",
- } satisfies Partial,
- });
-
const handleNext = async (stepData: Partial) => {
// Merge new data first so the async role-flip below reads the
// freshest values (React setState batching would otherwise give us
@@ -112,12 +346,11 @@ const MultiStepForm: React.FC = () => {
}
setFormData(merged);
- // ORG_WORKSPACE handoff: the onboarding shell only owns step 0. When the
- // user completes Personal Info we commit their role on the User row
- // so step 1's `POST /api/organizations` authorizes — the API gate
- // requires `UserRole === "ORG_WORKSPACE"` and the signup default is
- // CONSULTEE. The shared wizard then takes over for the remaining
- // steps. Any other role keeps using this page's step machine.
+ // ORG_WORKSPACE handoff: when the user completes Personal Info we commit
+ // their role on the User row so the wizard step's
+ // `POST /api/organizations` authorizes — the API gate requires
+ // `UserRole === "ORG_WORKSPACE"` and the signup default is CONSULTEE.
+ // Backing out of the wizard reverts it (see `handleExitOrgWizard`).
if (step === 0 && merged.role === "ORG_WORKSPACE") {
const userId = session?.user?.id;
if (!userId) {
@@ -159,6 +392,20 @@ const MultiStepForm: React.FC = () => {
setStep(targetStep);
};
+ // Backing out of the create-org wizard must also undo the role we committed
+ // on the way in, otherwise a user who changes their mind is left on
+ // ORG_WORKSPACE with `onboardingCompleted: false` — able to create orgs
+ // without ever having finished onboarding. Best-effort and fire-and-forget:
+ // returning to step 0 is the user-visible action, and the server no-ops
+ // unless the handoff is still provisional. Re-picking ORG_WORKSPACE
+ // re-commits the role through `handleNext`.
+ const handleExitOrgWizard = () => {
+ setStep(0);
+ const userId = session?.user?.id;
+ if (!userId) return;
+ void resetOnboardingRoleAction(userId);
+ };
+
const handleSubmit = async (data: Partial) => {
const finalData = { ...formData, ...data };
@@ -311,292 +558,151 @@ const MultiStepForm: React.FC = () => {
}
};
- const renderFormStep = () => {
- switch (step) {
- case 0:
- return (
-
- );
- case 1:
- switch (formData.role) {
- case "CONSULTANT":
- return (
-
- );
- case "CONSULTEE":
- return (
-
- );
- case "STAFF":
- return (
- [0]["initialData"]}
- />
- );
- default:
- return null;
- }
- case 2:
- switch (formData.role) {
- case "CONSULTANT":
- return (
-
- );
- case "CONSULTEE":
- return (
- [0]["formData"]}
- />
- );
- case "STAFF":
- return (
- [0]["initialData"]}
- />
- );
- default:
- return null;
- }
- case 3:
- switch (formData.role) {
- case "CONSULTANT":
- return (
-
- );
- case "CONSULTEE":
- return (
- [0]["formData"]}
- onGoToStep={handleGoToStep}
- />
- );
- case "STAFF":
- return (
- [0]["formData"]}
- onGoToStep={handleGoToStep}
- />
- );
- default:
- return null;
- }
- case 4:
- switch (formData.role) {
- case "CONSULTANT":
- return (
-
- );
- default:
- return null;
- }
- default:
- return null;
- }
+ // `role` is only trustworthy once step 0 has been submitted; before that (and
+ // for anything the registry does not cover) the consultee flow is the
+ // default, as it was when the labels lived in their own map.
+ const currentRole: OnboardingRole =
+ formData.role && formData.role in ONBOARDING_STEPS
+ ? (formData.role as OnboardingRole)
+ : "CONSULTEE";
+ const steps = ONBOARDING_STEPS[currentRole];
+ const totalSteps = steps.length;
+ const activeStep = steps[step];
+
+ const stepContext: OnboardingStepContext = {
+ formData,
+ userId: session?.user?.id,
+ onNext: handleNext,
+ onBack: handleBack,
+ onSubmit: handleSubmit,
+ onGoToStep: handleGoToStep,
+ onExitOrgWizard: handleExitOrgWizard,
};
- // Get step labels based on role
- const currentRole = formData.role || "CONSULTEE";
- const stepLabels =
- currentRole in STEP_LABELS
- ? STEP_LABELS[currentRole as keyof typeof STEP_LABELS]
- : STEP_LABELS.CONSULTEE;
- const totalSteps = stepLabels.length;
-
- // Use wider layout for steps that need more horizontal space
- const wideLayoutSteps = ["Availability"];
- const useWideLayout = wideLayoutSteps.includes(stepLabels[step]);
-
- // ORG_WORKSPACE handoff: after Personal Info commits the role, render the
- // shared create-org wizard instead of this page's shell. The wizard
- // owns the remaining 5-6 steps (Org Info → Review) and has its own
- // stepper; afterLaunch flips `user.onboardingCompleted = true` so the
- // user lands on their new org's home fully onboarded.
- if (currentRole === "ORG_WORKSPACE" && step > 0) {
- const userId = session?.user?.id;
- // #863 — hostOrgsEnabled defaults to false here (host capability hidden),
- // which is the honest state while ENABLE_HOST_ORGS is off. This is a client
- // component with no server parent to read the flag; TODO(#863): thread the
- // server flag when host orgs launch (e.g. a server action or a server shell).
- return (
- setStep(0)}
- afterLaunch={async () => {
- if (!userId) return;
- await completeOrgWorkspaceOnboardingAction(userId);
- }}
- />
- );
+ if (activeStep?.fullBleed) {
+ return <>{activeStep.render(stepContext)}>;
}
return (
-
-
>
)}
-
>
);
};
diff --git a/components/appointments/consultee/ConsulteeAppointmentsAdapter.tsx b/components/appointments/consultee/ConsulteeAppointmentsAdapter.tsx
index bfc471bf9..37d9683d1 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";
@@ -110,14 +110,23 @@ const KIND_TO_REPORT_TYPE: Record<
CLASS: "CLASS",
};
-export function useConsulteeAppointmentsAdapter(): AppointmentActionAdapter {
+/**
+ * @param options.consulteeId — Override when the URL has no `[consulteeId]`
+ * (org appointment detail). Falls back to the route param, then the session.
+ */
+export function useConsulteeAppointmentsAdapter(options?: {
+ consulteeId?: string;
+}): AppointmentActionAdapter {
const router = useRouter();
const { toast } = useToast();
- const client = useStreamVideoClient();
const { data: session } = useSession();
const queryClient = useQueryClient();
const params = useParams<{ consulteeId: string }>();
- const consulteeId = params?.consulteeId;
+ const consulteeId =
+ options?.consulteeId ||
+ params?.consulteeId ||
+ session?.user?.consulteeProfileId ||
+ undefined;
// ONE set of dialogs, keyed off the row that opened them.
const [activeVm, setActiveVm] = useState(null);
@@ -144,6 +153,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/dashboard/CollapsibleSidebar.tsx b/components/dashboard/CollapsibleSidebar.tsx
index 51002281a..475df41bd 100644
--- a/components/dashboard/CollapsibleSidebar.tsx
+++ b/components/dashboard/CollapsibleSidebar.tsx
@@ -613,18 +613,20 @@ export function CollapsibleSidebar({
}
/**
- * Loading skeleton that matches {@link CollapsibleSidebar}'s visual footprint.
+ * Full-page loading skeleton that matches {@link CollapsibleSidebar}'s
+ * visual footprint (sidebar + content column).
*
- * Render this while user/session data is still loading so the layout doesn't
- * flash between states. Used by both the admin and staff dashboard layouts —
- * keep it DRY with the real sidebar's classes so the width and background
- * stay aligned when the real component mounts.
+ * Use only as a *layout* fallback — when the shell itself has not mounted
+ * yet (e.g. admin/staff/org client layouts before session data). Do NOT use
+ * inside a segment `loading.tsx` whose parent layout already renders a
+ * shell; that nests a second `h-screen-maintenance` viewport. For those
+ * routes use a content-only skeleton such as `PageSkeleton`.
*/
export function CollapsibleSidebarSkeleton() {
return (
-