Skip to content
158 changes: 158 additions & 0 deletions __tests__/schedule/availability-grid-conditional-get.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
/**
* @jest-environment node
*/

/**
* #1319 PR 9 β€” conditional GET on the polled availability grid.
*
* ADR 16 keeps slot freshness on a 60s poll rather than a socket, so every
* open calendar re-asks this endpoint once a minute and almost always gets
* back the answer it already holds. The route now decides that from a single
* indexed change marker before it runs any of the occupancy work.
*
* The pin: an unchanged marker answers 304 WITHOUT touching the heavy queries,
* and a changed marker answers 200 with a different tag.
*/

jest.mock("../../lib/prisma", () => ({
__esModule: true,
default: {
$queryRaw: jest.fn(),
consultantProfile: { findUnique: jest.fn(), count: jest.fn() },
appointment: { findMany: jest.fn() },
membership: { findFirst: jest.fn() },
},
}));

jest.mock("../../lib/auth-server", () => ({
__esModule: true,
getSession: jest.fn(async () => null),
}));

import { NextRequest } from "next/server";
import { GET } from "../../app/api/slots/availability-with-allocation/[consultantId]/route";
import prisma from "@/lib/prisma";

const CONSULTANT_ID = "consultant-1";
const URL_BASE = `https://x.test/api/slots/availability-with-allocation/${CONSULTANT_ID}`;
const QUERY =
"startDateInUtc=2026-09-07T00:00:00.000Z&endDateInUtc=2026-09-14T00:00:00.000Z&timezone=UTC";

const mockedMarker = prisma.$queryRaw as unknown as jest.Mock;
const mockedProfile = prisma.consultantProfile.findUnique as jest.Mock;
const mockedAppointments = prisma.appointment.findMany as jest.Mock;

function marker(overrides: Record<string, Date | number | null> = {}) {
return [
{
profileUpdatedAt: new Date("2026-09-01T10:00:00.000Z"),
availabilityUpdatedAt: new Date("2026-09-01T11:00:00.000Z"),
availabilityRowCount: 3,
paymentsUpdatedAt: null,
slotsUpdatedAt: new Date("2026-09-02T09:00:00.000Z"),
requestsUpdatedAt: new Date("2026-09-02T08:00:00.000Z"),
nextHoldExpiry: null,
...overrides,
},
];
}

function request(ifNoneMatch?: string) {
return new NextRequest(`${URL_BASE}?${QUERY}`, {
headers: ifNoneMatch ? { "If-None-Match": ifNoneMatch } : undefined,
});
}

const params = Promise.resolve({ consultantId: CONSULTANT_ID });

beforeEach(() => {
jest.clearAllMocks();
mockedMarker.mockResolvedValue(marker());
mockedProfile.mockResolvedValue({
id: CONSULTANT_ID,
userId: "user-consultant",
scheduleType: "WEEKLY",
slotsOfAvailabilityWeekly: [],
slotsOfAvailabilityCustom: [],
});
mockedAppointments.mockResolvedValue([]);
});

describe("availability grid conditional GET", () => {
it("answers 200 with a strong ETag when the caller sends no validator", async () => {
const res = await GET(request(), { params });

expect(res.status).toBe(200);
const etag = res.headers.get("ETag");
expect(etag).toMatch(/^"[A-Za-z0-9_-]+"$/);
expect(res.headers.get("Cache-Control")).toBe("private, max-age=30");
expect(mockedProfile).toHaveBeenCalledTimes(1);
});

it("answers 304 and skips the occupancy queries when the marker has not moved", async () => {
const first = await GET(request(), { params });
const etag = first.headers.get("ETag") as string;

jest.clearAllMocks();
mockedMarker.mockResolvedValue(marker());

const second = await GET(request(etag), { params });

expect(second.status).toBe(304);
expect(second.headers.get("ETag")).toBe(etag);
expect(second.headers.get("Cache-Control")).toBe("private, max-age=30");
// The whole point: no availability read, no occupancy read.
expect(mockedProfile).not.toHaveBeenCalled();
expect(mockedAppointments).not.toHaveBeenCalled();
expect(mockedMarker).toHaveBeenCalledTimes(1);
});

it("answers 200 with a new ETag when a slot row has moved since", async () => {
const first = await GET(request(), { params });
const etag = first.headers.get("ETag") as string;

mockedMarker.mockResolvedValue(
marker({ slotsUpdatedAt: new Date("2026-09-02T09:30:00.000Z") }),
);

const second = await GET(request(etag), { params });

expect(second.status).toBe(200);
expect(second.headers.get("ETag")).not.toBe(etag);
expect(mockedAppointments).toHaveBeenCalled();
});

it("answers 200 when only the clock fold moved β€” a hold lapsed with no write", async () => {
const withHold = await GET(request(), {
params,
});
const etag = withHold.headers.get("ETag") as string;

// The earliest still-future PENDING deadline is what the marker carries;
// when now() passes it the row drops out and the next one takes its place.
mockedMarker.mockResolvedValue(
marker({ nextHoldExpiry: new Date("2026-09-07T12:00:00.000Z") }),
);

const after = await GET(request(etag), { params });

expect(after.status).toBe(200);
expect(after.headers.get("ETag")).not.toBe(etag);
});

it("answers 200 when an availability row was deleted β€” count moved, timestamps equal", async () => {
const withHold = await GET(request(), {
params,
});
const etag = withHold.headers.get("ETag") as string;

// The earliest still-future PENDING deadline is what the marker carries;
// when now() passes it the row drops out and the next one takes its place.
mockedMarker.mockResolvedValue(marker({ availabilityRowCount: 2 }));

const after = await GET(request(etag), { params });

expect(after.status).toBe(200);
expect(after.headers.get("ETag")).not.toBe(etag);
});
});
61 changes: 52 additions & 9 deletions app/api/slots/availability-with-allocation/[consultantId]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@ import {
type AppointmentForOverlapMeta,
} from "@/lib/booking/overlap-meta";
import { isPrivileged } from "@/lib/auth-helpers";
import {
availabilityGridEtag,
ifNoneMatchSatisfied,
readAvailabilityGridMarker,
} from "@/lib/scheduling/availabilityGridMarker";
import type { TSlotTiming } from "@/types/slots";
import type { BookingStatus } from "@/utils/timeSlotsProcessing";

