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(