mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 22:26:13 +00:00
feat(api): say when a list was cut at its ceiling
A list that stops at its own take and says nothing lets the last row read as the oldest there is. Capped lists now fetch one row past the limit and answer meta.truncated and meta.limit on the success envelope, so data keeps its shape. Applied to the capped lists without a total count: rhythm events, ECG recordings, sharing grants and activity, illness episodes, side effects, personal records, security activity, cycles, family history, allergies and mental-health assessments. The activity feed no longer reports a list that is exactly full as cut.
This commit is contained in:
1 parent
415da581f8
commit
97d1052dd1
28 files changed
+396
-174
No files matched your search
@@ -35,12 +35,12 @@
|
||||
* names other delegates, and a grant is not an introduction to the household.
|
||||
*/
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { DELEGATED_ACCESS_ACTION } from "@/lib/auth/audit";
|
||||
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";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -99,7 +99,7 @@ export const GET = apiHandler(async () => {
|
||||
const retentionDays = getAuditLogRetentionDays();
|
||||
const windowStart = new Date(Date.now() - retentionDays * 86_400_000);
|
||||
|
||||
const rows = await prisma.auditLog.findMany({
|
||||
const found = await prisma.auditLog.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
actorUserId: { not: null },
|
||||
@@ -113,7 +113,7 @@ export const GET = apiHandler(async () => {
|
||||
createdAt: { gte: windowStart },
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: MAX_ROWS,
|
||||
take: overfetch(MAX_ROWS),
|
||||
select: {
|
||||
id: true,
|
||||
actorUserId: true,
|
||||
@@ -123,6 +123,8 @@ export const GET = apiHandler(async () => {
|
||||
},
|
||||
});
|
||||
|
||||
const { rows, cap } = capRows(found, MAX_ROWS);
|
||||
|
||||
const actorIds = [
|
||||
...new Set(rows.map((r) => r.actorUserId).filter((id) => id !== null)),
|
||||
];
|
||||
@@ -140,23 +142,26 @@ export const GET = apiHandler(async () => {
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
}));
|
||||
|
||||
// Equality, not `>=`: `take` cannot return more than it was given, and a
|
||||
// `>` would be a comparison that can never be true.
|
||||
const truncated = rows.length === MAX_ROWS;
|
||||
// Exact: one row past the cap was asked for, so a list that is exactly
|
||||
// full no longer reads as cut.
|
||||
const truncated = cap.truncated;
|
||||
|
||||
annotate({
|
||||
action: { name: "sharing.activity.list" },
|
||||
meta: { entry_count: entries.length, truncated },
|
||||
});
|
||||
|
||||
return apiSuccess({
|
||||
entries,
|
||||
// The bound the view states out loud. Resolved here so the UI never has to
|
||||
// hardcode a number the operator can change.
|
||||
retentionDays,
|
||||
// Whether the list is everything inside that window, or its most recent
|
||||
// hundred rows. The view says which; silence would let the oldest line
|
||||
// read as the beginning.
|
||||
truncated,
|
||||
});
|
||||
return apiListSuccess(
|
||||
{
|
||||
entries,
|
||||
// The bound the view states out loud. Resolved here so the UI never has to
|
||||
// hardcode a number the operator can change.
|
||||
retentionDays,
|
||||
// Whether the list is everything inside that window, or its most recent
|
||||
// hundred rows. The view says which; silence would let the oldest line
|
||||
// read as the beginning.
|
||||
truncated,
|
||||
},
|
||||
cap,
|
||||
);
|
||||
});
|
||||
@@ -69,6 +69,7 @@ import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { GRANT_PARTY_SELECT, toGrantView } 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";
|
||||
|
||||
/**
|
||||
* Ten invitations an hour, per account.
|
||||
@@ -87,45 +88,62 @@ export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "sharing.grants.list" } });
|
||||
|
||||
const [given, received] = await Promise.all([
|
||||
const [givenFound, receivedFound] = await Promise.all([
|
||||
prisma.accountGrant.findMany({
|
||||
where: { grantorId: user.id },
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: MAX_GRANTS_PER_DIRECTION,
|
||||
take: overfetch(MAX_GRANTS_PER_DIRECTION),
|
||||
include: { grantee: { select: GRANT_PARTY_SELECT } },
|
||||
}),
|
||||
prisma.accountGrant.findMany({
|
||||
where: { granteeId: user.id },
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: MAX_GRANTS_PER_DIRECTION,
|
||||
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,
|
||||
);
|
||||
|
||||
// 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
|
||||
// exists to answer, and a list that quietly dropped the ended rows would
|
||||
// leave the panel unable to answer it. `state` says which are live.
|
||||
const now = new Date();
|
||||
return apiSuccess({
|
||||
given: given.map((g) => toGrantView(g, g.grantee, now)),
|
||||
received: received.map((g) => toGrantView(g, g.grantor, now)),
|
||||
// v1.37.0 — the audit window, on the payload that carries the rows.
|
||||
//
|
||||
// The revoke dialog tells the owner how long "who entered what" stays
|
||||
// answerable after the access ends, and it can only say that with the
|
||||
// number the operator configured. It used to read that number off the
|
||||
// activity feed, a second and independent request, so whether the sentence
|
||||
// appeared depended on which of two reads finished first — and an owner
|
||||
// who clicked quickly ended a manage grant having been told less than one
|
||||
// who clicked slowly, with nothing on screen to say a sentence was
|
||||
// missing. Riding it here removes the race by construction: a client
|
||||
// holding a grant to revoke is holding the window it must disclose.
|
||||
//
|
||||
// Same resolver as `/api/account/activity` and as the purge job, so the
|
||||
// two surfaces cannot state different numbers.
|
||||
retentionDays: getAuditLogRetentionDays(),
|
||||
});
|
||||
// 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,
|
||||
};
|
||||
return apiListSuccess(
|
||||
{
|
||||
given: given.map((g) => toGrantView(g, g.grantee, now)),
|
||||
received: received.map((g) => toGrantView(g, g.grantor, now)),
|
||||
// v1.37.0 — the audit window, on the payload that carries the rows.
|
||||
//
|
||||
// The revoke dialog tells the owner how long "who entered what" stays
|
||||
// answerable after the access ends, and it can only say that with the
|
||||
// number the operator configured. It used to read that number off the
|
||||
// activity feed, a second and independent request, so whether the sentence
|
||||
// appeared depended on which of two reads finished first — and an owner
|
||||
// who clicked quickly ended a manage grant having been told less than one
|
||||
// who clicked slowly, with nothing on screen to say a sentence was
|
||||
// missing. Riding it here removes the race by construction: a client
|
||||
// holding a grant to revoke is holding the window it must disclose.
|
||||
//
|
||||
// Same resolver as `/api/account/activity` and as the purge job, so the
|
||||
// two surfaces cannot state different numbers.
|
||||
retentionDays: getAuditLogRetentionDays(),
|
||||
},
|
||||
cap,
|
||||
);
|
||||
});
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
allergyListQuerySchema,
|
||||
} from "@/lib/validations/allergy";
|
||||
import { toAllergyDTO } from "@/lib/records/dto";
|
||||
import { apiListSuccess, capRows, overfetch } from "@/lib/api/list-cap";
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireRecordAuth("read", "profile");
|
||||
@@ -45,22 +46,23 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
}
|
||||
|
||||
const limit = parsed.data.limit ?? 100;
|
||||
const rows = await prisma.allergy.findMany({
|
||||
const found = await prisma.allergy.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
deletedAt: null,
|
||||
...(parsed.data.includeInactive === "false" ? { status: "ACTIVE" } : {}),
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: limit,
|
||||
take: overfetch(limit),
|
||||
});
|
||||
const { rows, cap } = capRows(found, limit);
|
||||
|
||||
annotate({
|
||||
action: { name: "allergy.list", entity_type: "allergy" },
|
||||
meta: { count: rows.length },
|
||||
});
|
||||
|
||||
return apiSuccess(rows.map(toAllergyDTO));
|
||||
return apiListSuccess(rows.map(toAllergyDTO), cap);
|
||||
});
|
||||
|
||||
// `withIdempotency` lets an iOS retry / double-tap re-send the same
|
||||
|
||||
@@ -10,11 +10,11 @@
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
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";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -40,10 +40,10 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
? Math.min(Math.floor(limitParam), MAX_LIMIT)
|
||||
: DEFAULT_LIMIT;
|
||||
|
||||
const rows = await prisma.auditLog.findMany({
|
||||
const found = await prisma.auditLog.findMany({
|
||||
where: securityActivityWhere(user.id),
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: limit,
|
||||
take: overfetch(limit),
|
||||
select: {
|
||||
action: true,
|
||||
createdAt: true,
|
||||
@@ -53,6 +53,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
},
|
||||
});
|
||||
|
||||
const { rows, cap } = capRows(found, limit);
|
||||
const events: SecurityActivityDTO[] = rows.map((r) => ({
|
||||
action: r.action,
|
||||
createdAt: r.createdAt.toISOString(),
|
||||
@@ -66,5 +67,5 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
meta: { event_count: events.length },
|
||||
});
|
||||
|
||||
return apiSuccess({ events });
|
||||
return apiListSuccess({ events }, cap);
|
||||
});
|
||||
@@ -12,17 +12,14 @@ import { NextRequest } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import {
|
||||
apiSuccess,
|
||||
apiValidationError,
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { apiValidationError, sanitiseZodIssues } from "@/lib/api-response";
|
||||
import { requireCycleEnabled } from "@/lib/cycle/gate";
|
||||
import { cycleHistoryQuerySchema } from "@/lib/validations/cycle";
|
||||
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";
|
||||
|
||||
const DEFAULT_LIMIT = 24;
|
||||
|
||||
@@ -54,11 +51,12 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
}
|
||||
const limit = parsed.data.limit ?? DEFAULT_LIMIT;
|
||||
|
||||
const rows = await prisma.menstrualCycle.findMany({
|
||||
const found = await prisma.menstrualCycle.findMany({
|
||||
where: { userId: user.id, deletedAt: null },
|
||||
orderBy: { startDate: "desc" },
|
||||
take: limit,
|
||||
take: overfetch(limit),
|
||||
});
|
||||
const { rows, cap } = capRows(found, limit);
|
||||
|
||||
// Completed lengths (non-null lengthDays) drive the length stats.
|
||||
const lengths = rows
|
||||
@@ -98,13 +96,16 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
meta: { returned: rows.length, regularity },
|
||||
});
|
||||
|
||||
return apiSuccess({
|
||||
cycles: rows.map(toMenstrualCycleDTO),
|
||||
stats: {
|
||||
avgLengthDays,
|
||||
lengthVariabilityDays,
|
||||
avgPeriodLengthDays,
|
||||
regularity,
|
||||
return apiListSuccess(
|
||||
{
|
||||
cycles: rows.map(toMenstrualCycleDTO),
|
||||
stats: {
|
||||
avgLengthDays,
|
||||
lengthVariabilityDays,
|
||||
avgPeriodLengthDays,
|
||||
regularity,
|
||||
},
|
||||
},
|
||||
});
|
||||
cap,
|
||||
);
|
||||
});
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
familyHistoryListQuerySchema,
|
||||
} from "@/lib/validations/family-history";
|
||||
import { toFamilyHistoryEntryDTO } from "@/lib/records/dto";
|
||||
import { apiListSuccess, capRows, overfetch } from "@/lib/api/list-cap";
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireRecordAuth("read", "profile");
|
||||
@@ -43,18 +44,19 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
}
|
||||
|
||||
const limit = parsed.data.limit ?? 100;
|
||||
const rows = await prisma.familyHistoryEntry.findMany({
|
||||
const found = await prisma.familyHistoryEntry.findMany({
|
||||
where: { userId: user.id, deletedAt: null },
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: limit,
|
||||
take: overfetch(limit),
|
||||
});
|
||||
const { rows, cap } = capRows(found, limit);
|
||||
|
||||
annotate({
|
||||
action: { name: "family-history.list", entity_type: "family_history" },
|
||||
meta: { count: rows.length },
|
||||
});
|
||||
|
||||
return apiSuccess(rows.map(toFamilyHistoryEntryDTO));
|
||||
return apiListSuccess(rows.map(toFamilyHistoryEntryDTO), cap);
|
||||
});
|
||||
|
||||
export const POST = apiHandler(
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
illnessEpisodeListQuerySchema,
|
||||
} from "@/lib/validations/illness";
|
||||
import { toIllnessEpisodeDTO } from "@/lib/illness/dto";
|
||||
import { apiListSuccess, capRows, overfetch } from "@/lib/api/list-cap";
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireRecordAuth("read", "illness");
|
||||
@@ -58,7 +59,7 @@ 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 rows = await prisma.illnessEpisode.findMany({
|
||||
const found = await prisma.illnessEpisode.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
deletedAt: null,
|
||||
@@ -75,16 +76,17 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
},
|
||||
// `id` breaks ties so offset paging never repeats or drops a row.
|
||||
orderBy: [{ onsetAt: order }, { id: order }],
|
||||
take: limit,
|
||||
take: overfetch(limit),
|
||||
skip: offset ?? 0,
|
||||
});
|
||||
const { rows, cap } = capRows(found, limit);
|
||||
|
||||
annotate({
|
||||
action: { name: "illness.episode.list", entity_type: "illness_episode" },
|
||||
meta: { count: rows.length },
|
||||
});
|
||||
|
||||
return apiSuccess(rows.map(toIllnessEpisodeDTO));
|
||||
return apiListSuccess(rows.map(toIllnessEpisodeDTO), cap);
|
||||
});
|
||||
|
||||
// `withIdempotency` lets an iOS retry / double-tap re-send the same
|
||||
|
||||
@@ -47,6 +47,7 @@ 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";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -133,7 +134,7 @@ export const GET = apiHandler(async () => {
|
||||
// sync.
|
||||
const { user } = await requireRecordAuth("manage", "record");
|
||||
|
||||
const rows = await prisma.ecgRecording.findMany({
|
||||
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.
|
||||
@@ -149,8 +150,9 @@ export const GET = apiHandler(async () => {
|
||||
source: true,
|
||||
},
|
||||
orderBy: { recordedAt: "desc" },
|
||||
take: MAX_RECORDINGS,
|
||||
take: overfetch(MAX_RECORDINGS),
|
||||
});
|
||||
const { rows, cap } = capRows(found, MAX_RECORDINGS);
|
||||
|
||||
const recordings = rows.map((r) => ({
|
||||
id: r.id,
|
||||
@@ -168,13 +170,16 @@ export const GET = apiHandler(async () => {
|
||||
|
||||
annotate({
|
||||
action: { name: "insights.ecg.list" },
|
||||
meta: { count: recordings.length },
|
||||
meta: { count: recordings.length, truncated: cap.truncated },
|
||||
});
|
||||
|
||||
return apiSuccess({
|
||||
recordings,
|
||||
hasRecordings: recordings.length > 0,
|
||||
});
|
||||
return apiListSuccess(
|
||||
{
|
||||
recordings,
|
||||
hasRecordings: recordings.length > 0,
|
||||
},
|
||||
cap,
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -24,12 +24,12 @@
|
||||
* (never a query field). No AI gate and no module gate: this is a pure DB
|
||||
* read of device data.
|
||||
*/
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
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";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -49,7 +49,7 @@ export const GET = apiHandler(async () => {
|
||||
// The `insights` module is the AI analysis opt-out, and nothing here was
|
||||
// written by a model.
|
||||
|
||||
const rows = await prisma.measurement.findMany({
|
||||
const found = await prisma.measurement.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
type: { in: EVENT_TYPE_LIST },
|
||||
@@ -64,8 +64,9 @@ export const GET = apiHandler(async () => {
|
||||
deviceType: true,
|
||||
},
|
||||
orderBy: { measuredAt: "desc" },
|
||||
take: MAX_EVENTS,
|
||||
take: overfetch(MAX_EVENTS),
|
||||
});
|
||||
const { rows, cap } = capRows(found, MAX_EVENTS);
|
||||
|
||||
const events = rows.map((r) => ({
|
||||
id: r.id,
|
||||
@@ -78,11 +79,14 @@ export const GET = apiHandler(async () => {
|
||||
|
||||
annotate({
|
||||
action: { name: "insights.rhythm-events" },
|
||||
meta: { count: events.length },
|
||||
meta: { count: events.length, truncated: cap.truncated },
|
||||
});
|
||||
|
||||
return apiSuccess({
|
||||
events,
|
||||
hasEvents: events.length > 0,
|
||||
});
|
||||
return apiListSuccess(
|
||||
{
|
||||
events,
|
||||
hasEvents: events.length > 0,
|
||||
},
|
||||
cap,
|
||||
);
|
||||
});
|
||||
@@ -159,7 +159,8 @@ describe("GET /api/medications/[id]/side-effects", () => {
|
||||
medicationId: "med-1",
|
||||
occurredAt: { gte: new Date(from), lt: new Date(to) },
|
||||
},
|
||||
take: 10,
|
||||
// One row past the limit, so the response can say it was cut.
|
||||
take: 11,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -39,6 +39,7 @@ 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";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
@@ -66,7 +67,7 @@ export const GET = apiHandler(
|
||||
|
||||
const { from, to, limit } = parsed.data;
|
||||
|
||||
const items = await prisma.medicationSideEffect.findMany({
|
||||
const found = await prisma.medicationSideEffect.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
medicationId: id,
|
||||
@@ -80,8 +81,9 @@ export const GET = apiHandler(
|
||||
: {}),
|
||||
},
|
||||
orderBy: { occurredAt: "desc" },
|
||||
take: limit,
|
||||
take: overfetch(limit),
|
||||
});
|
||||
const { rows: items, cap } = capRows(found, limit);
|
||||
|
||||
annotate({
|
||||
action: { name: "medication.sideEffect.list" },
|
||||
@@ -90,10 +92,13 @@ export const GET = apiHandler(
|
||||
|
||||
// Decrypt each note and strip the raw `notesEncrypted` ciphertext so it
|
||||
// never leaves the server.
|
||||
return apiSuccess({
|
||||
items: items.map(shapeSideEffectNotes),
|
||||
meta: { total: items.length },
|
||||
});
|
||||
return apiListSuccess(
|
||||
{
|
||||
items: items.map(shapeSideEffectNotes),
|
||||
meta: { total: items.length },
|
||||
},
|
||||
cap,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -57,6 +57,7 @@ import type {
|
||||
MeasurementType,
|
||||
MeasurementSource,
|
||||
} from "@/generated/prisma/client";
|
||||
import { apiListSuccess, capRows, overfetch } from "@/lib/api/list-cap";
|
||||
|
||||
function shapeRow(row: {
|
||||
id: string;
|
||||
@@ -118,7 +119,8 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
const { instrument, limit, offset } = parsed.data;
|
||||
const rows = await prisma.mentalHealthAssessment.findMany({
|
||||
const pageSize = limit ?? 100;
|
||||
const found = await prisma.mentalHealthAssessment.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
deletedAt: null,
|
||||
@@ -129,7 +131,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
// Unique tiebreaker — `takenAt` is day-resolution and two instruments
|
||||
// taken in one sitting tie.
|
||||
orderBy: [{ takenAt: "desc" }, { id: "desc" }],
|
||||
take: limit ?? 100,
|
||||
take: overfetch(pageSize),
|
||||
skip: offset ?? 0,
|
||||
select: {
|
||||
id: true,
|
||||
@@ -144,11 +146,12 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
const { rows, cap } = capRows(found, pageSize);
|
||||
annotate({
|
||||
action: { name: "mental-health.list" },
|
||||
meta: { count: rows.length },
|
||||
});
|
||||
return apiSuccess({ assessments: rows.map(shapeRow) });
|
||||
return apiListSuccess({ assessments: rows.map(shapeRow) }, cap);
|
||||
});
|
||||
|
||||
// `withIdempotency` lets a double-tap / retry re-send the same
|
||||
|
||||
@@ -67,7 +67,8 @@ describe("GET /api/personal-records", () => {
|
||||
expect(prisma.personalRecord.findMany).toHaveBeenCalledWith({
|
||||
where: { userId: "user-1" },
|
||||
orderBy: { achievedAt: "desc" },
|
||||
take: 100,
|
||||
// One row past the limit, so the response can say it was cut.
|
||||
take: 101,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -80,7 +81,8 @@ describe("GET /api/personal-records", () => {
|
||||
expect(prisma.personalRecord.findMany).toHaveBeenCalledWith({
|
||||
where: { userId: "user-1", metricType: "VO2_MAX" },
|
||||
orderBy: { achievedAt: "desc" },
|
||||
take: 100,
|
||||
// One row past the limit, so the response can say it was cut.
|
||||
take: 101,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -113,7 +115,8 @@ describe("GET /api/personal-records", () => {
|
||||
expect(prisma.personalRecord.findMany).toHaveBeenCalledWith({
|
||||
where: { userId: "user-1" },
|
||||
orderBy: { achievedAt: "desc" },
|
||||
take: 25,
|
||||
// One row past the limit, so the response can say it was cut.
|
||||
take: 26,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -126,7 +129,8 @@ describe("GET /api/personal-records", () => {
|
||||
expect(prisma.personalRecord.findMany).toHaveBeenCalledWith({
|
||||
where: { userId: "user-1" },
|
||||
orderBy: { achievedAt: "desc" },
|
||||
take: 500,
|
||||
// One row past the limit, so the response can say it was cut.
|
||||
take: 501,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -189,6 +193,53 @@ describe("GET /api/personal-records", () => {
|
||||
expect(body.data[0].metricType).toBe("VO2_MAX");
|
||||
});
|
||||
|
||||
it("says when the list was cut at its limit, and not when it is exactly full", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const row = (id: string) => ({
|
||||
id,
|
||||
userId: "user-1",
|
||||
metricType: "VO2_MAX",
|
||||
metricSlot: null,
|
||||
direction: "MAX",
|
||||
value: 40,
|
||||
unit: "mL/(kg·min)",
|
||||
achievedAt: new Date("2026-05-01T07:00:00.000Z"),
|
||||
sourceMeasurementId: null,
|
||||
source: "APPLE_HEALTH",
|
||||
externalId: null,
|
||||
createdAt: new Date(),
|
||||
});
|
||||
|
||||
// Three rows found for ?limit=2: one past the limit exists.
|
||||
vi.mocked(prisma.personalRecord.findMany).mockResolvedValue([
|
||||
row("a"),
|
||||
row("b"),
|
||||
row("c"),
|
||||
] as never);
|
||||
const cut = await GET(req("http://localhost/api/personal-records?limit=2"));
|
||||
const cutBody = (await cut.json()) as {
|
||||
data: unknown[];
|
||||
meta: { truncated: boolean; limit: number };
|
||||
};
|
||||
expect(cutBody.data).toHaveLength(2);
|
||||
expect(cutBody.meta).toMatchObject({ truncated: true, limit: 2 });
|
||||
|
||||
// Exactly two: complete, not cut.
|
||||
vi.mocked(prisma.personalRecord.findMany).mockResolvedValue([
|
||||
row("a"),
|
||||
row("b"),
|
||||
] as never);
|
||||
const full = await GET(
|
||||
req("http://localhost/api/personal-records?limit=2"),
|
||||
);
|
||||
const fullBody = (await full.json()) as {
|
||||
data: unknown[];
|
||||
meta: { truncated: boolean };
|
||||
};
|
||||
expect(fullBody.data).toHaveLength(2);
|
||||
expect(fullBody.meta.truncated).toBe(false);
|
||||
});
|
||||
|
||||
// v1.43 (ios #116 item 5) — every stored field stays, and the band for the
|
||||
// age the record was set at rides beside it.
|
||||
it("projects every stored field and adds the benchmark", async () => {
|
||||
|
||||
@@ -19,8 +19,8 @@
|
||||
* returned more rows than the caller asked for and nothing said so. The 500
|
||||
* ceiling refuses rather than clamps, for the same reason.
|
||||
*
|
||||
* Response envelope (matches the project-wide `apiSuccess` contract):
|
||||
* { data: PersonalRecord[], error: null }
|
||||
* Response envelope (the capped-list contract, `src/lib/api/list-cap.ts`):
|
||||
* { data: PersonalRecord[], error: null, meta: { truncated, limit } }
|
||||
*
|
||||
* v1.43 (ios #116 item 5) — the route projects each row instead of passing the
|
||||
* Prisma result through. Every stored field stays on the wire, and each row
|
||||
@@ -31,13 +31,14 @@ import { prisma } from "@/lib/db";
|
||||
import type { NextRequest } from "next/server";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { apiSuccess, returnAllZodIssues } from "@/lib/api-response";
|
||||
import { returnAllZodIssues } from "@/lib/api-response";
|
||||
import { listPersonalRecordsSchema } from "@/lib/validations/measurement";
|
||||
import {
|
||||
mayCarryBenchmark,
|
||||
resolveRecordBenchmark,
|
||||
} from "@/lib/personal-records/record-benchmark";
|
||||
import { toProfileSex } from "@/lib/profile/sex";
|
||||
import { apiListSuccess, capRows, overfetch } from "@/lib/api/list-cap";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -62,14 +63,15 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
}
|
||||
const { metricType, limit } = parsed.data;
|
||||
|
||||
const records = await prisma.personalRecord.findMany({
|
||||
const found = await prisma.personalRecord.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
...(metricType ? { metricType } : {}),
|
||||
},
|
||||
orderBy: { achievedAt: "desc" },
|
||||
take: limit,
|
||||
take: overfetch(limit),
|
||||
});
|
||||
const { rows: records, cap } = capRows(found, limit);
|
||||
|
||||
const wantsBand = records.some((r) => mayCarryBenchmark(r.metricType));
|
||||
const profile = wantsBand
|
||||
@@ -94,7 +96,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
weightKg: weight?.value ?? null,
|
||||
};
|
||||
|
||||
return apiSuccess(
|
||||
return apiListSuccess(
|
||||
records.map((r) => ({
|
||||
id: r.id,
|
||||
userId: r.userId,
|
||||
@@ -110,5 +112,6 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
createdAt: r.createdAt,
|
||||
benchmark: profile ? resolveRecordBenchmark(r, benchmarkProfile) : null,
|
||||
})),
|
||||
cap,
|
||||
);
|
||||
});
|
||||
Reference in new issue
Block a user