Expand All @@ -33,6 +38,15 @@ type SlotTimingWithOverlap = TSlotTiming & {
overlappingAppointments?: OverlapAppointmentMeta[];
};

// #1164 β€” browser-only cache for the polling grid. `private`, not the sibling's
// `public, s-maxage`: the same URL answers differently by session (the
// includeAppointmentDetails/consulteeUserId gates below), so a shared cache
// would cross identities. Bounded staleness is safe β€” allocation re-validates
// server-side β€” and the one caller that must never see it, the post-allocation
// refetch, asks for `cache: "no-store"` (AllocationService, #1164).
// No SWR: the 60s poll and return-tick must repaint fresh, not one-interval-old.
const GRID_CACHE_CONTROL = "private, max-age=30";

// An org OWNER/MAINTAINER acting for a member consultant (RequestSlotAllocationTab
// mounts mode="allocate" for org admins allocating on a consultant's behalf)
// is authorized the same as the owning consultant. isPrivileged only covers
Expand Down Expand Up @@ -207,6 +221,41 @@ export async function GET(
);
}

// #1319 PR 9 β€” conditional GET, computed BEFORE the heavy reads.
//
// Every open calendar re-asks this endpoint once a minute (ADR 16: polling,
// not Realtime) and the answer is almost always the one it already has. One
// indexed marker read decides that in a single statement, against the 8
// statements the public grid costs and the 18 the detail grid costs (#997,
// docs/booking/20-availability-grid-cost.md).
//
// Placed after the authorization gates on purpose: a caller who has since
// lost access is refused up there, so a 304 can never serve stale
// permission. The resolved (not requested) detail flag and the consultee id
// are hashed into the tag, so the two payload shapes cannot collide.
const marker = await readAvailabilityGridMarker(
prisma,
consultantId,
consulteeUserId,
);
// No marker = no such consultant; fall through so the 404 below still answers.
const etag = marker
? availabilityGridEtag(marker, {
consultantId,
startIso: startDate.toISOString(),
endIso: endDate.toISOString(),
timezone,
includeAppointmentDetails,
consulteeUserId,
})
: null;
if (etag && ifNoneMatchSatisfied(req.headers.get("if-none-match"), etag)) {
return new NextResponse(null, {
status: 304,
headers: { "Cache-Control": GRID_CACHE_CONTROL, ETag: etag },
});
}

