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

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

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

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

No files matched your search

+27 -26
View File
@@ -40,7 +40,8 @@ import { prisma } from "@/lib/db";
import { getAuditLogRetentionDays } from "@/lib/jobs/audit-log-cleanup";
import { annotate } from "@/lib/logging/context";
import { GRANT_PARTY_SELECT } from "@/lib/sharing/grant-view";
import { apiListSuccess, capRows, overfetch } from "@/lib/api/list-cap";
import { apiListSuccess } from "@/lib/api/list-cap";
import { listPage } from "@/lib/http/list-query";
export const dynamic = "force-dynamic";
@@ -99,31 +100,31 @@ export const GET = apiHandler(async () => {
const retentionDays = getAuditLogRetentionDays();
const windowStart = new Date(Date.now() - retentionDays * 86_400_000);
const found = await prisma.auditLog.findMany({
where: {
userId: user.id,
actorUserId: { not: null },
// A switch into one's own record is not a delegation and has nothing to
// report. `actingActorFor` already refuses to stamp one, so this is the
// second end of that rule rather than its only enforcement.
NOT: { actorUserId: user.id },
// The window the copy promises. The purge job makes it true eventually;
// this makes it true now, which matters on an instance whose retention
// was shortened after rows were already written.
createdAt: { gte: windowStart },
},
orderBy: { createdAt: "desc" },
take: overfetch(MAX_ROWS),
select: {
id: true,
actorUserId: true,
action: true,
details: true,
createdAt: true,
},
});
const { rows, cap } = capRows(found, MAX_ROWS);
const { rows, cap } = await listPage(MAX_ROWS, (take) =>
prisma.auditLog.findMany({
where: {
userId: user.id,
actorUserId: { not: null },
// A switch into one's own record is not a delegation and has nothing to
// report. `actingActorFor` already refuses to stamp one, so this is the
// second end of that rule rather than its only enforcement.
NOT: { actorUserId: user.id },
// The window the copy promises. The purge job makes it true eventually;
// this makes it true now, which matters on an instance whose retention
// was shortened after rows were already written.
createdAt: { gte: windowStart },
},
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
take,
select: {
id: true,
actorUserId: true,
action: true,
details: true,
createdAt: true,
},
}),
);
const actorIds = [
...new Set(rows.map((r) => r.actorUserId).filter((id) => id !== null)),
+22 -27
View File
@@ -73,7 +73,8 @@ import {
} from "@/lib/sharing/grant-view";
import { GrantError, inviteGrant } from "@/lib/sharing/grants";
import { inviteGrantSchema } from "@/lib/validations/account-sharing";
import { apiListSuccess, capRows, overfetch } from "@/lib/api/list-cap";
import { apiListSuccess } from "@/lib/api/list-cap";
import { combinedCap, listPage } from "@/lib/http/list-query";
/**
* Ten invitations an hour, per account.
@@ -92,28 +93,25 @@ export const GET = apiHandler(async () => {
const { user } = await requireAuth();
annotate({ action: { name: "sharing.grants.list" } });
const [givenFound, receivedFound] = await Promise.all([
prisma.accountGrant.findMany({
where: { grantorId: user.id },
orderBy: { createdAt: "desc" },
take: overfetch(MAX_GRANTS_PER_DIRECTION),
include: { grantee: { select: GRANT_PARTY_SELECT } },
}),
prisma.accountGrant.findMany({
where: { granteeId: user.id },
orderBy: { createdAt: "desc" },
take: overfetch(MAX_GRANTS_PER_DIRECTION),
include: { grantor: { select: GRANT_PARTY_SELECT } },
}),
]);
const { rows: given, cap: givenCap } = capRows(
givenFound,
MAX_GRANTS_PER_DIRECTION,
);
const { rows: received, cap: receivedCap } = capRows(
receivedFound,
MAX_GRANTS_PER_DIRECTION,
);
const [{ rows: given, cap: givenCap }, { rows: received, cap: receivedCap }] =
await Promise.all([
listPage(MAX_GRANTS_PER_DIRECTION, (take) =>
prisma.accountGrant.findMany({
where: { grantorId: user.id },
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
take,
include: { grantee: { select: GRANT_PARTY_SELECT } },
}),
),
listPage(MAX_GRANTS_PER_DIRECTION, (take) =>
prisma.accountGrant.findMany({
where: { granteeId: user.id },
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
take,
include: { grantor: { select: GRANT_PARTY_SELECT } },
}),
),
]);
// Revoked and expired rows come back too. They are the consent record — "who
// had access, from when to when, and who ended it" is the question the table
@@ -122,10 +120,7 @@ export const GET = apiHandler(async () => {
const now = new Date();
// One cap per direction, one signal: either list cut short is a partial
// answer.
const cap = {
truncated: givenCap.truncated || receivedCap.truncated,
limit: MAX_GRANTS_PER_DIRECTION,
};
const cap = combinedCap(givenCap, receivedCap);
return apiListSuccess(
{
given: given.map((g) => toGrantView(g, g.grantee, now)),
+15 -11
View File
@@ -29,7 +29,8 @@ import {
allergyListQuerySchema,
} from "@/lib/validations/allergy";
import { toAllergyDTO } from "@/lib/records/dto";
import { apiListSuccess, capRows, overfetch } from "@/lib/api/list-cap";
import { apiListSuccess } from "@/lib/api/list-cap";
import { listPage } from "@/lib/http/list-query";
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireRecordAuth("read", "profile");
@@ -46,16 +47,19 @@ export const GET = apiHandler(async (request: NextRequest) => {
}
const limit = parsed.data.limit ?? 100;
const found = await prisma.allergy.findMany({
where: {
userId: user.id,
deletedAt: null,
...(parsed.data.includeInactive === "false" ? { status: "ACTIVE" } : {}),
},
orderBy: { createdAt: "desc" },
take: overfetch(limit),
});
const { rows, cap } = capRows(found, limit);
const { rows, cap } = await listPage(limit, (take) =>
prisma.allergy.findMany({
where: {
userId: user.id,
deletedAt: null,
...(parsed.data.includeInactive === "false"
? { status: "ACTIVE" }
: {}),
},
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
take,
}),
);
annotate({
action: { name: "allergy.list", entity_type: "allergy" },
@@ -0,0 +1,68 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
auditLog: { findMany: vi.fn(), create: vi.fn() },
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { GET } from "../route";
import { getSession } from "@/lib/auth/session";
import { prisma } from "@/lib/db";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "u", role: "USER" as const, locale: "en" },
};
const req = (qs = "") =>
new NextRequest(`http://localhost/api/auth/me/security-activity${qs}`);
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.auditLog.findMany).mockResolvedValue([] as never);
});
describe("GET /api/auth/me/security-activity", () => {
it("reads one row past the requested page, in a total order", async () => {
const res = await GET(req("?limit=10"));
expect(res.status).toBe(200);
const call = vi.mocked(prisma.auditLog.findMany).mock.calls[0][0] as {
take: number;
orderBy: unknown;
};
expect(call.take).toBe(11);
expect(call.orderBy).toEqual([{ createdAt: "desc" }, { id: "desc" }]);
const body = (await res.json()) as {
meta: { truncated: boolean; limit: number };
};
expect(body.meta).toMatchObject({ truncated: false, limit: 10 });
});
it("answers a limit past the published ceiling with a 422 naming it", async () => {
const res = await GET(req("?limit=500"));
expect(res.status).toBe(422);
const body = (await res.json()) as {
meta: { errorCode: string };
details: { issues: Array<{ path: string }> };
};
expect(body.meta.errorCode).toBe("security_activity.invalid");
expect(body.details.issues.map((i) => i.path)).toEqual(["limit"]);
expect(prisma.auditLog.findMany).not.toHaveBeenCalled();
});
});
+29 -21
View File
@@ -14,7 +14,9 @@ import { annotate } from "@/lib/logging/context";
import { prisma } from "@/lib/db";
import { securityActivityWhere } from "@/lib/auth/security-activity";
import { maskIp } from "@/lib/auth/device-fingerprint";
import { apiListSuccess, capRows, overfetch } from "@/lib/api/list-cap";
import { apiListSuccess } from "@/lib/api/list-cap";
import { listPage, parseListQuery } from "@/lib/http/list-query";
import { returnAllZodIssues } from "@/lib/api-response";
export const dynamic = "force-dynamic";
@@ -32,28 +34,34 @@ export interface SecurityActivityDTO {
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const limitParam = Number(
request.nextUrl.searchParams.get("limit") ?? DEFAULT_LIMIT,
);
const limit =
Number.isFinite(limitParam) && limitParam > 0
? Math.min(Math.floor(limitParam), MAX_LIMIT)
: DEFAULT_LIMIT;
const found = await prisma.auditLog.findMany({
where: securityActivityWhere(user.id),
orderBy: { createdAt: "desc" },
take: overfetch(limit),
select: {
action: true,
createdAt: true,
location: true,
ipAddress: true,
carrier: true,
},
// The shared list query: an out-of-range limit is a 422 naming it, as on
// every other list, rather than a silent clamp to something not asked for.
const query = parseListQuery(request.nextUrl.searchParams, {
defaultLimit: DEFAULT_LIMIT,
maxLimit: MAX_LIMIT,
});
if (!query.success) {
return returnAllZodIssues(query.error, 422, {
errorCode: "security_activity.invalid",
});
}
const { limit } = query.data;
const { rows, cap } = await listPage(limit, (take) =>
prisma.auditLog.findMany({
where: securityActivityWhere(user.id),
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
take,
select: {
action: true,
createdAt: true,
location: true,
ipAddress: true,
carrier: true,
},
}),
);
const { rows, cap } = capRows(found, limit);
const events: SecurityActivityDTO[] = rows.map((r) => ({
action: r.action,
createdAt: r.createdAt.toISOString(),
+9 -7
View File
@@ -19,7 +19,8 @@ import { toMenstrualCycleDTO } from "@/lib/cycle/dto";
import { median, mad } from "@/lib/cycle";
import { dayDiff } from "@/lib/cycle/day-math";
import { MIN_CYCLES_TO_PREDICT } from "@/lib/cycle/types";
import { apiListSuccess, capRows, overfetch } from "@/lib/api/list-cap";
import { apiListSuccess } from "@/lib/api/list-cap";
import { listPage } from "@/lib/http/list-query";
const DEFAULT_LIMIT = 24;
@@ -51,12 +52,13 @@ export const GET = apiHandler(async (request: NextRequest) => {
}
const limit = parsed.data.limit ?? DEFAULT_LIMIT;
const found = await prisma.menstrualCycle.findMany({
where: { userId: user.id, deletedAt: null },
orderBy: { startDate: "desc" },
take: overfetch(limit),
});
const { rows, cap } = capRows(found, limit);
const { rows, cap } = await listPage(limit, (take) =>
prisma.menstrualCycle.findMany({
where: { userId: user.id, deletedAt: null },
orderBy: [{ startDate: "desc" }, { id: "desc" }],
take,
}),
);
// Completed lengths (non-null lengthDays) drive the length stats.
const lengths = rows
+11 -9
View File
@@ -50,6 +50,7 @@ import {
sanitiseZodIssues,
} from "@/lib/api-response";
import { getAiCapability } from "@/lib/ai/capabilities/gate";
import { listPage } from "@/lib/http/list-query";
import { prisma } from "@/lib/db";
import { hashQueryTokens } from "@/lib/documents/content-index";
import {
@@ -710,16 +711,17 @@ export const GET = apiHandler(async (request: Request) => {
// Hardening: the list NEVER fetches the encrypted blob column — a page of
// 50 rows would otherwise drag up to 50 × cap ciphertext bytes per request.
const rows = await prisma.inboundDocument.findMany({
where,
omit: { contentEncrypted: true },
orderBy,
take: limit + 1,
...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}),
});
const { rows: page, cap } = await listPage(limit, (take) =>
prisma.inboundDocument.findMany({
where,
omit: { contentEncrypted: true },
orderBy,
take,
...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}),
}),
);
const hasMore = rows.length > limit;
const page = hasMore ? rows.slice(0, limit) : rows;
const hasMore = cap.truncated;
const nextCursor = hasMore ? (page[page.length - 1]?.id ?? null) : null;
// Fact tallies for the whole page in ONE grouped count — no per-document
+30 -22
View File
@@ -26,6 +26,8 @@ import {
safeJson,
} from "@/lib/api-response";
import { checkRecordWriteRateLimit } from "@/lib/rate-limit";
import { apiListSuccess } from "@/lib/api/list-cap";
import { combinedCap, listPage } from "@/lib/http/list-query";
import { withIdempotency } from "@/lib/idempotency";
import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
import {
@@ -109,27 +111,33 @@ export const GET = apiHandler(async (request: NextRequest) => {
// past reads newest-first, which is the opposite direction; resolving both
// here means the client renders two lists instead of splitting and
// re-sorting one, and the ordering lives in one place.
const [upcoming, past] = await Promise.all([
prisma.encounter.findMany({
// `gt` and `gte` are different keys, so the caller's lower bound and this
// arm's floor both survive the spread; only the `lte` pair collided.
where: { ...where, occurredAt: { ...where.occurredAt, gt: now } },
orderBy: [{ occurredAt: "asc" }, { id: "asc" }],
take,
skip,
include: ENCOUNTER_INCLUDE,
}),
prisma.encounter.findMany({
where: {
...where,
occurredAt: { ...where.occurredAt, lte: pastCeiling },
},
orderBy: [{ occurredAt: "desc" }, { id: "desc" }],
take,
skip,
include: ENCOUNTER_INCLUDE,
}),
]);
const [{ rows: upcoming, cap: upcomingCap }, { rows: past, cap: pastCap }] =
await Promise.all([
listPage(take, (overTake) =>
prisma.encounter.findMany({
// `gt` and `gte` are different keys, so the caller's lower bound and
// this arm's floor both survive the spread; only the `lte` pair
// collided.
where: { ...where, occurredAt: { ...where.occurredAt, gt: now } },
orderBy: [{ occurredAt: "asc" }, { id: "asc" }],
take: overTake,
skip,
include: ENCOUNTER_INCLUDE,
}),
),
listPage(take, (overTake) =>
prisma.encounter.findMany({
where: {
...where,
occurredAt: { ...where.occurredAt, lte: pastCeiling },
},
orderBy: [{ occurredAt: "desc" }, { id: "desc" }],
take: overTake,
skip,
include: ENCOUNTER_INCLUDE,
}),
),
]);
// The links ride the list. The card counts what a visit produced, and the
// edit sheet seeds its pickers from the row it was handed — a list without
@@ -152,7 +160,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
upcoming: upcoming.map((row) => toEncounterDTO(row, links.get(row.id))),
past: past.map((row) => toEncounterDTO(row, links.get(row.id))),
};
return apiSuccess(body);
return apiListSuccess(body, combinedCap(upcomingCap, pastCap));
});
// A double-tap on "save" must not file the same visit twice.
+9 -7
View File
@@ -28,7 +28,8 @@ import {
familyHistoryListQuerySchema,
} from "@/lib/validations/family-history";
import { toFamilyHistoryEntryDTO } from "@/lib/records/dto";
import { apiListSuccess, capRows, overfetch } from "@/lib/api/list-cap";
import { apiListSuccess } from "@/lib/api/list-cap";
import { listPage } from "@/lib/http/list-query";
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireRecordAuth("read", "profile");
@@ -44,12 +45,13 @@ export const GET = apiHandler(async (request: NextRequest) => {
}
const limit = parsed.data.limit ?? 100;
const found = await prisma.familyHistoryEntry.findMany({
where: { userId: user.id, deletedAt: null },
orderBy: { createdAt: "desc" },
take: overfetch(limit),
});
const { rows, cap } = capRows(found, limit);
const { rows, cap } = await listPage(limit, (take) =>
prisma.familyHistoryEntry.findMany({
where: { userId: user.id, deletedAt: null },
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
take,
}),
);
annotate({
action: { name: "family-history.list", entity_type: "family_history" },
+26 -22
View File
@@ -32,7 +32,8 @@ import {
illnessEpisodeListQuerySchema,
} from "@/lib/validations/illness";
import { toIllnessEpisodeDTO } from "@/lib/illness/dto";
import { apiListSuccess, capRows, overfetch } from "@/lib/api/list-cap";
import { apiListSuccess } from "@/lib/api/list-cap";
import { listPage } from "@/lib/http/list-query";
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireRecordAuth("read", "illness");
@@ -59,27 +60,30 @@ export const GET = apiHandler(async (request: NextRequest) => {
const { from, to, q, offset, sortDir } = parsed.data;
const limit = parsed.data.limit ?? 50;
const order = sortDir ?? "desc";
const found = await prisma.illnessEpisode.findMany({
where: {
userId: user.id,
deletedAt: null,
...(parsed.data.includeResolved === "false" ? { resolvedAt: null } : {}),
...(from || to
? {
onsetAt: {
...(from ? { gte: new Date(from) } : {}),
...(to ? { lte: new Date(to) } : {}),
},
}
: {}),
...(q ? { label: { contains: q, mode: "insensitive" as const } } : {}),
},
// `id` breaks ties so offset paging never repeats or drops a row.
orderBy: [{ onsetAt: order }, { id: order }],
take: overfetch(limit),
skip: offset ?? 0,
});
const { rows, cap } = capRows(found, limit);
const { rows, cap } = await listPage(limit, (take) =>
prisma.illnessEpisode.findMany({
where: {
userId: user.id,
deletedAt: null,
...(parsed.data.includeResolved === "false"
? { resolvedAt: null }
: {}),
...(from || to
? {
onsetAt: {
...(from ? { gte: new Date(from) } : {}),
...(to ? { lte: new Date(to) } : {}),
},
}
: {}),
...(q ? { label: { contains: q, mode: "insensitive" as const } } : {}),
},
// `id` breaks ties so offset paging never repeats or drops a row.
orderBy: [{ onsetAt: order }, { id: order }],
take,
skip: offset ?? 0,
}),
);
annotate({
action: { name: "illness.episode.list", entity_type: "illness_episode" },
+22 -20
View File
@@ -47,7 +47,8 @@ import { checkRateLimit } from "@/lib/rate-limit";
import { prisma } from "@/lib/db";
import { persistEcgRecording } from "@/lib/ecg/persist-recording";
import { isoDateTime } from "@/lib/validations/datetime";
import { apiListSuccess, capRows, overfetch } from "@/lib/api/list-cap";
import { apiListSuccess } from "@/lib/api/list-cap";
import { listPage } from "@/lib/http/list-query";
export const dynamic = "force-dynamic";
@@ -134,25 +135,26 @@ export const GET = apiHandler(async () => {
// sync.
const { user } = await requireRecordAuth("manage", "record");
const found = await prisma.ecgRecording.findMany({
where: { userId: user.id },
// Everything EXCEPT `waveformEncrypted` — the list never touches the
// encrypted blob, so no decrypt happens on this path.
select: {
id: true,
recordedAt: true,
durationSeconds: true,
samplingFrequency: true,
sampleCount: true,
averageHeartRate: true,
lead: true,
rhythmClassification: true,
source: true,
},
orderBy: { recordedAt: "desc" },
take: overfetch(MAX_RECORDINGS),
});
const { rows, cap } = capRows(found, MAX_RECORDINGS);
const { rows, cap } = await listPage(MAX_RECORDINGS, (take) =>
prisma.ecgRecording.findMany({
where: { userId: user.id },
// Everything EXCEPT `waveformEncrypted` — the list never touches the
// encrypted blob, so no decrypt happens on this path.
select: {
id: true,
recordedAt: true,
durationSeconds: true,
samplingFrequency: true,
sampleCount: true,
averageHeartRate: true,
lead: true,
rhythmClassification: true,
source: true,
},
orderBy: [{ recordedAt: "desc" }, { id: "desc" }],
take,
}),
);
const recordings = rows.map((r) => ({
id: r.id,
+21 -19
View File
@@ -29,7 +29,8 @@ import { annotate } from "@/lib/logging/context";
import { prisma } from "@/lib/db";
import { EVENT_MEASUREMENT_TYPES } from "@/lib/validations/measurement";
import type { MeasurementType } from "@/generated/prisma/client";
import { apiListSuccess, capRows, overfetch } from "@/lib/api/list-cap";
import { apiListSuccess } from "@/lib/api/list-cap";
import { listPage } from "@/lib/http/list-query";
export const dynamic = "force-dynamic";
@@ -49,24 +50,25 @@ export const GET = apiHandler(async () => {
// The `insights` module is the AI analysis opt-out, and nothing here was
// written by a model.
const found = await prisma.measurement.findMany({
where: {
userId: user.id,
type: { in: EVENT_TYPE_LIST },
deletedAt: null,
},
select: {
id: true,
type: true,
rhythmClassification: true,
measuredAt: true,
source: true,
deviceType: true,
},
orderBy: { measuredAt: "desc" },
take: overfetch(MAX_EVENTS),
});
const { rows, cap } = capRows(found, MAX_EVENTS);
const { rows, cap } = await listPage(MAX_EVENTS, (take) =>
prisma.measurement.findMany({
where: {
userId: user.id,
type: { in: EVENT_TYPE_LIST },
deletedAt: null,
},
select: {
id: true,
type: true,
rhythmClassification: true,
measuredAt: true,
source: true,
deviceType: true,
},
orderBy: [{ measuredAt: "desc" }, { id: "desc" }],
take,
}),
);
const events = rows.map((r) => ({
id: r.id,
@@ -39,7 +39,8 @@ import { categoryForEntry } from "@/lib/medications/side-effects/taxonomy";
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
import { encryptNote, shapeSideEffectNotes } from "@/lib/crypto/note-cipher";
import { withIdempotency } from "@/lib/idempotency";
import { apiListSuccess, capRows, overfetch } from "@/lib/api/list-cap";
import { apiListSuccess } from "@/lib/api/list-cap";
import { listPage } from "@/lib/http/list-query";
type RouteParams = { params: Promise<{ id: string }> };
@@ -67,23 +68,24 @@ export const GET = apiHandler(
const { from, to, limit } = parsed.data;
const found = await prisma.medicationSideEffect.findMany({
where: {
userId: user.id,
medicationId: id,
...(from || to
? {
occurredAt: {
...(from ? { gte: from } : {}),
...(to ? { lt: to } : {}),
},
}
: {}),
},
orderBy: { occurredAt: "desc" },
take: overfetch(limit),
});
const { rows: items, cap } = capRows(found, limit);
const { rows: items, cap } = await listPage(limit, (take) =>
prisma.medicationSideEffect.findMany({
where: {
userId: user.id,
medicationId: id,
...(from || to
? {
occurredAt: {
...(from ? { gte: from } : {}),
...(to ? { lt: to } : {}),
},
}
: {}),
},
orderBy: [{ occurredAt: "desc" }, { id: "desc" }],
take,
}),
);
annotate({
action: { name: "medication.sideEffect.list" },
+30 -28
View File
@@ -57,7 +57,8 @@ import type {
MeasurementType,
MeasurementSource,
} from "@/generated/prisma/client";
import { apiListSuccess, capRows, overfetch } from "@/lib/api/list-cap";
import { apiListSuccess } from "@/lib/api/list-cap";
import { listPage } from "@/lib/http/list-query";
function shapeRow(row: {
id: string;
@@ -120,33 +121,34 @@ export const GET = apiHandler(async (request: NextRequest) => {
}
const { instrument, limit, offset } = parsed.data;
const pageSize = limit ?? 100;
const found = await prisma.mentalHealthAssessment.findMany({
where: {
userId: user.id,
deletedAt: null,
// Screeners only: the table also stores AUDIT-C (v1.43), which older
// app builds cannot decode in this list.
instrument: instrument ?? { in: [...SCREENER_INSTRUMENTS] },
},
// Unique tiebreaker — `takenAt` is day-resolution and two instruments
// taken in one sitting tie.
orderBy: [{ takenAt: "desc" }, { id: "desc" }],
take: overfetch(pageSize),
skip: offset ?? 0,
select: {
id: true,
instrument: true,
locale: true,
version: true,
totalScore: true,
severityBand: true,
item9Flagged: true,
crisisShownAt: true,
takenAt: true,
createdAt: true,
},
});
const { rows, cap } = capRows(found, pageSize);
const { rows, cap } = await listPage(pageSize, (take) =>
prisma.mentalHealthAssessment.findMany({
where: {
userId: user.id,
deletedAt: null,
// Screeners only: the table also stores AUDIT-C (v1.43), which older
// app builds cannot decode in this list.
instrument: instrument ?? { in: [...SCREENER_INSTRUMENTS] },
},
// Unique tiebreaker — `takenAt` is day-resolution and two instruments
// taken in one sitting tie.
orderBy: [{ takenAt: "desc" }, { id: "desc" }],
take,
skip: offset ?? 0,
select: {
id: true,
instrument: true,
locale: true,
version: true,
totalScore: true,
severityBand: true,
item9Flagged: true,
crisisShownAt: true,
takenAt: true,
createdAt: true,
},
}),
);
annotate({
action: { name: "mental-health.list" },
meta: { count: rows.length },
@@ -66,7 +66,7 @@ describe("GET /api/personal-records", () => {
expect(body.error).toBeNull();
expect(prisma.personalRecord.findMany).toHaveBeenCalledWith({
where: { userId: "user-1" },
orderBy: { achievedAt: "desc" },
orderBy: [{ achievedAt: "desc" }, { id: "desc" }],
// One row past the limit, so the response can say it was cut.
take: 101,
});
@@ -80,7 +80,7 @@ describe("GET /api/personal-records", () => {
expect(res.status).toBe(200);
expect(prisma.personalRecord.findMany).toHaveBeenCalledWith({
where: { userId: "user-1", metricType: "VO2_MAX" },
orderBy: { achievedAt: "desc" },
orderBy: [{ achievedAt: "desc" }, { id: "desc" }],
// One row past the limit, so the response can say it was cut.
take: 101,
});
@@ -114,7 +114,7 @@ describe("GET /api/personal-records", () => {
expect(res.status).toBe(200);
expect(prisma.personalRecord.findMany).toHaveBeenCalledWith({
where: { userId: "user-1" },
orderBy: { achievedAt: "desc" },
orderBy: [{ achievedAt: "desc" }, { id: "desc" }],
// One row past the limit, so the response can say it was cut.
take: 26,
});
@@ -128,7 +128,7 @@ describe("GET /api/personal-records", () => {
expect(res.status).toBe(200);
expect(prisma.personalRecord.findMany).toHaveBeenCalledWith({
where: { userId: "user-1" },
orderBy: { achievedAt: "desc" },
orderBy: [{ achievedAt: "desc" }, { id: "desc" }],
// One row past the limit, so the response can say it was cut.
take: 501,
});
+12 -10
View File
@@ -38,7 +38,8 @@ import {
resolveRecordBenchmark,
} from "@/lib/personal-records/record-benchmark";
import { toProfileSex } from "@/lib/profile/sex";
import { apiListSuccess, capRows, overfetch } from "@/lib/api/list-cap";
import { apiListSuccess } from "@/lib/api/list-cap";
import { listPage } from "@/lib/http/list-query";
export const dynamic = "force-dynamic";
@@ -63,15 +64,16 @@ export const GET = apiHandler(async (request: NextRequest) => {
}
const { metricType, limit } = parsed.data;
const found = await prisma.personalRecord.findMany({
where: {
userId: user.id,
...(metricType ? { metricType } : {}),
},
orderBy: { achievedAt: "desc" },
take: overfetch(limit),
});
const { rows: records, cap } = capRows(found, limit);
const { rows: records, cap } = await listPage(limit, (take) =>
prisma.personalRecord.findMany({
where: {
userId: user.id,
...(metricType ? { metricType } : {}),
},
orderBy: [{ achievedAt: "desc" }, { id: "desc" }],
take,
}),
);
const wantsBand = records.some((r) => mayCarryBenchmark(r.metricType));
const profile = wantsBand
+44 -36
View File
@@ -33,6 +33,8 @@ import {
practitionerListQuerySchema,
} from "@/lib/validations/practitioners";
import { toPractitionerDTO } from "@/lib/practitioners/dto";
import { apiListSuccess } from "@/lib/api/list-cap";
import { listPage } from "@/lib/http/list-query";
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireRecordAuth("read", "profile");
@@ -41,6 +43,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
const parsed = practitionerListQuerySchema.safeParse({
q: params.get("q") ?? undefined,
limit: params.get("limit") ?? undefined,
offset: params.get("offset") ?? undefined,
});
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422, {
@@ -48,50 +51,55 @@ export const GET = apiHandler(async (request: NextRequest) => {
});
}
const rows = await prisma.practitioner.findMany({
where: {
userId: user.id,
deletedAt: null,
// Three plaintext columns, because a person looks a practice up by
// whichever they remember: the doctor's name on the referral, the
// practice name on the door, or the field they need ("Zahnmedizin").
// Searching only name + practice made the specialty attempt return
// nothing and read as "not in the address book".
...(parsed.data.q
? {
OR: [
{
name: {
contains: parsed.data.q,
mode: "insensitive" as const,
const limit = parsed.data.limit ?? 100;
const { rows, cap } = await listPage(limit, (take) =>
prisma.practitioner.findMany({
where: {
userId: user.id,
deletedAt: null,
// Three plaintext columns, because a person looks a practice up by
// whichever they remember: the doctor's name on the referral, the
// practice name on the door, or the field they need ("Zahnmedizin").
// Searching only name + practice made the specialty attempt return
// nothing and read as "not in the address book".
...(parsed.data.q
? {
OR: [
{
name: {
contains: parsed.data.q,
mode: "insensitive" as const,
},
},
},
{
practice: {
contains: parsed.data.q,
mode: "insensitive" as const,
{
practice: {
contains: parsed.data.q,
mode: "insensitive" as const,
},
},
},
{
specialty: {
contains: parsed.data.q,
mode: "insensitive" as const,
{
specialty: {
contains: parsed.data.q,
mode: "insensitive" as const,
},
},
},
],
}
: {}),
},
orderBy: { name: "asc" },
take: parsed.data.limit ?? 100,
});
],
}
: {}),
},
// Two contacts can share a name; the id keeps the page boundary stable.
orderBy: [{ name: "asc" }, { id: "asc" }],
skip: parsed.data.offset ?? 0,
take,
}),
);
annotate({
action: { name: "practitioner.contact.list", entity_type: "practitioner" },
meta: { count: rows.length },
meta: { count: rows.length, truncated: cap.truncated },
});
return apiSuccess(rows.map(toPractitionerDTO));
return apiListSuccess(rows.map(toPractitionerDTO), cap);
});
// A double-tap on "save" must not mint a second copy of the same practice.
+18 -11
View File
@@ -15,6 +15,8 @@
*/
import { NextRequest } from "next/server";
import { listPage } from "@/lib/http/list-query";
import { prisma } from "@/lib/db";
import {
apiSuccess,
@@ -65,16 +67,18 @@ export const GET = apiHandler(async (request: NextRequest) => {
if (q.from) occurredAt.gte = new Date(q.from);
if (q.to) occurredAt.lte = new Date(q.to);
const rows = await prisma.symptomEvent.findMany({
where: {
userId: user.id,
...(q.from || q.to ? { occurredAt } : {}),
...(q.definitionId ? { definitionId: q.definitionId } : {}),
...(q.episodeId ? { episodeId: q.episodeId } : {}),
},
orderBy: [{ occurredAt: "desc" }, { id: "desc" }],
take: q.limit,
});
const { rows, cap } = await listPage(q.limit, (take) =>
prisma.symptomEvent.findMany({
where: {
userId: user.id,
...(q.from || q.to ? { occurredAt } : {}),
...(q.definitionId ? { definitionId: q.definitionId } : {}),
...(q.episodeId ? { episodeId: q.episodeId } : {}),
},
orderBy: [{ occurredAt: "desc" }, { id: "desc" }],
take,
}),
);
annotate({
action: { name: "symptoms.event.list" },
@@ -84,7 +88,10 @@ export const GET = apiHandler(async (request: NextRequest) => {
return apiSuccess({
events: rows.map(toSymptomEventDTO),
limit: q.limit,
hasMore: rows.length === q.limit,
// Exact: true only when a row past this page exists. `rows.length ===
// limit` said true on a page that was exactly full, and the client then
// asked for a page that held nothing.
hasMore: cap.truncated,
});
});
+30 -22
View File
@@ -31,6 +31,8 @@ import {
} from "@/lib/api-response";
import { checkRecordWriteRateLimit } from "@/lib/rate-limit";
import { withIdempotency } from "@/lib/idempotency";
import { apiListSuccess } from "@/lib/api/list-cap";
import { listPage } from "@/lib/http/list-query";
import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
import {
vaccinationCreateSchema,
@@ -62,6 +64,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
from: params.get("from") ?? undefined,
to: params.get("to") ?? undefined,
limit: params.get("limit") ?? undefined,
offset: params.get("offset") ?? undefined,
});
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422, {
@@ -70,29 +73,34 @@ export const GET = apiHandler(async (request: NextRequest) => {
}
const query = parsed.data;
// Full and bounded rather than paged: an Impfpass is a lifetime document but
// a small one, and the default already covers a long life.
// A lifetime document but a small one: the default page already covers a
// long life, and `meta.truncated` says when it does not.
const take = query.limit ?? 300;
const [rows, series] = await Promise.all([
prisma.vaccinationRecord.findMany({
where: {
userId: user.id,
deletedAt: null,
...(query.antigenSlug ? { antigenSlug: query.antigenSlug } : {}),
...(query.from || query.to
? {
occurredAt: {
...(query.from ? { gte: new Date(query.from) } : {}),
...(query.to ? { lte: new Date(query.to) } : {}),
},
}
: {}),
},
orderBy: { occurredAt: "desc" },
take,
include: VACCINATION_INCLUDE,
}),
const [{ rows, cap }, series] = await Promise.all([
listPage(take, (overTake) =>
prisma.vaccinationRecord.findMany({
where: {
userId: user.id,
deletedAt: null,
...(query.antigenSlug ? { antigenSlug: query.antigenSlug } : {}),
...(query.from || query.to
? {
occurredAt: {
...(query.from ? { gte: new Date(query.from) } : {}),
...(query.to ? { lte: new Date(query.to) } : {}),
},
}
: {}),
},
// Two doses on one day are common (a combined appointment); the id
// keeps the page boundary between them stable.
orderBy: [{ occurredAt: "desc" }, { id: "desc" }],
skip: query.offset ?? 0,
take: overTake,
include: VACCINATION_INCLUDE,
}),
),
// Over the WHOLE live set, never over the filtered page: a position is a
// count of what came before it, so deriving it from a window would report
// the oldest dose in that window as the first one ever given.
@@ -135,7 +143,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
),
renewals,
};
return apiSuccess(body);
return apiListSuccess(body, cap);
});
// A double-tap on "save" must not log the same dose twice.