fix(api): one helper for list pages, exact cut signal, total order

listPage (src/lib/http/list-query.ts) reads one row past the limit and
cuts it back, on top of the existing list-cap signal rather than beside
it. Every capped list now gets its page there, and every read passed to
it orders by something ending in the row id, so rows that share a sort
value cannot repeat or vanish at a page boundary.

- practitioners, vaccinations and encounters were cut at their take
  with no signal; they now answer meta.truncated and accept offset, and
  their web readers walk the pages instead of stopping at the first
- symptoms/events reported hasMore on a page that was exactly full; it
  is now true only when a row past the page exists
- the document list's hand-rolled limit + 1 goes through the helper
- security-activity answers an out-of-range limit with the 422 its
  published schema already promised instead of clamping it

list-page-guard binds every apiListSuccess route to listPage, forbids
hand-cut lists and length-derived hasMore, and requires the id
tiebreaker in every listPage read.
This commit is contained in:
Marc-André Bombeck committed 2026-10-20 13:29:14 +02:00
1 parent 2265763160
commit 8db7c71b0e
35 files changed
+1024 -394

No files matched your search

+56 -44
View File
@@ -2969,17 +2969,17 @@ paths:
- Auth
summary: List recent account-security activity
description: "v1.23 — the SHARED security-activity feed: the caller's recent auth + export + deletion audit events with
timestamp, resolved location, and a host-masked IP. `limit` query param caps at 100 (default 50). Reuses the
AuditLog store; no event detail bodies are surfaced."
timestamp, resolved location, and a host-masked IP. `limit` is 1-100 (default 50); a value outside that is a 422
naming it. Reuses the AuditLog store; no event detail bodies are surfaced."
parameters:
- in: query
name: limit
schema:
description: Page size; defaults to 50, clamped to 100.
description: Page size, 1-100; defaults to 50. Out of range is a 422.
type: integer
minimum: 1
maximum: 100
description: Page size; defaults to 50, clamped to 100.
description: Page size, 1-100; defaults to 50. Out of range is a 422.
responses:
"200":
description: Recent security events for the caller.
@@ -15871,8 +15871,9 @@ paths:
tags:
- Records
summary: List visits
description: Returns the caller's live visits, split into `upcoming` and `past`. Bounded and windowed; soft-deleted
visits are excluded.
description: Returns the caller's live visits, split into `upcoming` (soonest first) and `past` (newest first). `limit`
and `offset` apply to each list; `meta.truncated` is true when either list has rows past this page, so a client
reads the next `offset` until it is false. Soft-deleted visits are excluded.
parameters:
- in: query
name: from
@@ -16231,7 +16232,8 @@ paths:
tags:
- Records
summary: List practitioners
description: Returns the caller's address book, name-ordered, with an optional case-insensitive name search.
description: Returns the caller's address book, name-ordered, with an optional case-insensitive search over name,
practice and specialty. Paged with `limit` and `offset`; `meta.truncated` says whether another page exists.
parameters:
- in: query
name: q
@@ -16245,6 +16247,15 @@ paths:
type: integer
minimum: 1
maximum: 200
- in: query
name: offset
schema:
description: Rows to skip, for reading the address book page by page; `meta.truncated` says whether another page exists.
default: 0
type: integer
minimum: 0
maximum: 100000
description: Rows to skip, for reading the address book page by page; `meta.truncated` says whether another page exists.
responses:
"200":
description: The caller's address book.
@@ -16506,8 +16517,9 @@ paths:
tags:
- Records
summary: List vaccinations
description: Returns the caller's live doses, newest first, each with its series resolved per component antigen.
Soft-deleted doses are excluded.
description: Returns the caller's live doses, newest first, each with its series resolved per component antigen over the
whole record, never over the page. Paged with `limit` (default 300) and `offset`; `meta.truncated` says whether
another page exists. Soft-deleted doses are excluded.
parameters:
- in: query
name: antigenSlug
@@ -16533,6 +16545,15 @@ paths:
type: integer
minimum: 1
maximum: 500
- in: query
name: offset
schema:
description: Doses to skip, for reading the log page by page; `meta.truncated` says whether another page exists.
default: 0
type: integer
minimum: 0
maximum: 100000
description: Doses to skip, for reading the log page by page; `meta.truncated` says whether another page exists.
responses:
"200":
description: The caller's immunization history.
@@ -36236,24 +36257,24 @@ components:
profile_claim — profile_claim.invalid, profile_claim.oidc_only_unsupported,
profile_claim.sso_unavailable, profile_claim.taken; record_write — record_write.rate_limited;
report-selection — report-selection.body.invalid_json, report-selection.body.invalid_shape,
report-selection.leaves.unknown; route — route.retired; search — search.invalid; share-link —
share-link.document.decryptFailed, share-link.document.notFound, share-link.document.rateLimited,
share-link.documents.invalid, share-link.report.notFound, share-link.report.rateLimited,
share-link.selection.forbidden_leaf, share-link.selection.unknown_leaf; sharing —
sharing.accept.expired, sharing.accept.not_found, sharing.accept.not_pending, sharing.accept.revoked,
sharing.access.denied, sharing.invite.duplicate, sharing.invite.invalid, sharing.invite.invalid_scope,
sharing.invite.manage_browser_only, sharing.invite.no_account, sharing.invite.refused,
sharing.invite.self, sharing.not_permitted, sharing.renounce.already_ended, sharing.renounce.not_found,
sharing.revoke.already_ended, sharing.revoke.not_found, sharing.session.changed, sharing.switch.invalid,
sharing.switch.wrong_transport; source-priority — source-priority.body.invalid_json,
source-priority.body.invalid_shape; symptoms — symptoms.definition.limitReached,
symptoms.definition.notFound, symptoms.episode.notOpen, symptoms.event.notFound, symptoms.invalid;
timeline — timeline.invalid; tokens — tokens.documents.ceiling_reached,
tokens.measurements.ceiling_reached, tokens.workouts.ceiling_reached; vaccination —
vaccination.booster-invalid, vaccination.booster-no-antigen, vaccination.booster-out-of-scope,
vaccination.custom-vaccine-not-found, vaccination.custom.invalid, vaccination.custom.name-taken,
vaccination.custom.not-found, vaccination.encounter-not-found, vaccination.identity-required,
vaccination.invalid, vaccination.link.invalid, vaccination.not-found,
report-selection.leaves.unknown; route — route.retired; search — search.invalid; security_activity —
security_activity.invalid; share-link — share-link.document.decryptFailed, share-link.document.notFound,
share-link.document.rateLimited, share-link.documents.invalid, share-link.report.notFound,
share-link.report.rateLimited, share-link.selection.forbidden_leaf, share-link.selection.unknown_leaf;
sharing — sharing.accept.expired, sharing.accept.not_found, sharing.accept.not_pending,
sharing.accept.revoked, sharing.access.denied, sharing.invite.duplicate, sharing.invite.invalid,
sharing.invite.invalid_scope, sharing.invite.manage_browser_only, sharing.invite.no_account,
sharing.invite.refused, sharing.invite.self, sharing.not_permitted, sharing.renounce.already_ended,
sharing.renounce.not_found, sharing.revoke.already_ended, sharing.revoke.not_found,
sharing.session.changed, sharing.switch.invalid, sharing.switch.wrong_transport; source-priority —
source-priority.body.invalid_json, source-priority.body.invalid_shape; symptoms —
symptoms.definition.limitReached, symptoms.definition.notFound, symptoms.episode.notOpen,
symptoms.event.notFound, symptoms.invalid; timeline — timeline.invalid; tokens —
tokens.documents.ceiling_reached, tokens.measurements.ceiling_reached, tokens.workouts.ceiling_reached;
vaccination — vaccination.booster-invalid, vaccination.booster-no-antigen,
vaccination.booster-out-of-scope, vaccination.custom-vaccine-not-found, vaccination.custom.invalid,
vaccination.custom.name-taken, vaccination.custom.not-found, vaccination.encounter-not-found,
vaccination.identity-required, vaccination.invalid, vaccination.link.invalid, vaccination.not-found,
vaccination.practitioner-not-found; workout — workout.batch.invalid, workout.batch.payload_too_large,
workout.batch.source_not_permitted, workout.batch.too_large, workout.delete.synced_source; (unprefixed)
— about_me_conflict, ai_response_truncated, coach_prefs_conflict, dashboard_layout_conflict, empty_file,
@@ -71789,14 +71810,11 @@ components:
error:
type: "null"
meta:
type: object
properties:
requestId:
type: string
additionalProperties: false
$ref: "#/components/schemas/ListCapMeta"
required:
- data
- error
- meta
additionalProperties: false
EncounterList:
type: object
@@ -72524,14 +72542,11 @@ components:
error:
type: "null"
meta:
type: object
properties:
requestId:
type: string
additionalProperties: false
$ref: "#/components/schemas/ListCapMeta"
required:
- data
- error
- meta
additionalProperties: false
CreatePractitionerEnvelope:
type: object
@@ -72753,14 +72768,11 @@ components:
error:
type: "null"
meta:
type: object
properties:
requestId:
type: string
additionalProperties: false
$ref: "#/components/schemas/ListCapMeta"
required:
- data
- error
- meta
additionalProperties: false
VaccinationList:
type: object
@@ -85402,8 +85414,8 @@ components:
- limit
- hasMore
additionalProperties: false
description: A page of occurrences, newest first. `hasMore` is true when the page is full; page on with `to` set to the
oldest `occurredAt` seen.
description: A page of occurrences, newest first. `hasMore` is true exactly when an occurrence past this page exists;
page on with `to` set to the oldest `occurredAt` seen.
SymptomEvent:
type: object
properties:
+190
View File
@@ -0,0 +1,190 @@
/**
* Structural guard: every capped or paged list gets its page from one helper,
* and every such read has a total order.
*
* The list routes had five answers to "is there more", and two of them lied:
* a `hasMore` computed as `rows.length === limit` reported more on a page that
* was exactly full, and a list cut at its `take` with no signal at all let the
* last row read as the oldest one there is. `listPage`
* (`src/lib/http/list-query.ts`) reads one row past the limit and says
* exactly whether that row exists. This guard holds the tree to it:
*
* 1. a route that answers with `apiListSuccess` gets its cap from
* `listPage`;
* 2. nothing outside the two list modules calls `capRows` or `overfetch` by
* hand;
* 3. a `hasMore` is never a length comparison;
* 4. every read passed to `listPage` orders by something that ends in the
* row id, so a page boundary between two rows that share a sort value
* neither repeats one nor drops one.
*
* The routes are derived from the tree, never listed by hand. Comments are
* stripped first, and the planted cases at the top prove each matcher fires.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { walkSourceFiles } from "./helpers/source-files";
const SRC = join(process.cwd(), "src");
/** The modules allowed to cut a list by hand: the helper and its primitive. */
const LIST_MODULES = new Set(["lib/api/list-cap.ts", "lib/http/list-query.ts"]);
function stripComments(text: string): string {
return text.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, "");
}
/** The text of a call's argument list, from the `(` after `name` to its match. */
function callBodies(code: string, name: string): string[] {
const out: string[] = [];
const re = new RegExp(`\\b${name}\\s*\\(`, "g");
for (let m = re.exec(code); m; m = re.exec(code)) {
let depth = 0;
let i = m.index + m[0].length - 1;
const start = i;
for (; i < code.length; i++) {
const ch = code[i];
if (ch === "(") depth++;
else if (ch === ")") {
depth--;
if (depth === 0) break;
}
}
out.push(code.slice(start, i + 1));
}
return out;
}
/** The value of the first `orderBy` in a read, up to its closing bracket. */
function orderByValue(body: string): string | null {
const m = /\borderBy\b\s*(:)?\s*/.exec(body);
if (!m) return null;
// Shorthand `orderBy,` names a variable of the same name.
if (!m[1]) return "orderBy";
let i = m.index + m[0].length;
const open = body[i];
if (open !== "[" && open !== "{") {
const ident = /^[A-Za-z_$][\w$]*/.exec(body.slice(i));
return ident ? ident[0] : null;
}
const close = open === "[" ? "]" : "}";
let depth = 0;
const start = i;
for (; i < body.length; i++) {
if (body[i] === open) depth++;
else if (body[i] === close) {
depth--;
if (depth === 0) break;
}
}
return body.slice(start, i + 1);
}
/** Whether an order ends in the row id, following one variable if named. */
function endsInId(order: string, file: string): boolean {
const literal = (text: string) =>
/^\[[\s\S]*\{\s*id\s*:\s*[^}]+\}\s*,?\s*\]$/.test(text.trim());
if (order.startsWith("[") || order.startsWith("{")) return literal(order);
const decl = new RegExp(
`\\b(?:const|let)\\s+${order}\\b[^=]*=\\s*(\\[[\\s\\S]*?\\]);`,
).exec(file);
return decl ? literal(decl[1]) : false;
}
describe("capped lists go through listPage", () => {
const files = walkSourceFiles(SRC, { floor: 3000 }).filter(
(file) =>
!file.startsWith("generated/") &&
!file.includes("__tests__/") &&
!/\.test\.tsx?$/.test(file),
);
const read = (file: string) =>
stripComments(readFileSync(join(SRC, file), "utf8"));
const routes = files.filter((f) => /^app\/api\/.+\/route\.tsx?$/.test(f));
it("the matchers fire on planted sources", () => {
const handRolled = `
const found = await prisma.allergy.findMany({ take: overfetch(limit) });
const { rows, cap } = capRows(found, limit);
return apiListSuccess(rows, cap);`;
expect(/\bcapRows\s*\(/.test(handRolled)).toBe(true);
expect(/\blistPage\s*\(/.test(handRolled)).toBe(false);
expect(
/\bhasMore\s*:[^,\n]*\.length\b/.test(
"hasMore: rows.length === q.limit,",
),
).toBe(true);
expect(
/\bhasMore\s*:[^,\n]*\.length\b/.test("hasMore: cap.truncated,"),
).toBe(false);
const noTiebreak = `listPage(limit, (take) =>
prisma.x.findMany({ orderBy: { createdAt: "desc" }, take }))`;
const [body] = callBodies(noTiebreak, "listPage");
expect(endsInId(orderByValue(body)!, noTiebreak)).toBe(false);
const tiebreak = `listPage(limit, (take) =>
prisma.x.findMany({ orderBy: [{ createdAt: "desc" }, { id: "desc" }], take }))`;
const [good] = callBodies(tiebreak, "listPage");
expect(endsInId(orderByValue(good)!, tiebreak)).toBe(true);
// A shorthand `orderBy` resolves through its declaration.
const viaVariable = `const orderBy: X[] = [primaryOrder, { id: order }];
listPage(limit, (take) => prisma.x.findMany({ where, orderBy, take }))`;
const [short] = callBodies(viaVariable, "listPage");
expect(endsInId(orderByValue(short)!, viaVariable)).toBe(true);
const viaBadVariable = `const orderBy = [primaryOrder];
listPage(limit, (take) => prisma.x.findMany({ where, orderBy, take }))`;
const [bad] = callBodies(viaBadVariable, "listPage");
expect(endsInId(orderByValue(bad)!, viaBadVariable)).toBe(false);
});
it("finds the capped routes", () => {
const capped = routes.filter((f) => /\bapiListSuccess\s*\(/.test(read(f)));
expect(capped.length).toBeGreaterThanOrEqual(15);
});
it("every route answering a capped list gets its cap from listPage", () => {
const unbound = routes.filter((f) => {
const code = read(f);
return (
/\bapiListSuccess\s*\(/.test(code) && !/\blistPage\s*\(/.test(code)
);
});
expect(unbound).toEqual([]);
});
it("nothing cuts a list by hand outside the list modules", () => {
const handRolled = files.filter(
(f) =>
!LIST_MODULES.has(f) && /\b(?:capRows|overfetch)\s*\(/.test(read(f)),
);
expect(handRolled).toEqual([]);
});
it("no hasMore is a length comparison", () => {
const guessed = routes.filter((f) =>
/\bhasMore\s*:[^,\n]*\.length\b/.test(read(f)),
);
expect(guessed).toEqual([]);
});
it("every listPage read orders by something that ends in the row id", () => {
const unordered: string[] = [];
let reads = 0;
for (const f of files) {
if (LIST_MODULES.has(f)) continue;
const code = read(f);
for (const body of callBodies(code, "listPage")) {
reads += 1;
const order = orderByValue(body);
if (!order || !endsInId(order, code)) unordered.push(f);
}
}
expect(reads).toBeGreaterThanOrEqual(18);
expect(unordered).toEqual([]);
});
});
+27 -26
View File
@@ -40,7 +40,8 @@ import { prisma } from "@/lib/db";
import { getAuditLogRetentionDays } from "@/lib/jobs/audit-log-cleanup";
import { annotate } from "@/lib/logging/context";
import { GRANT_PARTY_SELECT } from "@/lib/sharing/grant-view";
import { apiListSuccess, capRows, overfetch } from "@/lib/api/list-cap";
import { apiListSuccess } from "@/lib/api/list-cap";
import { listPage } from "@/lib/http/list-query";
export const dynamic = "force-dynamic";
@@ -99,31 +100,31 @@ export const GET = apiHandler(async () => {
const retentionDays = getAuditLogRetentionDays();
const windowStart = new Date(Date.now() - retentionDays * 86_400_000);
const found = await prisma.auditLog.findMany({
where: {
userId: user.id,
actorUserId: { not: null },
// A switch into one's own record is not a delegation and has nothing to
// report. `actingActorFor` already refuses to stamp one, so this is the
// second end of that rule rather than its only enforcement.
NOT: { actorUserId: user.id },
// The window the copy promises. The purge job makes it true eventually;
// this makes it true now, which matters on an instance whose retention
// was shortened after rows were already written.
createdAt: { gte: windowStart },
},
orderBy: { createdAt: "desc" },
take: overfetch(MAX_ROWS),
select: {
id: true,
actorUserId: true,
action: true,
details: true,
createdAt: true,
},
});
const { rows, cap } = capRows(found, MAX_ROWS);
const { rows, cap } = await listPage(MAX_ROWS, (take) =>
prisma.auditLog.findMany({
where: {
userId: user.id,
actorUserId: { not: null },
// A switch into one's own record is not a delegation and has nothing to
// report. `actingActorFor` already refuses to stamp one, so this is the
// second end of that rule rather than its only enforcement.
NOT: { actorUserId: user.id },
// The window the copy promises. The purge job makes it true eventually;
// this makes it true now, which matters on an instance whose retention
// was shortened after rows were already written.
createdAt: { gte: windowStart },
},
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
take,
select: {
id: true,
actorUserId: true,
action: true,
details: true,
createdAt: true,
},
}),
);
const actorIds = [
...new Set(rows.map((r) => r.actorUserId).filter((id) => id !== null)),
+22 -27
View File
@@ -73,7 +73,8 @@ import {
} from "@/lib/sharing/grant-view";
import { GrantError, inviteGrant } from "@/lib/sharing/grants";
import { inviteGrantSchema } from "@/lib/validations/account-sharing";
import { apiListSuccess, capRows, overfetch } from "@/lib/api/list-cap";
import { apiListSuccess } from "@/lib/api/list-cap";
import { combinedCap, listPage } from "@/lib/http/list-query";
/**
* Ten invitations an hour, per account.
@@ -92,28 +93,25 @@ export const GET = apiHandler(async () => {
const { user } = await requireAuth();
annotate({ action: { name: "sharing.grants.list" } });
const [givenFound, receivedFound] = await Promise.all([
prisma.accountGrant.findMany({
where: { grantorId: user.id },
orderBy: { createdAt: "desc" },
take: overfetch(MAX_GRANTS_PER_DIRECTION),
include: { grantee: { select: GRANT_PARTY_SELECT } },
}),
prisma.accountGrant.findMany({
where: { granteeId: user.id },
orderBy: { createdAt: "desc" },
take: overfetch(MAX_GRANTS_PER_DIRECTION),
include: { grantor: { select: GRANT_PARTY_SELECT } },
}),
]);
const { rows: given, cap: givenCap } = capRows(
givenFound,
MAX_GRANTS_PER_DIRECTION,
);
const { rows: received, cap: receivedCap } = capRows(
receivedFound,
MAX_GRANTS_PER_DIRECTION,
);
const [{ rows: given, cap: givenCap }, { rows: received, cap: receivedCap }] =
await Promise.all([
listPage(MAX_GRANTS_PER_DIRECTION, (take) =>
prisma.accountGrant.findMany({
where: { grantorId: user.id },
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
take,
include: { grantee: { select: GRANT_PARTY_SELECT } },
}),
),
listPage(MAX_GRANTS_PER_DIRECTION, (take) =>
prisma.accountGrant.findMany({
where: { granteeId: user.id },
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
take,
include: { grantor: { select: GRANT_PARTY_SELECT } },
}),
),
]);
// Revoked and expired rows come back too. They are the consent record — "who
// had access, from when to when, and who ended it" is the question the table
@@ -122,10 +120,7 @@ export const GET = apiHandler(async () => {
const now = new Date();
// One cap per direction, one signal: either list cut short is a partial
// answer.
const cap = {
truncated: givenCap.truncated || receivedCap.truncated,
limit: MAX_GRANTS_PER_DIRECTION,
};
const cap = combinedCap(givenCap, receivedCap);
return apiListSuccess(
{
given: given.map((g) => toGrantView(g, g.grantee, now)),
+15 -11
View File
@@ -29,7 +29,8 @@ import {
allergyListQuerySchema,
} from "@/lib/validations/allergy";
import { toAllergyDTO } from "@/lib/records/dto";
import { apiListSuccess, capRows, overfetch } from "@/lib/api/list-cap";
import { apiListSuccess } from "@/lib/api/list-cap";
import { listPage } from "@/lib/http/list-query";
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireRecordAuth("read", "profile");
@@ -46,16 +47,19 @@ export const GET = apiHandler(async (request: NextRequest) => {
}
const limit = parsed.data.limit ?? 100;
const found = await prisma.allergy.findMany({
where: {
userId: user.id,
deletedAt: null,
...(parsed.data.includeInactive === "false" ? { status: "ACTIVE" } : {}),
},
orderBy: { createdAt: "desc" },
take: overfetch(limit),
});
const { rows, cap } = capRows(found, limit);
const { rows, cap } = await listPage(limit, (take) =>
prisma.allergy.findMany({
where: {
userId: user.id,
deletedAt: null,
...(parsed.data.includeInactive === "false"
? { status: "ACTIVE" }
: {}),
},
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
take,
}),
);
annotate({
action: { name: "allergy.list", entity_type: "allergy" },
@@ -0,0 +1,68 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
auditLog: { findMany: vi.fn(), create: vi.fn() },
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { GET } from "../route";
import { getSession } from "@/lib/auth/session";
import { prisma } from "@/lib/db";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "u", role: "USER" as const, locale: "en" },
};
const req = (qs = "") =>
new NextRequest(`http://localhost/api/auth/me/security-activity${qs}`);
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.auditLog.findMany).mockResolvedValue([] as never);
});
describe("GET /api/auth/me/security-activity", () => {
it("reads one row past the requested page, in a total order", async () => {
const res = await GET(req("?limit=10"));
expect(res.status).toBe(200);
const call = vi.mocked(prisma.auditLog.findMany).mock.calls[0][0] as {
take: number;
orderBy: unknown;
};
expect(call.take).toBe(11);
expect(call.orderBy).toEqual([{ createdAt: "desc" }, { id: "desc" }]);
const body = (await res.json()) as {
meta: { truncated: boolean; limit: number };
};
expect(body.meta).toMatchObject({ truncated: false, limit: 10 });
});
it("answers a limit past the published ceiling with a 422 naming it", async () => {
const res = await GET(req("?limit=500"));
expect(res.status).toBe(422);
const body = (await res.json()) as {
meta: { errorCode: string };
details: { issues: Array<{ path: string }> };
};
expect(body.meta.errorCode).toBe("security_activity.invalid");
expect(body.details.issues.map((i) => i.path)).toEqual(["limit"]);
expect(prisma.auditLog.findMany).not.toHaveBeenCalled();
});
});
+29 -21
View File
@@ -14,7 +14,9 @@ import { annotate } from "@/lib/logging/context";
import { prisma } from "@/lib/db";
import { securityActivityWhere } from "@/lib/auth/security-activity";
import { maskIp } from "@/lib/auth/device-fingerprint";
import { apiListSuccess, capRows, overfetch } from "@/lib/api/list-cap";
import { apiListSuccess } from "@/lib/api/list-cap";
import { listPage, parseListQuery } from "@/lib/http/list-query";
import { returnAllZodIssues } from "@/lib/api-response";
export const dynamic = "force-dynamic";
@@ -32,28 +34,34 @@ export interface SecurityActivityDTO {
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const limitParam = Number(
request.nextUrl.searchParams.get("limit") ?? DEFAULT_LIMIT,
);
const limit =
Number.isFinite(limitParam) && limitParam > 0
? Math.min(Math.floor(limitParam), MAX_LIMIT)
: DEFAULT_LIMIT;
const found = await prisma.auditLog.findMany({
where: securityActivityWhere(user.id),
orderBy: { createdAt: "desc" },
take: overfetch(limit),
select: {
action: true,
createdAt: true,
location: true,
ipAddress: true,
carrier: true,
},
// The shared list query: an out-of-range limit is a 422 naming it, as on
// every other list, rather than a silent clamp to something not asked for.
const query = parseListQuery(request.nextUrl.searchParams, {
defaultLimit: DEFAULT_LIMIT,
maxLimit: MAX_LIMIT,
});
if (!query.success) {
return returnAllZodIssues(query.error, 422, {
errorCode: "security_activity.invalid",
});
}
const { limit } = query.data;
const { rows, cap } = await listPage(limit, (take) =>
prisma.auditLog.findMany({
where: securityActivityWhere(user.id),
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
take,
select: {
action: true,
createdAt: true,
location: true,
ipAddress: true,
carrier: true,
},
}),
);
const { rows, cap } = capRows(found, limit);
const events: SecurityActivityDTO[] = rows.map((r) => ({
action: r.action,
createdAt: r.createdAt.toISOString(),
+9 -7
View File
@@ -19,7 +19,8 @@ import { toMenstrualCycleDTO } from "@/lib/cycle/dto";
import { median, mad } from "@/lib/cycle";
import { dayDiff } from "@/lib/cycle/day-math";
import { MIN_CYCLES_TO_PREDICT } from "@/lib/cycle/types";
import { apiListSuccess, capRows, overfetch } from "@/lib/api/list-cap";
import { apiListSuccess } from "@/lib/api/list-cap";
import { listPage } from "@/lib/http/list-query";
const DEFAULT_LIMIT = 24;
@@ -51,12 +52,13 @@ export const GET = apiHandler(async (request: NextRequest) => {
}
const limit = parsed.data.limit ?? DEFAULT_LIMIT;
const found = await prisma.menstrualCycle.findMany({
where: { userId: user.id, deletedAt: null },
orderBy: { startDate: "desc" },
take: overfetch(limit),
});
const { rows, cap } = capRows(found, limit);
const { rows, cap } = await listPage(limit, (take) =>
prisma.menstrualCycle.findMany({
where: { userId: user.id, deletedAt: null },
orderBy: [{ startDate: "desc" }, { id: "desc" }],
take,
}),
);
// Completed lengths (non-null lengthDays) drive the length stats.
const lengths = rows
+11 -9
View File
@@ -50,6 +50,7 @@ import {
sanitiseZodIssues,
} from "@/lib/api-response";
import { getAiCapability } from "@/lib/ai/capabilities/gate";
import { listPage } from "@/lib/http/list-query";
import { prisma } from "@/lib/db";
import { hashQueryTokens } from "@/lib/documents/content-index";
import {
@@ -710,16 +711,17 @@ export const GET = apiHandler(async (request: Request) => {
// Hardening: the list NEVER fetches the encrypted blob column — a page of
// 50 rows would otherwise drag up to 50 × cap ciphertext bytes per request.
const rows = await prisma.inboundDocument.findMany({
where,
omit: { contentEncrypted: true },
orderBy,
take: limit + 1,
...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}),
});
const { rows: page, cap } = await listPage(limit, (take) =>
prisma.inboundDocument.findMany({
where,
omit: { contentEncrypted: true },
orderBy,
take,
...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}),
}),
);
const hasMore = rows.length > limit;
const page = hasMore ? rows.slice(0, limit) : rows;
const hasMore = cap.truncated;
const nextCursor = hasMore ? (page[page.length - 1]?.id ?? null) : null;
// Fact tallies for the whole page in ONE grouped count — no per-document
+30 -22
View File
@@ -26,6 +26,8 @@ import {
safeJson,
} from "@/lib/api-response";
import { checkRecordWriteRateLimit } from "@/lib/rate-limit";
import { apiListSuccess } from "@/lib/api/list-cap";
import { combinedCap, listPage } from "@/lib/http/list-query";
import { withIdempotency } from "@/lib/idempotency";
import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
import {
@@ -109,27 +111,33 @@ export const GET = apiHandler(async (request: NextRequest) => {
// past reads newest-first, which is the opposite direction; resolving both
// here means the client renders two lists instead of splitting and
// re-sorting one, and the ordering lives in one place.
const [upcoming, past] = await Promise.all([
prisma.encounter.findMany({
// `gt` and `gte` are different keys, so the caller's lower bound and this
// arm's floor both survive the spread; only the `lte` pair collided.
where: { ...where, occurredAt: { ...where.occurredAt, gt: now } },
orderBy: [{ occurredAt: "asc" }, { id: "asc" }],
take,
skip,
include: ENCOUNTER_INCLUDE,
}),
prisma.encounter.findMany({
where: {
...where,
occurredAt: { ...where.occurredAt, lte: pastCeiling },
},
orderBy: [{ occurredAt: "desc" }, { id: "desc" }],
take,
skip,
include: ENCOUNTER_INCLUDE,
}),
]);
const [{ rows: upcoming, cap: upcomingCap }, { rows: past, cap: pastCap }] =
await Promise.all([
listPage(take, (overTake) =>
prisma.encounter.findMany({
// `gt` and `gte` are different keys, so the caller's lower bound and
// this arm's floor both survive the spread; only the `lte` pair
// collided.
where: { ...where, occurredAt: { ...where.occurredAt, gt: now } },
orderBy: [{ occurredAt: "asc" }, { id: "asc" }],
take: overTake,
skip,
include: ENCOUNTER_INCLUDE,
}),
),
listPage(take, (overTake) =>
prisma.encounter.findMany({
where: {
...where,
occurredAt: { ...where.occurredAt, lte: pastCeiling },
},
orderBy: [{ occurredAt: "desc" }, { id: "desc" }],
take: overTake,
skip,
include: ENCOUNTER_INCLUDE,
}),
),
]);
// The links ride the list. The card counts what a visit produced, and the
// edit sheet seeds its pickers from the row it was handed — a list without
@@ -152,7 +160,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
upcoming: upcoming.map((row) => toEncounterDTO(row, links.get(row.id))),
past: past.map((row) => toEncounterDTO(row, links.get(row.id))),
};
return apiSuccess(body);
return apiListSuccess(body, combinedCap(upcomingCap, pastCap));
});
// A double-tap on "save" must not file the same visit twice.
+9 -7
View File
@@ -28,7 +28,8 @@ import {
familyHistoryListQuerySchema,
} from "@/lib/validations/family-history";
import { toFamilyHistoryEntryDTO } from "@/lib/records/dto";
import { apiListSuccess, capRows, overfetch } from "@/lib/api/list-cap";
import { apiListSuccess } from "@/lib/api/list-cap";
import { listPage } from "@/lib/http/list-query";
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireRecordAuth("read", "profile");
@@ -44,12 +45,13 @@ export const GET = apiHandler(async (request: NextRequest) => {
}
const limit = parsed.data.limit ?? 100;
const found = await prisma.familyHistoryEntry.findMany({
where: { userId: user.id, deletedAt: null },
orderBy: { createdAt: "desc" },
take: overfetch(limit),
});
const { rows, cap } = capRows(found, limit);
const { rows, cap } = await listPage(limit, (take) =>
prisma.familyHistoryEntry.findMany({
where: { userId: user.id, deletedAt: null },
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
take,
}),
);
annotate({
action: { name: "family-history.list", entity_type: "family_history" },
+26 -22
View File
@@ -32,7 +32,8 @@ import {
illnessEpisodeListQuerySchema,
} from "@/lib/validations/illness";
import { toIllnessEpisodeDTO } from "@/lib/illness/dto";
import { apiListSuccess, capRows, overfetch } from "@/lib/api/list-cap";
import { apiListSuccess } from "@/lib/api/list-cap";
import { listPage } from "@/lib/http/list-query";
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireRecordAuth("read", "illness");
@@ -59,27 +60,30 @@ export const GET = apiHandler(async (request: NextRequest) => {
const { from, to, q, offset, sortDir } = parsed.data;
const limit = parsed.data.limit ?? 50;
const order = sortDir ?? "desc";
const found = await prisma.illnessEpisode.findMany({
where: {
userId: user.id,
deletedAt: null,
...(parsed.data.includeResolved === "false" ? { resolvedAt: null } : {}),
...(from || to
? {
onsetAt: {
...(from ? { gte: new Date(from) } : {}),
...(to ? { lte: new Date(to) } : {}),
},
}
: {}),
...(q ? { label: { contains: q, mode: "insensitive" as const } } : {}),
},
// `id` breaks ties so offset paging never repeats or drops a row.
orderBy: [{ onsetAt: order }, { id: order }],
take: overfetch(limit),
skip: offset ?? 0,
});
const { rows, cap } = capRows(found, limit);
const { rows, cap } = await listPage(limit, (take) =>
prisma.illnessEpisode.findMany({
where: {
userId: user.id,
deletedAt: null,
...(parsed.data.includeResolved === "false"
? { resolvedAt: null }
: {}),
...(from || to
? {
onsetAt: {
...(from ? { gte: new Date(from) } : {}),
...(to ? { lte: new Date(to) } : {}),
},
}
: {}),
...(q ? { label: { contains: q, mode: "insensitive" as const } } : {}),
},
// `id` breaks ties so offset paging never repeats or drops a row.
orderBy: [{ onsetAt: order }, { id: order }],
take,
skip: offset ?? 0,
}),
);
annotate({
action: { name: "illness.episode.list", entity_type: "illness_episode" },
+22 -20
View File
@@ -47,7 +47,8 @@ import { checkRateLimit } from "@/lib/rate-limit";
import { prisma } from "@/lib/db";
import { persistEcgRecording } from "@/lib/ecg/persist-recording";
import { isoDateTime } from "@/lib/validations/datetime";
import { apiListSuccess, capRows, overfetch } from "@/lib/api/list-cap";
import { apiListSuccess } from "@/lib/api/list-cap";
import { listPage } from "@/lib/http/list-query";
export const dynamic = "force-dynamic";
@@ -134,25 +135,26 @@ export const GET = apiHandler(async () => {
// sync.
const { user } = await requireRecordAuth("manage", "record");
const found = await prisma.ecgRecording.findMany({
where: { userId: user.id },
// Everything EXCEPT `waveformEncrypted` — the list never touches the
// encrypted blob, so no decrypt happens on this path.
select: {
id: true,
recordedAt: true,
durationSeconds: true,
samplingFrequency: true,
sampleCount: true,
averageHeartRate: true,
lead: true,
rhythmClassification: true,
source: true,
},
orderBy: { recordedAt: "desc" },
take: overfetch(MAX_RECORDINGS),
});
const { rows, cap } = capRows(found, MAX_RECORDINGS);
const { rows, cap } = await listPage(MAX_RECORDINGS, (take) =>
prisma.ecgRecording.findMany({
where: { userId: user.id },
// Everything EXCEPT `waveformEncrypted` — the list never touches the
// encrypted blob, so no decrypt happens on this path.
select: {
id: true,
recordedAt: true,
durationSeconds: true,
samplingFrequency: true,
sampleCount: true,
averageHeartRate: true,
lead: true,
rhythmClassification: true,
source: true,
},
orderBy: [{ recordedAt: "desc" }, { id: "desc" }],
take,
}),
);
const recordings = rows.map((r) => ({
id: r.id,
+21 -19
View File
@@ -29,7 +29,8 @@ import { annotate } from "@/lib/logging/context";
import { prisma } from "@/lib/db";
import { EVENT_MEASUREMENT_TYPES } from "@/lib/validations/measurement";
import type { MeasurementType } from "@/generated/prisma/client";
import { apiListSuccess, capRows, overfetch } from "@/lib/api/list-cap";
import { apiListSuccess } from "@/lib/api/list-cap";
import { listPage } from "@/lib/http/list-query";
export const dynamic = "force-dynamic";
@@ -49,24 +50,25 @@ export const GET = apiHandler(async () => {
// The `insights` module is the AI analysis opt-out, and nothing here was
// written by a model.
const found = await prisma.measurement.findMany({
where: {
userId: user.id,
type: { in: EVENT_TYPE_LIST },
deletedAt: null,
},
select: {
id: true,
type: true,
rhythmClassification: true,
measuredAt: true,
source: true,
deviceType: true,
},
orderBy: { measuredAt: "desc" },
take: overfetch(MAX_EVENTS),
});
const { rows, cap } = capRows(found, MAX_EVENTS);
const { rows, cap } = await listPage(MAX_EVENTS, (take) =>
prisma.measurement.findMany({
where: {
userId: user.id,
type: { in: EVENT_TYPE_LIST },
deletedAt: null,
},
select: {
id: true,
type: true,
rhythmClassification: true,
measuredAt: true,
source: true,
deviceType: true,
},
orderBy: [{ measuredAt: "desc" }, { id: "desc" }],
take,
}),
);
const events = rows.map((r) => ({
id: r.id,
@@ -39,7 +39,8 @@ import { categoryForEntry } from "@/lib/medications/side-effects/taxonomy";
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
import { encryptNote, shapeSideEffectNotes } from "@/lib/crypto/note-cipher";
import { withIdempotency } from "@/lib/idempotency";
import { apiListSuccess, capRows, overfetch } from "@/lib/api/list-cap";
import { apiListSuccess } from "@/lib/api/list-cap";
import { listPage } from "@/lib/http/list-query";
type RouteParams = { params: Promise<{ id: string }> };
@@ -67,23 +68,24 @@ export const GET = apiHandler(
const { from, to, limit } = parsed.data;
const found = await prisma.medicationSideEffect.findMany({
where: {
userId: user.id,
medicationId: id,
...(from || to
? {
occurredAt: {
...(from ? { gte: from } : {}),
...(to ? { lt: to } : {}),
},
}
: {}),
},
orderBy: { occurredAt: "desc" },
take: overfetch(limit),
});
const { rows: items, cap } = capRows(found, limit);
const { rows: items, cap } = await listPage(limit, (take) =>
prisma.medicationSideEffect.findMany({
where: {
userId: user.id,
medicationId: id,
...(from || to
? {
occurredAt: {
...(from ? { gte: from } : {}),
...(to ? { lt: to } : {}),
},
}
: {}),
},
orderBy: [{ occurredAt: "desc" }, { id: "desc" }],
take,
}),
);
annotate({
action: { name: "medication.sideEffect.list" },
+30 -28
View File
@@ -57,7 +57,8 @@ import type {
MeasurementType,
MeasurementSource,
} from "@/generated/prisma/client";
import { apiListSuccess, capRows, overfetch } from "@/lib/api/list-cap";
import { apiListSuccess } from "@/lib/api/list-cap";
import { listPage } from "@/lib/http/list-query";
function shapeRow(row: {
id: string;
@@ -120,33 +121,34 @@ export const GET = apiHandler(async (request: NextRequest) => {
}
const { instrument, limit, offset } = parsed.data;
const pageSize = limit ?? 100;
const found = await prisma.mentalHealthAssessment.findMany({
where: {
userId: user.id,
deletedAt: null,
// Screeners only: the table also stores AUDIT-C (v1.43), which older
// app builds cannot decode in this list.
instrument: instrument ?? { in: [...SCREENER_INSTRUMENTS] },
},
// Unique tiebreaker — `takenAt` is day-resolution and two instruments
// taken in one sitting tie.
orderBy: [{ takenAt: "desc" }, { id: "desc" }],
take: overfetch(pageSize),
skip: offset ?? 0,
select: {
id: true,
instrument: true,
locale: true,
version: true,
totalScore: true,
severityBand: true,
item9Flagged: true,
crisisShownAt: true,
takenAt: true,
createdAt: true,
},
});
const { rows, cap } = capRows(found, pageSize);
const { rows, cap } = await listPage(pageSize, (take) =>
prisma.mentalHealthAssessment.findMany({
where: {
userId: user.id,
deletedAt: null,
// Screeners only: the table also stores AUDIT-C (v1.43), which older
// app builds cannot decode in this list.
instrument: instrument ?? { in: [...SCREENER_INSTRUMENTS] },
},
// Unique tiebreaker — `takenAt` is day-resolution and two instruments
// taken in one sitting tie.
orderBy: [{ takenAt: "desc" }, { id: "desc" }],
take,
skip: offset ?? 0,
select: {
id: true,
instrument: true,
locale: true,
version: true,
totalScore: true,
severityBand: true,
item9Flagged: true,
crisisShownAt: true,
takenAt: true,
createdAt: true,
},
}),
);
annotate({
action: { name: "mental-health.list" },
meta: { count: rows.length },
@@ -66,7 +66,7 @@ describe("GET /api/personal-records", () => {
expect(body.error).toBeNull();
expect(prisma.personalRecord.findMany).toHaveBeenCalledWith({
where: { userId: "user-1" },
orderBy: { achievedAt: "desc" },
orderBy: [{ achievedAt: "desc" }, { id: "desc" }],
// One row past the limit, so the response can say it was cut.
take: 101,
});
@@ -80,7 +80,7 @@ describe("GET /api/personal-records", () => {
expect(res.status).toBe(200);
expect(prisma.personalRecord.findMany).toHaveBeenCalledWith({
where: { userId: "user-1", metricType: "VO2_MAX" },
orderBy: { achievedAt: "desc" },
orderBy: [{ achievedAt: "desc" }, { id: "desc" }],
// One row past the limit, so the response can say it was cut.
take: 101,
});
@@ -114,7 +114,7 @@ describe("GET /api/personal-records", () => {
expect(res.status).toBe(200);
expect(prisma.personalRecord.findMany).toHaveBeenCalledWith({
where: { userId: "user-1" },
orderBy: { achievedAt: "desc" },
orderBy: [{ achievedAt: "desc" }, { id: "desc" }],
// One row past the limit, so the response can say it was cut.
take: 26,
});
@@ -128,7 +128,7 @@ describe("GET /api/personal-records", () => {
expect(res.status).toBe(200);
expect(prisma.personalRecord.findMany).toHaveBeenCalledWith({
where: { userId: "user-1" },
orderBy: { achievedAt: "desc" },
orderBy: [{ achievedAt: "desc" }, { id: "desc" }],
// One row past the limit, so the response can say it was cut.
take: 501,
});
+12 -10
View File
@@ -38,7 +38,8 @@ import {
resolveRecordBenchmark,
} from "@/lib/personal-records/record-benchmark";
import { toProfileSex } from "@/lib/profile/sex";
import { apiListSuccess, capRows, overfetch } from "@/lib/api/list-cap";
import { apiListSuccess } from "@/lib/api/list-cap";
import { listPage } from "@/lib/http/list-query";
export const dynamic = "force-dynamic";
@@ -63,15 +64,16 @@ export const GET = apiHandler(async (request: NextRequest) => {
}
const { metricType, limit } = parsed.data;
const found = await prisma.personalRecord.findMany({
where: {
userId: user.id,
...(metricType ? { metricType } : {}),
},
orderBy: { achievedAt: "desc" },
take: overfetch(limit),
});
const { rows: records, cap } = capRows(found, limit);
const { rows: records, cap } = await listPage(limit, (take) =>
prisma.personalRecord.findMany({
where: {
userId: user.id,
...(metricType ? { metricType } : {}),
},
orderBy: [{ achievedAt: "desc" }, { id: "desc" }],
take,
}),
);
const wantsBand = records.some((r) => mayCarryBenchmark(r.metricType));
const profile = wantsBand
+44 -36
View File
@@ -33,6 +33,8 @@ import {
practitionerListQuerySchema,
} from "@/lib/validations/practitioners";
import { toPractitionerDTO } from "@/lib/practitioners/dto";
import { apiListSuccess } from "@/lib/api/list-cap";
import { listPage } from "@/lib/http/list-query";
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireRecordAuth("read", "profile");
@@ -41,6 +43,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
const parsed = practitionerListQuerySchema.safeParse({
q: params.get("q") ?? undefined,
limit: params.get("limit") ?? undefined,
offset: params.get("offset") ?? undefined,
});
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422, {
@@ -48,50 +51,55 @@ export const GET = apiHandler(async (request: NextRequest) => {
});
}
const rows = await prisma.practitioner.findMany({
where: {
userId: user.id,
deletedAt: null,
// Three plaintext columns, because a person looks a practice up by
// whichever they remember: the doctor's name on the referral, the
// practice name on the door, or the field they need ("Zahnmedizin").
// Searching only name + practice made the specialty attempt return
// nothing and read as "not in the address book".
...(parsed.data.q
? {
OR: [
{
name: {
contains: parsed.data.q,
mode: "insensitive" as const,
const limit = parsed.data.limit ?? 100;
const { rows, cap } = await listPage(limit, (take) =>
prisma.practitioner.findMany({
where: {
userId: user.id,
deletedAt: null,
// Three plaintext columns, because a person looks a practice up by
// whichever they remember: the doctor's name on the referral, the
// practice name on the door, or the field they need ("Zahnmedizin").
// Searching only name + practice made the specialty attempt return
// nothing and read as "not in the address book".
...(parsed.data.q
? {
OR: [
{
name: {
contains: parsed.data.q,
mode: "insensitive" as const,
},
},
},
{
practice: {
contains: parsed.data.q,
mode: "insensitive" as const,
{
practice: {
contains: parsed.data.q,
mode: "insensitive" as const,
},
},
},
{
specialty: {
contains: parsed.data.q,
mode: "insensitive" as const,
{
specialty: {
contains: parsed.data.q,
mode: "insensitive" as const,
},
},
},
],
}
: {}),
},
orderBy: { name: "asc" },
take: parsed.data.limit ?? 100,
});
],
}
: {}),
},
// Two contacts can share a name; the id keeps the page boundary stable.
orderBy: [{ name: "asc" }, { id: "asc" }],
skip: parsed.data.offset ?? 0,
take,
}),
);
annotate({
action: { name: "practitioner.contact.list", entity_type: "practitioner" },
meta: { count: rows.length },
meta: { count: rows.length, truncated: cap.truncated },
});
return apiSuccess(rows.map(toPractitionerDTO));
return apiListSuccess(rows.map(toPractitionerDTO), cap);
});
// A double-tap on "save" must not mint a second copy of the same practice.
+18 -11
View File
@@ -15,6 +15,8 @@
*/
import { NextRequest } from "next/server";
import { listPage } from "@/lib/http/list-query";
import { prisma } from "@/lib/db";
import {
apiSuccess,
@@ -65,16 +67,18 @@ export const GET = apiHandler(async (request: NextRequest) => {
if (q.from) occurredAt.gte = new Date(q.from);
if (q.to) occurredAt.lte = new Date(q.to);
const rows = await prisma.symptomEvent.findMany({
where: {
userId: user.id,
...(q.from || q.to ? { occurredAt } : {}),
...(q.definitionId ? { definitionId: q.definitionId } : {}),
...(q.episodeId ? { episodeId: q.episodeId } : {}),
},
orderBy: [{ occurredAt: "desc" }, { id: "desc" }],
take: q.limit,
});
const { rows, cap } = await listPage(q.limit, (take) =>
prisma.symptomEvent.findMany({
where: {
userId: user.id,
...(q.from || q.to ? { occurredAt } : {}),
...(q.definitionId ? { definitionId: q.definitionId } : {}),
...(q.episodeId ? { episodeId: q.episodeId } : {}),
},
orderBy: [{ occurredAt: "desc" }, { id: "desc" }],
take,
}),
);
annotate({
action: { name: "symptoms.event.list" },
@@ -84,7 +88,10 @@ export const GET = apiHandler(async (request: NextRequest) => {
return apiSuccess({
events: rows.map(toSymptomEventDTO),
limit: q.limit,
hasMore: rows.length === q.limit,
// Exact: true only when a row past this page exists. `rows.length ===
// limit` said true on a page that was exactly full, and the client then
// asked for a page that held nothing.
hasMore: cap.truncated,
});
});
+30 -22
View File
@@ -31,6 +31,8 @@ import {
} from "@/lib/api-response";
import { checkRecordWriteRateLimit } from "@/lib/rate-limit";
import { withIdempotency } from "@/lib/idempotency";
import { apiListSuccess } from "@/lib/api/list-cap";
import { listPage } from "@/lib/http/list-query";
import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
import {
vaccinationCreateSchema,
@@ -62,6 +64,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
from: params.get("from") ?? undefined,
to: params.get("to") ?? undefined,
limit: params.get("limit") ?? undefined,
offset: params.get("offset") ?? undefined,
});
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422, {
@@ -70,29 +73,34 @@ export const GET = apiHandler(async (request: NextRequest) => {
}
const query = parsed.data;
// Full and bounded rather than paged: an Impfpass is a lifetime document but
// a small one, and the default already covers a long life.
// A lifetime document but a small one: the default page already covers a
// long life, and `meta.truncated` says when it does not.
const take = query.limit ?? 300;
const [rows, series] = await Promise.all([
prisma.vaccinationRecord.findMany({
where: {
userId: user.id,
deletedAt: null,
...(query.antigenSlug ? { antigenSlug: query.antigenSlug } : {}),
...(query.from || query.to
? {
occurredAt: {
...(query.from ? { gte: new Date(query.from) } : {}),
...(query.to ? { lte: new Date(query.to) } : {}),
},
}
: {}),
},
orderBy: { occurredAt: "desc" },
take,
include: VACCINATION_INCLUDE,
}),
const [{ rows, cap }, series] = await Promise.all([
listPage(take, (overTake) =>
prisma.vaccinationRecord.findMany({
where: {
userId: user.id,
deletedAt: null,
...(query.antigenSlug ? { antigenSlug: query.antigenSlug } : {}),
...(query.from || query.to
? {
occurredAt: {
...(query.from ? { gte: new Date(query.from) } : {}),
...(query.to ? { lte: new Date(query.to) } : {}),
},
}
: {}),
},
// Two doses on one day are common (a combined appointment); the id
// keeps the page boundary between them stable.
orderBy: [{ occurredAt: "desc" }, { id: "desc" }],
skip: query.offset ?? 0,
take: overTake,
include: VACCINATION_INCLUDE,
}),
),
// Over the WHOLE live set, never over the filtered page: a position is a
// count of what came before it, so deriving it from a window would report
// the oldest dose in that window as the first one ever given.
@@ -135,7 +143,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
),
renewals,
};
return apiSuccess(body);
return apiListSuccess(body, cap);
});
// A double-tap on "save" must not log the same dose twice.
@@ -14,7 +14,13 @@
*/
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { apiDelete, apiGet, apiPatch, apiPost } from "@/lib/api/api-fetch";
import {
apiDelete,
apiGet,
apiGetList,
apiPatch,
apiPost,
} from "@/lib/api/api-fetch";
import { invalidateReminderReads } from "@/hooks/use-measurement-reminders";
import {
invalidateKeys,
@@ -52,6 +58,7 @@ export interface VaccinationWriteBody {
}
const BASE = "/api/vaccinations";
const VACCINATION_PAGE_SIZE = 300;
/**
* The account's immunization log, newest dose first.
@@ -64,12 +71,33 @@ export function useVaccinations(antigenSlug?: string | null, enabled = true) {
return useQuery({
queryKey: queryKeys.vaccinationList(antigenSlug ?? null),
enabled,
queryFn: () =>
apiGet<VaccinationListDTO>(
antigenSlug
? `${BASE}?antigenSlug=${encodeURIComponent(antigenSlug)}`
: BASE,
),
// The route pages and says whether another page exists; the log is read
// to the end. `renewals` is the same on every page, so the first is kept.
queryFn: async () => {
const page = (offset: number) => {
const sp = new URLSearchParams({
limit: String(VACCINATION_PAGE_SIZE),
offset: String(offset),
});
if (antigenSlug) sp.set("antigenSlug", antigenSlug);
return apiGetList<VaccinationListDTO>(
`/api/vaccinations?${sp.toString()}`,
);
};
const first = await page(0);
const vaccinations = [...first.data.vaccinations];
let cap = first.cap;
for (
let offset = VACCINATION_PAGE_SIZE;
cap?.truncated;
offset += VACCINATION_PAGE_SIZE
) {
const next = await page(offset);
vaccinations.push(...next.data.vaccinations);
cap = next.cap;
}
return { ...first.data, vaccinations };
},
});
}
+29 -9
View File
@@ -19,7 +19,13 @@ import {
useQueryClient,
} from "@tanstack/react-query";
import { apiDelete, apiGet, apiPatch, apiPost } from "@/lib/api/api-fetch";
import {
apiDelete,
apiGet,
apiGetList,
apiPatch,
apiPost,
} from "@/lib/api/api-fetch";
import { invalidateReminderReads } from "@/hooks/use-measurement-reminders";
import {
encounterDependentKeys,
@@ -53,6 +59,7 @@ export interface EncounterWriteBody {
}
const BASE = "/api/encounters";
const ENCOUNTER_PAGE_SIZE = 200;
export function useEncounters(
enabled = true,
@@ -73,14 +80,27 @@ export function useEncounters(
window?.status,
window?.episodeId,
),
queryFn: () => {
const sp = new URLSearchParams();
if (from) sp.set("from", from);
if (to) sp.set("to", to);
if (window?.status) sp.set("status", window.status);
if (window?.episodeId) sp.set("episodeId", window.episodeId);
const qs = sp.toString();
return apiGet<EncounterList>(qs ? `${BASE}?${qs}` : BASE);
// `limit` and `offset` apply to both arms and the route says whether
// either has rows past the page, so this reads to the end: a visit list
// that stopped at the first page showed an old visit as missing.
queryFn: async () => {
const list: EncounterList = { upcoming: [], past: [] };
for (let offset = 0; ; offset += ENCOUNTER_PAGE_SIZE) {
const sp = new URLSearchParams({
limit: String(ENCOUNTER_PAGE_SIZE),
offset: String(offset),
});
if (from) sp.set("from", from);
if (to) sp.set("to", to);
if (window?.status) sp.set("status", window.status);
if (window?.episodeId) sp.set("episodeId", window.episodeId);
const { data: page, cap } = await apiGetList<EncounterList>(
`/api/encounters?${sp.toString()}`,
);
list.upcoming.push(...page.upcoming);
list.past.push(...page.past);
if (!cap?.truncated) return list;
}
},
enabled,
});
+19 -5
View File
@@ -10,7 +10,7 @@
*/
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { apiDelete, apiGet, apiPatch, apiPost } from "@/lib/api/api-fetch";
import { apiDelete, apiGetList, apiPatch, apiPost } from "@/lib/api/api-fetch";
import {
encounterDependentKeys,
invalidateKeys,
@@ -31,15 +31,29 @@ export interface PractitionerWriteBody {
}
const BASE = "/api/practitioners";
const PRACTITIONER_PAGE_SIZE = 200;
export function usePractitioners(q?: string, enabled = true) {
const term = q?.trim() || undefined;
return useQuery({
queryKey: queryKeys.practitionerList(term),
queryFn: () =>
apiGet<Practitioner[]>(
term ? `${BASE}?q=${encodeURIComponent(term)}` : BASE,
),
// The route pages and says whether another page exists; the pickers and
// the address book read this as the whole list, so it walks to the end.
queryFn: async () => {
const all: Practitioner[] = [];
for (let offset = 0; ; offset += PRACTITIONER_PAGE_SIZE) {
const sp = new URLSearchParams({
limit: String(PRACTITIONER_PAGE_SIZE),
offset: String(offset),
});
if (term) sp.set("q", term);
const { data: page, cap } = await apiGetList<Practitioner[]>(
`/api/practitioners?${sp.toString()}`,
);
all.push(...page);
if (!cap?.truncated) return all;
}
},
enabled,
});
}
+77
View File
@@ -0,0 +1,77 @@
import { describe, expect, it, vi } from "vitest";
import { combinedCap, listPage, parseListQuery } from "../list-query";
describe("listPage", () => {
it("asks for one row past the limit and reports a cut only when that row exists", async () => {
const read = vi.fn(async (take: number) =>
Array.from({ length: Math.min(take, 5) }, (_, i) => i),
);
const cut = await listPage(3, read);
expect(read).toHaveBeenLastCalledWith(4);
expect(cut.rows).toEqual([0, 1, 2]);
expect(cut.cap).toEqual({ truncated: true, limit: 3 });
// Exactly full is complete: five rows, a limit of five.
const full = await listPage(5, read);
expect(full.rows).toHaveLength(5);
expect(full.cap).toEqual({ truncated: false, limit: 5 });
const short = await listPage(9, read);
expect(short.rows).toHaveLength(5);
expect(short.cap.truncated).toBe(false);
});
});
describe("combinedCap", () => {
it("is truncated when any list was cut", () => {
expect(
combinedCap(
{ truncated: false, limit: 50 },
{ truncated: true, limit: 50 },
),
).toEqual({ truncated: true, limit: 50 });
expect(
combinedCap(
{ truncated: false, limit: 50 },
{ truncated: false, limit: 50 },
).truncated,
).toBe(false);
});
});
describe("parseListQuery", () => {
const options = { defaultLimit: 50, maxLimit: 200, offset: true };
it("applies the default and reads both fields", () => {
expect(parseListQuery(new URLSearchParams(), options).data).toEqual({
limit: 50,
offset: 0,
});
expect(
parseListQuery(new URLSearchParams("limit=10&offset=20"), options).data,
).toEqual({ limit: 10, offset: 20 });
});
it("refuses a limit past the ceiling and a negative offset, both at once", () => {
const parsed = parseListQuery(
new URLSearchParams("limit=500&offset=-1"),
options,
);
expect(parsed.success).toBe(false);
expect(parsed.error?.issues.map((i) => i.path[0]).sort()).toEqual([
"limit",
"offset",
]);
});
it("ignores offset on a route that does not page", () => {
expect(
parseListQuery(new URLSearchParams("offset=20"), {
defaultLimit: 50,
maxLimit: 200,
}).data,
).toEqual({ limit: 50, offset: 0 });
});
});
+24
View File
@@ -0,0 +1,24 @@
/**
* The `limit` and `offset` query fields of a paged list, safe for any bundle.
*
* Kept apart from `./list-query.ts` (which reaches the response envelope) so a
* request schema in `src/lib/validations/` can use them without pulling server
* code into a client that imports the same schema.
*/
import { z } from "zod";
/** The `limit` field, for a route whose query schema has other fields too. */
export function listLimitField({
defaultLimit,
maxLimit,
}: {
defaultLimit: number;
maxLimit: number;
}) {
return z.coerce.number().int().min(1).max(maxLimit).default(defaultLimit);
}
/** The `offset` field, for a route whose query schema has other fields too. */
export function listOffsetField() {
return z.coerce.number().int().min(0).max(100_000).default(0);
}
+92
View File
@@ -0,0 +1,92 @@
/**
* One way to read a page of a list, and one way to say whether it was cut.
*
* The list routes had grown five answers to "is there more": `meta.truncated`
* from `src/lib/api/list-cap.ts`, a `hasMore` computed as `rows.length ===
* limit` (true on a list that is exactly full, so a client asks for a page
* that does not exist), a `total` from a second count query, a sync cursor,
* and nothing at all. This module is where a capped or paged list gets its
* page, so the signal is computed the same way everywhere:
*
* - {@link parseListQuery} reads `limit` (and `offset`, where the route
* pages) with the route's own default and ceiling;
* - {@link listPage} runs the read for one row past the limit and cuts it
* back, so `cap.truncated` is exact: true only when a row past the page
* exists.
*
* The cap is the one in `list-cap.ts` — this module adds no second signal.
* A route answers with `apiListSuccess(data, cap)` (the envelope's `meta`),
* or, where a route already published a `hasMore` in its body, derives that
* field from `cap.truncated`.
*
* Every read through here must have a total order: the `orderBy` ends in the
* row id, so a page boundary between two rows that share a timestamp does not
* repeat one of them on the next page or drop it. `list-page-guard.test.ts`
* holds every capped list to this module and to that tiebreaker.
*/
import { z } from "zod";
import { capRows, overfetch, type ListCap } from "@/lib/api/list-cap";
import { listLimitField, listOffsetField } from "./list-query-fields";
export { listLimitField, listOffsetField } from "./list-query-fields";
export interface ListQueryOptions {
/** The limit when the request names none. */
defaultLimit: number;
/** The largest limit a request may ask for. */
maxLimit: number;
/** Whether the route pages with `offset`. */
offset?: boolean;
}
export interface ListQuery {
limit: number;
offset: number;
}
/**
* Read `limit` (and `offset`) from a request's query string.
*
* Returns the Zod result, so the route answers a bad value with the same
* multi-issue 422 it gives every other field.
*/
export function parseListQuery(
params: URLSearchParams,
options: ListQueryOptions,
) {
const schema = z.object({
limit: listLimitField(options),
offset: options.offset ? listOffsetField() : z.literal(0).default(0),
});
return schema.safeParse({
limit: params.get("limit") ?? undefined,
offset: options.offset ? (params.get("offset") ?? undefined) : undefined,
});
}
/**
* Run a list read for one page and say whether rows past it exist.
*
* `read` receives the `take` to pass to Prisma (one past `limit`); the rows
* come back cut to `limit` with the exact cap beside them.
*/
export async function listPage<T>(
limit: number,
read: (take: number) => Promise<readonly T[]>,
): Promise<{ rows: T[]; cap: ListCap }> {
return capRows(await read(overfetch(limit)), limit);
}
/**
* The cap of a response that carries several capped lists at once (the
* encounters' upcoming and past arms, the grants given and received): more
* rows exist when any one of them was cut.
*/
export function combinedCap(...caps: ListCap[]): ListCap {
return {
truncated: caps.some((cap) => cap.truncated),
limit: Math.max(...caps.map((cap) => cap.limit)),
};
}
+1
View File
@@ -392,6 +392,7 @@ export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
],
route: ["route.retired"],
search: ["search.invalid"],
security_activity: ["security_activity.invalid"],
"share-link": [
"share-link.document.decryptFailed",
"share-link.document.notFound",
+4 -2
View File
@@ -1708,7 +1708,7 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["Auth"],
summary: "List recent account-security activity",
description:
"v1.23 — the SHARED security-activity feed: the caller's recent auth + export + deletion audit events with timestamp, resolved location, and a host-masked IP. `limit` query param caps at 100 (default 50). Reuses the AuditLog store; no event detail bodies are surfaced.",
"v1.23 — the SHARED security-activity feed: the caller's recent auth + export + deletion audit events with timestamp, resolved location, and a host-masked IP. `limit` is 1-100 (default 50); a value outside that is a 422 naming it. Reuses the AuditLog store; no event detail bodies are surfaced.",
requestParams: {
query: z.object({
limit: z.coerce
@@ -1717,7 +1717,9 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
.min(1)
.max(100)
.optional()
.describe("Page size; defaults to 50, clamped to 100."),
.describe(
"Page size, 1-100; defaults to 50. Out of range is a 422.",
),
}),
},
responses: {
+5 -4
View File
@@ -45,6 +45,7 @@ import {
errorEnvelope,
idempotencyKeyParameter,
idempotentWrite,
listEnvelope,
recordRefusal,
recordWriteRateLimitResponse,
stdResponses,
@@ -346,7 +347,7 @@ export const encounterPaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["Records"],
summary: "List visits",
description:
"Returns the caller's live visits, split into `upcoming` and `past`. Bounded and windowed; soft-deleted visits are excluded.",
"Returns the caller's live visits, split into `upcoming` (soonest first) and `past` (newest first). `limit` and `offset` apply to each list; `meta.truncated` is true when either list has rows past this page, so a client reads the next `offset` until it is false. Soft-deleted visits are excluded.",
requestParams: { query: listEncountersQuery },
responses: {
...recordRefusal(),
@@ -354,7 +355,7 @@ export const encounterPaths: NonNullable<ZodOpenApiObject["paths"]> = {
description: "The caller's visits.",
content: {
"application/json": {
schema: dataEnvelope(encounterList, "ListEncountersEnvelope"),
schema: listEnvelope(encounterList, "ListEncountersEnvelope"),
},
},
},
@@ -612,7 +613,7 @@ export const encounterPaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["Records"],
summary: "List practitioners",
description:
"Returns the caller's address book, name-ordered, with an optional case-insensitive name search.",
"Returns the caller's address book, name-ordered, with an optional case-insensitive search over name, practice and specialty. Paged with `limit` and `offset`; `meta.truncated` says whether another page exists.",
requestParams: { query: listPractitionersQuery },
responses: {
...recordRefusal(),
@@ -620,7 +621,7 @@ export const encounterPaths: NonNullable<ZodOpenApiObject["paths"]> = {
description: "The caller's address book.",
content: {
"application/json": {
schema: dataEnvelope(
schema: listEnvelope(
z.array(practitioner),
"ListPractitionersEnvelope",
),
+1 -1
View File
@@ -119,7 +119,7 @@ const symptomEventList = z
.meta({
id: "SymptomEventList",
description:
"A page of occurrences, newest first. `hasMore` is true when the page is full; page on with `to` set to the oldest `occurredAt` seen.",
"A page of occurrences, newest first. `hasMore` is true exactly when an occurrence past this page exists; page on with `to` set to the oldest `occurredAt` seen.",
});
const deletedResponse = z
+3 -2
View File
@@ -43,6 +43,7 @@ import {
recordRefusal,
recordWriteRateLimitResponse,
stdResponses,
listEnvelope,
} from "./shared";
import { isoDateTime } from "@/lib/validations/datetime";
@@ -413,7 +414,7 @@ export const vaccinationPaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["Records"],
summary: "List vaccinations",
description:
"Returns the caller's live doses, newest first, each with its series resolved per component antigen. Soft-deleted doses are excluded.",
"Returns the caller's live doses, newest first, each with its series resolved per component antigen over the whole record, never over the page. Paged with `limit` (default 300) and `offset`; `meta.truncated` says whether another page exists. Soft-deleted doses are excluded.",
requestParams: { query: listVaccinationsQuery },
responses: {
...recordRefusal(),
@@ -421,7 +422,7 @@ export const vaccinationPaths: NonNullable<ZodOpenApiObject["paths"]> = {
description: "The caller's immunization history.",
content: {
"application/json": {
schema: dataEnvelope(vaccinationList, "ListVaccinationsEnvelope"),
schema: listEnvelope(vaccinationList, "ListVaccinationsEnvelope"),
},
},
},
+7
View File
@@ -10,6 +10,8 @@
*/
import { z } from "zod/v4";
import { listOffsetField } from "@/lib/http/list-query-fields";
export const practitionerCreateSchema = z
.object({
name: z.string().min(1).max(160),
@@ -50,6 +52,11 @@ export const practitionerListQuerySchema = z
.object({
q: z.string().min(1).max(160).optional(),
limit: z.coerce.number().int().min(1).max(200).optional(),
offset: listOffsetField()
.optional()
.describe(
"Rows to skip, for reading the address book page by page; `meta.truncated` says whether another page exists.",
),
})
.strict();
+6
View File
@@ -24,6 +24,7 @@
* moment the catalogue is authoritative, because the picker produced it.
*/
import { z } from "zod/v4";
import { listOffsetField } from "@/lib/http/list-query-fields";
import {
ANTIGEN_SLUGS,
@@ -195,6 +196,11 @@ export const vaccinationListQuerySchema = z
from: isoDateTime().optional(),
to: isoDateTime().optional(),
limit: z.coerce.number().int().min(1).max(500).optional(),
offset: listOffsetField()
.optional()
.describe(
"Doses to skip, for reading the log page by page; `meta.truncated` says whether another page exists.",
),
})
.strict()
.refine((q) => !(q.from && q.to) || q.from <= q.to, {
+28
View File
@@ -325,6 +325,34 @@ describe("symptom occurrences", () => {
).toBe(0);
});
it("says hasMore only when an occurrence past the page exists", async () => {
const id = await defineSymptom("Headache");
for (const occurredAt of [
"2026-09-01T09:30:00.000Z",
"2026-09-02T09:30:00.000Z",
]) {
expect(
(await logEvent({ definitionId: id, intensity: 3, occurredAt })).status,
).toBe(201);
}
const { GET } = await import("@/app/api/symptoms/events/route");
const page = async (limit: number) =>
(
await json<Envelope<{ events: unknown[]; hasMore: boolean }>>(
await call(GET, "GET", `/api/symptoms/events?limit=${limit}`),
)
).data;
// A page that is exactly full is the whole list: no page follows it.
const full = await page(2);
expect(full.events).toHaveLength(2);
expect(full.hasMore).toBe(false);
const cut = await page(1);
expect(cut.events).toHaveLength(1);
expect(cut.hasMore).toBe(true);
});
it("refuses an intensity outside 0-10 and a time in the future", async () => {
const id = await defineSymptom("Aura");
expect((await logEvent({ definitionId: id, intensity: 11 })).status).toBe(