From 8db7c71b0e6efa19a09b29782a2b0beef9c280cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 20 Oct 2026 13:29:14 +0200 Subject: [PATCH] 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. --- docs/api/openapi.yaml | 100 +++++---- src/__tests__/list-page-guard.test.ts | 190 ++++++++++++++++++ src/app/api/account/activity/route.ts | 53 ++--- src/app/api/account/grants/route.ts | 49 ++--- src/app/api/allergies/route.ts | 26 ++- .../security-activity/__tests__/route.test.ts | 68 +++++++ .../api/auth/me/security-activity/route.ts | 50 +++-- src/app/api/cycle/cycles/route.ts | 16 +- src/app/api/documents/inbound/route.ts | 20 +- src/app/api/encounters/route.ts | 52 +++-- src/app/api/family-history/route.ts | 16 +- src/app/api/illness/episodes/route.ts | 48 +++-- src/app/api/insights/ecg/route.ts | 42 ++-- src/app/api/insights/rhythm-events/route.ts | 40 ++-- .../medications/[id]/side-effects/route.ts | 38 ++-- .../api/mental-health/assessments/route.ts | 58 +++--- .../personal-records/__tests__/route.test.ts | 8 +- src/app/api/personal-records/route.ts | 22 +- src/app/api/practitioners/route.ts | 80 ++++---- src/app/api/symptoms/events/route.ts | 29 ++- src/app/api/vaccinations/route.ts | 52 +++-- .../vaccinations/use-vaccinations.ts | 42 +++- src/hooks/use-encounters.ts | 38 +++- src/hooks/use-practitioners.ts | 24 ++- src/lib/http/__tests__/list-query.test.ts | 77 +++++++ src/lib/http/list-query-fields.ts | 24 +++ src/lib/http/list-query.ts | 92 +++++++++ src/lib/openapi/error-codes.ts | 1 + src/lib/openapi/routes/auth.ts | 6 +- src/lib/openapi/routes/encounters.ts | 9 +- src/lib/openapi/routes/symptoms.ts | 2 +- src/lib/openapi/routes/vaccinations.ts | 5 +- src/lib/validations/practitioners.ts | 7 + src/lib/validations/vaccinations.ts | 6 + tests/integration/symptoms.test.ts | 28 +++ 35 files changed, 1024 insertions(+), 394 deletions(-) create mode 100644 src/__tests__/list-page-guard.test.ts create mode 100644 src/app/api/auth/me/security-activity/__tests__/route.test.ts create mode 100644 src/lib/http/__tests__/list-query.test.ts create mode 100644 src/lib/http/list-query-fields.ts create mode 100644 src/lib/http/list-query.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index a913d754f..380c0a6cb 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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: diff --git a/src/__tests__/list-page-guard.test.ts b/src/__tests__/list-page-guard.test.ts new file mode 100644 index 000000000..bdd57b8fb --- /dev/null +++ b/src/__tests__/list-page-guard.test.ts @@ -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([]); + }); +}); diff --git a/src/app/api/account/activity/route.ts b/src/app/api/account/activity/route.ts index 365584600..bade35a9c 100644 --- a/src/app/api/account/activity/route.ts +++ b/src/app/api/account/activity/route.ts @@ -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)), diff --git a/src/app/api/account/grants/route.ts b/src/app/api/account/grants/route.ts index 2851c662e..74663d2e0 100644 --- a/src/app/api/account/grants/route.ts +++ b/src/app/api/account/grants/route.ts @@ -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)), diff --git a/src/app/api/allergies/route.ts b/src/app/api/allergies/route.ts index f184c3e3e..5f5436ef8 100644 --- a/src/app/api/allergies/route.ts +++ b/src/app/api/allergies/route.ts @@ -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" }, diff --git a/src/app/api/auth/me/security-activity/__tests__/route.test.ts b/src/app/api/auth/me/security-activity/__tests__/route.test.ts new file mode 100644 index 000000000..fa66d8dc2 --- /dev/null +++ b/src/app/api/auth/me/security-activity/__tests__/route.test.ts @@ -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(); + }); +}); diff --git a/src/app/api/auth/me/security-activity/route.ts b/src/app/api/auth/me/security-activity/route.ts index b88411fe9..a364f3464 100644 --- a/src/app/api/auth/me/security-activity/route.ts +++ b/src/app/api/auth/me/security-activity/route.ts @@ -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(), diff --git a/src/app/api/cycle/cycles/route.ts b/src/app/api/cycle/cycles/route.ts index 73da843d4..f7de2c2d4 100644 --- a/src/app/api/cycle/cycles/route.ts +++ b/src/app/api/cycle/cycles/route.ts @@ -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 diff --git a/src/app/api/documents/inbound/route.ts b/src/app/api/documents/inbound/route.ts index f2c6723d4..86f68721c 100644 --- a/src/app/api/documents/inbound/route.ts +++ b/src/app/api/documents/inbound/route.ts @@ -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 diff --git a/src/app/api/encounters/route.ts b/src/app/api/encounters/route.ts index ef90cc80b..f44a047f1 100644 --- a/src/app/api/encounters/route.ts +++ b/src/app/api/encounters/route.ts @@ -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. diff --git a/src/app/api/family-history/route.ts b/src/app/api/family-history/route.ts index cb1464501..a8937161b 100644 --- a/src/app/api/family-history/route.ts +++ b/src/app/api/family-history/route.ts @@ -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" }, diff --git a/src/app/api/illness/episodes/route.ts b/src/app/api/illness/episodes/route.ts index 57b997184..2c16236b9 100644 --- a/src/app/api/illness/episodes/route.ts +++ b/src/app/api/illness/episodes/route.ts @@ -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" }, diff --git a/src/app/api/insights/ecg/route.ts b/src/app/api/insights/ecg/route.ts index b7001d9d2..83db67783 100644 --- a/src/app/api/insights/ecg/route.ts +++ b/src/app/api/insights/ecg/route.ts @@ -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, diff --git a/src/app/api/insights/rhythm-events/route.ts b/src/app/api/insights/rhythm-events/route.ts index 6cddeb6ac..c73206455 100644 --- a/src/app/api/insights/rhythm-events/route.ts +++ b/src/app/api/insights/rhythm-events/route.ts @@ -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, diff --git a/src/app/api/medications/[id]/side-effects/route.ts b/src/app/api/medications/[id]/side-effects/route.ts index 0b12feae8..5efd00c8d 100644 --- a/src/app/api/medications/[id]/side-effects/route.ts +++ b/src/app/api/medications/[id]/side-effects/route.ts @@ -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" }, diff --git a/src/app/api/mental-health/assessments/route.ts b/src/app/api/mental-health/assessments/route.ts index dd52b4443..610bceab3 100644 --- a/src/app/api/mental-health/assessments/route.ts +++ b/src/app/api/mental-health/assessments/route.ts @@ -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 }, diff --git a/src/app/api/personal-records/__tests__/route.test.ts b/src/app/api/personal-records/__tests__/route.test.ts index a4498ca4a..2593e518d 100644 --- a/src/app/api/personal-records/__tests__/route.test.ts +++ b/src/app/api/personal-records/__tests__/route.test.ts @@ -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, }); diff --git a/src/app/api/personal-records/route.ts b/src/app/api/personal-records/route.ts index dea22ef2a..ffbc29a06 100644 --- a/src/app/api/personal-records/route.ts +++ b/src/app/api/personal-records/route.ts @@ -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 diff --git a/src/app/api/practitioners/route.ts b/src/app/api/practitioners/route.ts index 39e15a3a2..8cc7ac970 100644 --- a/src/app/api/practitioners/route.ts +++ b/src/app/api/practitioners/route.ts @@ -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. diff --git a/src/app/api/symptoms/events/route.ts b/src/app/api/symptoms/events/route.ts index a23e997a4..40fa8b0ec 100644 --- a/src/app/api/symptoms/events/route.ts +++ b/src/app/api/symptoms/events/route.ts @@ -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, }); }); diff --git a/src/app/api/vaccinations/route.ts b/src/app/api/vaccinations/route.ts index f7cda6128..3a15bc2c8 100644 --- a/src/app/api/vaccinations/route.ts +++ b/src/app/api/vaccinations/route.ts @@ -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. diff --git a/src/components/vaccinations/use-vaccinations.ts b/src/components/vaccinations/use-vaccinations.ts index faa4cfb24..c67f2b77f 100644 --- a/src/components/vaccinations/use-vaccinations.ts +++ b/src/components/vaccinations/use-vaccinations.ts @@ -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( - 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( + `/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 }; + }, }); } diff --git a/src/hooks/use-encounters.ts b/src/hooks/use-encounters.ts index 45cb0baa3..dc947d9de 100644 --- a/src/hooks/use-encounters.ts +++ b/src/hooks/use-encounters.ts @@ -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(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( + `/api/encounters?${sp.toString()}`, + ); + list.upcoming.push(...page.upcoming); + list.past.push(...page.past); + if (!cap?.truncated) return list; + } }, enabled, }); diff --git a/src/hooks/use-practitioners.ts b/src/hooks/use-practitioners.ts index 0b66f26cc..bab014841 100644 --- a/src/hooks/use-practitioners.ts +++ b/src/hooks/use-practitioners.ts @@ -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( - 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( + `/api/practitioners?${sp.toString()}`, + ); + all.push(...page); + if (!cap?.truncated) return all; + } + }, enabled, }); } diff --git a/src/lib/http/__tests__/list-query.test.ts b/src/lib/http/__tests__/list-query.test.ts new file mode 100644 index 000000000..294a9714a --- /dev/null +++ b/src/lib/http/__tests__/list-query.test.ts @@ -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 }); + }); +}); diff --git a/src/lib/http/list-query-fields.ts b/src/lib/http/list-query-fields.ts new file mode 100644 index 000000000..734a559dd --- /dev/null +++ b/src/lib/http/list-query-fields.ts @@ -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); +} diff --git a/src/lib/http/list-query.ts b/src/lib/http/list-query.ts new file mode 100644 index 000000000..7f210e9f0 --- /dev/null +++ b/src/lib/http/list-query.ts @@ -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( + limit: number, + read: (take: number) => Promise, +): 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)), + }; +} diff --git a/src/lib/openapi/error-codes.ts b/src/lib/openapi/error-codes.ts index 460074912..7849dc564 100644 --- a/src/lib/openapi/error-codes.ts +++ b/src/lib/openapi/error-codes.ts @@ -392,6 +392,7 @@ export const ERROR_CODE_CATALOGUE: Readonly> = ], route: ["route.retired"], search: ["search.invalid"], + security_activity: ["security_activity.invalid"], "share-link": [ "share-link.document.decryptFailed", "share-link.document.notFound", diff --git a/src/lib/openapi/routes/auth.ts b/src/lib/openapi/routes/auth.ts index 71e0a5424..31795eff0 100644 --- a/src/lib/openapi/routes/auth.ts +++ b/src/lib/openapi/routes/auth.ts @@ -1708,7 +1708,7 @@ export const authPaths: NonNullable = { 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 = { .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: { diff --git a/src/lib/openapi/routes/encounters.ts b/src/lib/openapi/routes/encounters.ts index bd6bade34..36cd0c7d4 100644 --- a/src/lib/openapi/routes/encounters.ts +++ b/src/lib/openapi/routes/encounters.ts @@ -45,6 +45,7 @@ import { errorEnvelope, idempotencyKeyParameter, idempotentWrite, + listEnvelope, recordRefusal, recordWriteRateLimitResponse, stdResponses, @@ -346,7 +347,7 @@ export const encounterPaths: NonNullable = { 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 = { description: "The caller's visits.", content: { "application/json": { - schema: dataEnvelope(encounterList, "ListEncountersEnvelope"), + schema: listEnvelope(encounterList, "ListEncountersEnvelope"), }, }, }, @@ -612,7 +613,7 @@ export const encounterPaths: NonNullable = { 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 = { description: "The caller's address book.", content: { "application/json": { - schema: dataEnvelope( + schema: listEnvelope( z.array(practitioner), "ListPractitionersEnvelope", ), diff --git a/src/lib/openapi/routes/symptoms.ts b/src/lib/openapi/routes/symptoms.ts index 3ca724052..689833fed 100644 --- a/src/lib/openapi/routes/symptoms.ts +++ b/src/lib/openapi/routes/symptoms.ts @@ -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 diff --git a/src/lib/openapi/routes/vaccinations.ts b/src/lib/openapi/routes/vaccinations.ts index bbc34d674..768d1c4df 100644 --- a/src/lib/openapi/routes/vaccinations.ts +++ b/src/lib/openapi/routes/vaccinations.ts @@ -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 = { 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 = { description: "The caller's immunization history.", content: { "application/json": { - schema: dataEnvelope(vaccinationList, "ListVaccinationsEnvelope"), + schema: listEnvelope(vaccinationList, "ListVaccinationsEnvelope"), }, }, }, diff --git a/src/lib/validations/practitioners.ts b/src/lib/validations/practitioners.ts index 3bfc283d1..85fcc0566 100644 --- a/src/lib/validations/practitioners.ts +++ b/src/lib/validations/practitioners.ts @@ -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(); diff --git a/src/lib/validations/vaccinations.ts b/src/lib/validations/vaccinations.ts index 2ef829b05..9adb4a1f1 100644 --- a/src/lib/validations/vaccinations.ts +++ b/src/lib/validations/vaccinations.ts @@ -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, { diff --git a/tests/integration/symptoms.test.ts b/tests/integration/symptoms.test.ts index 1c22323de..3271ec9ea 100644 --- a/tests/integration/symptoms.test.ts +++ b/tests/integration/symptoms.test.ts @@ -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>( + 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(