// 1. Fetch consultant's availability
const consultant = await prisma.consultantProfile.findUnique({
where: { id: consultantId },
Expand Down Expand Up @@ -699,15 +748,9 @@ export async function GET(
{
status: 200,
headers: {
// #1164 β€” browser-only cache for the polling grid. `private`, not
// the sibling's `public, s-maxage`: the same URL answers differently
// by session (includeAppointmentDetails/consulteeUserId gates
// above), so a shared cache would cross identities. Bounded
// staleness is safe β€” allocation re-validates server-side β€” and the
// one caller that must never see it, the post-allocation refetch,
// asks for `cache: "no-store"` (AllocationService, #1164).
// #1164 β€” no SWR: the 60s poll and return-tick must repaint fresh, not one-interval-old (adversarial review)
"Cache-Control": "private, max-age=30",
"Cache-Control": GRID_CACHE_CONTROL,
// #1319 PR 9 β€” what the next poll sends back as If-None-Match.
...(etag ? { ETag: etag } : {}),
},
},
);
Expand Down
16 changes: 16 additions & 0 deletions docs/booking/05-troubleshooting-and-changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,22 @@ One piece of #1206 is deliberately deferred and marked in the code. Re-attemptin

---

### PR 9 β€” availability grid conditional GET (`perf/availability-grid-conditional-get`)

Part of #1319. This PR makes the polled availability grid cheap when nothing has changed, and it does so on the strength of a measurement rather than a guess. The measurement itself is written up in [20-availability-grid-cost.md](./20-availability-grid-cost.md).

**The endpoint's cost was measured before anything was changed.** ADR 16 keeps slot freshness on a sixty-second poll, so every open calendar re-asks `/api/slots/availability-with-allocation/[consultantId]` once a minute. One public poll is eight SQL statements and about 140 ms; one consultant Allocate-Slots poll is eighteen statements and about 205 ms, and passing `consulteeUserId` adds roughly five more. `EXPLAIN (ANALYZE, BUFFERS)` on the occupancy query shows fifteen `LEFT JOIN`s, seventeen sequential scans, and 8.9 ms of planning against 7.6 ms of execution β€” the query costs the same whether it returns rows or not, because it reads the whole booking core every time. The response body costs a measured 405 bytes per thirty-minute cell, so a busy consultant's week is about 25 KB and a month view roughly four times that. No new index was added: the plan shows every consultant-scoped arm already served by an existing one, and the sequential scans that remain are the planner's correct choice on tables of a few hundred rows.

**The route now answers 304 when nothing it reads has moved.** Before any of the work above, the route computes a change marker in a single indexed statement β€” measured at 3.4–5.5 ms of planning, 3.3–4.7 ms of execution and 32 ms end to end, which is the network round trip and almost nothing else. The marker is the consultant profile's `updatedAt`, the newest availability row for that consultant, the newest booked slot row among the appointments that reach them, the newest parent request row among those same appointments and among their own plans, and the earliest still-future `PENDING` payment deadline. Those five values are hashed together with the window, the timezone, the resolved detail flag and the accepted consultee id into a strong ETag, and a matching `If-None-Match` short-circuits the whole endpoint. An unchanged poll therefore costs one statement instead of eight or eighteen, and sends no body at all.

**The marker deliberately over-invalidates rather than risk a stale 304.** It is scoped to the consultant rather than to the requested window, so an edit to a booking months away recomputes this week's grid; that costs one wasted response and can never serve a stale one. The last entry is the clock fold: a payment hold lapsing changes the answer without changing a row, so the marker carries the earliest deadline _still in the future_, which moves to the next hold the moment the clock crosses it. Authorization is not in the marker and does not need to be, because the ETag is computed after the permission gates β€” a caller who has lost access is refused there and never reaches the conditional branch.

**The marker is one raw `SELECT`, against the ORM-first rule, and the reason is round trips.** `PG_POOL_MAX=1` serialises every Prisma read onto one connection on Netlify, and `Promise.all` buys nothing there (#1117). Expressed through the ORM the marker is ten aggregates, which is ten round trips β€” slower than the query it exists to skip. As one statement it is one.

**The calendar hook echoes the tag back and treats 304 as "unchanged".** `useCalendarData` keeps the last response's ETag in a ref and `AllocationService.fetchAvailabilitySlots` sends it as `If-None-Match`, except on the post-allocation refetch, which has just mutated and wants the body regardless. A 304 returns early without calling `setState`, so an unchanged poll no longer re-renders every cell in the grid. Sending a conditional header makes `fetch` treat the request as `no-store` per the Fetch specification, so the browser's own thirty-second freshness shortcut no longer short-circuits it; that is the trade, and it buys a client that never repaints from a body the browser may have evicted.

**One defect was found while measuring and deliberately not fixed here.** Both occupancy branches select only `payment.expiresAt`, while `isOccupiedByLiveAppointment` reads `paymentStatus` and `bookingSource`, so the route's expired-hold handling is inert and a lapsed hold paints busy until the sweep tidies it. That is a correctness bug rather than a performance one and belongs in its own change; it means the clock fold is, for now, defensive rather than load-bearing.

## Changelog: 2026-08-14 β€” documentation refresh

Docs-only pass shipped as the final PR of the #1169 booking + maintenance productionization train, closing the long-standing booking-docs drift item #1013. No code changed in this entry.
Expand Down
Loading
Loading