Merge branch 'fix/v1437-data' into release/v1.43.7

This commit is contained in:
Marc-André Bombeck committed 2026-10-20 08:12:32 +02:00
commit dee6a27ae4
100 files changed
+2314 -506

No files matched your search

+21 -16
View File
@@ -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,
);
});
+40 -22
View File
@@ -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) => {
+5 -3
View File
@@ -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
+2 -1
View File
@@ -8,6 +8,7 @@ import { prisma } from "@/lib/db";
import { auditLog } from "@/lib/auth/audit";
import { getClientIp, apiSuccess, apiError } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { INSIGHTS_CACHE_CLEARED } from "@/lib/insights/cached-text-cipher";
/**
* v1.4.7.1: Device-code flow polling endpoint.
@@ -81,7 +82,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
codexConnectedAt: new Date(),
codexConnectionStatus: "connected",
insightsCachedAt: null,
insightsCachedText: null,
...INSIGHTS_CACHE_CLEARED,
insightsCachedLocale: null,
},
});
+2 -1
View File
@@ -5,6 +5,7 @@ import { prisma } from "@/lib/db";
import { auditLog } from "@/lib/auth/audit";
import { apiSuccess, getClientIp } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { INSIGHTS_CACHE_CLEARED } from "@/lib/insights/cached-text-cipher";
export const DELETE = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
@@ -21,7 +22,7 @@ export const DELETE = apiHandler(async (request: NextRequest) => {
codexConnectedAt: null,
codexConnectionStatus: "disconnected",
insightsCachedAt: null,
insightsCachedText: null,
...INSIGHTS_CACHE_CLEARED,
insightsCachedLocale: null,
},
});
@@ -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);
});
@@ -227,6 +227,7 @@ describe("about-me module gate", () => {
data: {
insightsCachedAt: null,
insightsCachedText: null,
insightsCachedTextEncrypted: null,
insightsCachedLocale: null,
},
});
+2 -1
View File
@@ -69,6 +69,7 @@ import {
aboutMeAiSections,
type StoredHealthProfileAiSection,
} from "@/lib/validations/health-profile-facts";
import { INSIGHTS_CACHE_CLEARED } from "@/lib/insights/cached-text-cipher";
const PUT_RATE_LIMIT = 30;
const PUT_WINDOW_MS = 60_000;
@@ -319,7 +320,7 @@ export const PUT = apiHandler(async (req: Request) => {
where: { id: user.id },
data: {
insightsCachedAt: null,
insightsCachedText: null,
...INSIGHTS_CACHE_CLEARED,
insightsCachedLocale: null,
},
});
+16 -15
View File
@@ -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,
);
});
@@ -4,9 +4,9 @@
* Until v1.4.41 this route returned `parsed.error.issues[0].message`,
* which dropped every issue past the first. iOS contract debugging
* needed one round-trip per wrong field. The route now returns every
* issue under `details.issues` AND writes a
* `dashboard.widgets.validation-failed` audit-ledger row so the
* operator can grep `/api/admin/audit` for the same trail.
* issue under `details.issues` and keeps the refusal on the wide event.
* Since v1.43.7 it files no audit row: the write resolves the caller, and a
* caller's own refusal is telemetry, not an audit event.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
@@ -17,6 +17,10 @@ const transactionUser = vi.hoisted(() => ({
update: vi.fn(),
}));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn(async () => undefined),
}));
vi.mock("@/lib/db", () => ({
prisma: {
user: {
@@ -91,6 +95,7 @@ import {
type DashboardLayoutWithToken,
} from "@/lib/dashboard-layout";
import { PRIORITY_ITEM_KINDS } from "@/lib/daily/priority-item";
import { auditLog } from "@/lib/auth/audit";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
@@ -191,49 +196,14 @@ describe("PUT /api/dashboard/widgets — 422 multi-issue envelope (v1.4.42 W2)",
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("writes one audit-ledger row keyed dashboard.widgets.validation-failed", async () => {
it("files no audit row for the caller's own refusal", async () => {
const res = await callPut(makeReq({ version: 2, widgets: [] }));
expect(res.status).toBe(422);
// The audit row is fire-and-forget — let the microtask queue drain.
await new Promise((r) => setTimeout(r, 5));
expect(prisma.auditLog.create).toHaveBeenCalledTimes(1);
const call = vi.mocked(prisma.auditLog.create).mock.calls[0]?.[0] as {
data: { userId: string; action: string; details: string };
};
expect(call.data.userId).toBe("user-1");
expect(call.data.action).toBe("dashboard.widgets.validation-failed");
const details = JSON.parse(call.data.details) as {
issues: Array<{ path: string; code: string }>;
};
expect(details.issues.length).toBe(2);
for (const issue of details.issues) {
// v1.4.49 — audit row is the persisted surface. `message`
// strips here so a future Zod code that embeds the offending
// value cannot leak into the ledger.
expect(Object.keys(issue).sort()).toEqual(["code", "path"]);
}
});
it("dedups the audit-ledger write across two sequential 422s for the same user (v1.4.43 B2)", async () => {
// First 422 writes one row; the second 422 inside the 60 s window
// returns the same envelope but skips the audit insert.
const res1 = await callPut(makeReq({ version: 2, widgets: [] }));
expect(res1.status).toBe(422);
const res2 = await callPut(makeReq({ version: 2, widgets: [] }));
expect(res2.status).toBe(422);
await new Promise((r) => setTimeout(r, 5));
expect(prisma.auditLog.create).toHaveBeenCalledTimes(1);
// The full multi-issue envelope still rides on every 422 — the
// dedup only suppresses the breadcrumb write, never the response.
const body2 = (await res2.json()) as {
details: { issues: Array<unknown> };
};
expect(body2.details.issues.length).toBe(2);
// The refusal is telemetry on the wide event; the write resolves the
// caller, so no delegate's attempt can reach this route.
expect(auditLog).not.toHaveBeenCalled();
expect(prisma.auditLog.create).not.toHaveBeenCalled();
});
it("surfaces received_keys + received_shape_excerpt + zod_issues in the wide-event meta (v1.4.48 H-iOS-1)", async () => {
@@ -296,20 +266,6 @@ describe("PUT /api/dashboard/widgets — 422 multi-issue envelope (v1.4.42 W2)",
expect((meta.received_shape_excerpt as string).length).toBe(256);
});
it("does not block the 422 response when the audit-row write rejects", async () => {
vi.mocked(prisma.auditLog.create).mockRejectedValueOnce(
new Error("db down"),
);
const res = await callPut(makeReq({ version: 2, widgets: [] }));
// The response is the contract — the audit row is best-effort.
expect(res.status).toBe(422);
const body = (await res.json()) as {
details: { issues: Array<unknown> };
};
expect(body.details.issues.length).toBe(2);
});
it("redacts sensitive keys before writing the wide-event received_shape_excerpt (v1.4.49)", async () => {
// Caller adds a credential-shaped field — must not land in the
// wide-event excerpt verbatim. The denylist also covers nested
@@ -353,25 +309,6 @@ describe("PUT /api/dashboard/widgets — 422 multi-issue envelope (v1.4.42 W2)",
]),
);
});
it("strips `message` from the audit-ledger issues row (v1.4.49)", async () => {
// The wide-event meta keeps full message for operator debugging,
// but the persisted auditLog row must only carry `path` + `code`
// so a future Zod code that embeds the offending value cannot
// leak into the ledger.
await callPut(makeReq({ version: 2, widgets: [] }));
expect(prisma.auditLog.create).toHaveBeenCalledTimes(1);
const call = vi.mocked(prisma.auditLog.create).mock.calls[0]?.[0] as {
data: { details: string };
};
const parsed = JSON.parse(call.data.details) as {
issues: Array<Record<string, unknown>>;
};
for (const issue of parsed.issues) {
expect(Object.keys(issue).sort()).toEqual(["code", "path"]);
}
});
});
describe("PUT /api/dashboard/widgets — accept-and-ignore unknown ids (v1.7.0 #9)", () => {
+4 -32
View File
@@ -67,7 +67,6 @@ const widgetIdEnum = z.enum(DASHBOARD_WIDGET_CATALOGUE_IDS);
// `/api/dashboard/widgets` and `/api/dashboard/chart-overlay-prefs` share
// the same 60 s `(userId, action)` window.
export { __resetAuditDedupMemoForTests } from "@/lib/audit-dedup";
import { shouldEmitAuditRow } from "@/lib/audit-dedup";
const layoutSchema = z.object({
version: z.literal(1),
@@ -360,37 +359,10 @@ export const PUT = apiHandler(async (request: NextRequest) => {
zod_issues: issues,
},
});
// v1.4.43 B2 — gate the breadcrumb behind a 60 s (userId, action) dedup
// so a misbehaving iOS client retrying every second cannot pump
// thousands of identical rows into the audit ledger.
if (
shouldEmitAuditRow(
user.id,
"dashboard.widgets.validation-failed",
Date.now(),
)
) {
// Best-effort breadcrumb — never block the 422 on a write miss.
// v1.4.49 — strip `message` from the audit-ledger row so Zod
// codes that embed the offending value in their default message
// (`invalid_enum_value` and similar) cannot leak user content
// through the audit surface. The wide-event excerpt above
// already carries the shape signal for operator debugging.
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
stripValuesFromMessage: true,
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "dashboard.widgets.validation-failed",
details: JSON.stringify({ issues: auditIssues }),
},
})
.catch(() => {
/* swallow — validation response is the contract, audit row is best-effort */
});
}
// No audit row: this write resolves the caller (`requireAuth`), so the
// refusal can only ever be the caller's own, which is telemetry and
// lives on the wide event above. Only a delegate's refused attempt is
// filed (see `isRejectedAttemptAction` in `src/lib/auth/audit.ts`).
return returnAllZodIssues(parsed.error, 422);
}
+5 -3
View File
@@ -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(
+5 -3
View File
@@ -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
+12 -7
View File
@@ -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,
);
});
/**
@@ -175,6 +175,10 @@ import {
import { annotate } from "@/lib/logging/context";
import { buildUserPrompt } from "@/lib/ai/prompts/insight-system-prompt";
import { AiUnavailableError } from "@/lib/ai/capabilities/refusal";
import { openInsightsCachedText } from "@/lib/insights/cached-text-cipher";
// The cached payload is sealed at rest, so its writes need a key.
vi.stubEnv("ENCRYPTION_KEY", "c".repeat(64));
beforeEach(() => {
vi.clearAllMocks();
@@ -455,7 +459,7 @@ describe("POST /api/insights/generate — cache write (v1.16.8)", () => {
const args = vi.mocked(prisma.user.updateMany).mock.calls[0][0] as {
data: Record<string, unknown>;
};
expect(args.data.insightsCachedText).toEqual(expect.any(String));
expect(openInsightsCachedText(args.data)).toEqual(expect.any(String));
// SHA-256 hex digest of the compacted feature snapshot.
expect(args.data.insightsSnapshotHash).toMatch(/^[0-9a-f]{64}$/);
// The cache row records the language it was written in, so a reader
+19 -15
View File
@@ -110,6 +110,11 @@ import {
enqueueStatusRefillForUser,
} from "@/lib/insights/comprehensive-generate";
import { cachedPayloadCarriesBriefing } from "@/lib/insights/briefing-payload";
import {
INSIGHTS_CACHE_SELECT,
insightsCacheWrite,
openInsightsCachedText,
} from "@/lib/insights/cached-text-cipher";
export const dynamic = "force-dynamic";
@@ -176,12 +181,13 @@ export const GET = apiHandler(async (request: NextRequest) => {
where: { id: userId },
select: {
insightsCachedAt: true,
insightsCachedText: true,
...INSIGHTS_CACHE_SELECT,
insightsCachedLocale: true,
locale: true,
timezone: true,
},
});
const cachedText = openInsightsCachedText(dbUser);
// Resolve the locale the caller is actually reading (cookie /
// Accept-Language fall-back when `User.locale` is unset) and validate it
@@ -211,7 +217,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
// text was generated: `insightsCachedAt` also moves when an unchanged
// warm only re-stamps yesterday's text.
const generatedToday = generatedOnLocalToday(
readBriefingGeneratedAt(dbUser?.insightsCachedText ?? null),
readBriefingGeneratedAt(cachedText),
timezone,
new Date(),
);
@@ -267,9 +273,9 @@ export const GET = apiHandler(async (request: NextRequest) => {
revalidating = true;
}
if (dbUser?.insightsCachedText && !languageMismatch) {
if (cachedText && !languageMismatch) {
try {
const cached = JSON.parse(dbUser.insightsCachedText);
const cached = JSON.parse(cachedText);
const legacyPayload = isLegacyInsightPayload(cached);
annotate({
action: { name: "insights.generate.read" },
@@ -355,7 +361,7 @@ export const POST = apiHandler((request: NextRequest) =>
select: {
insightsPrivacyMode: true,
insightsCachedAt: true,
insightsCachedText: true,
...INSIGHTS_CACHE_SELECT,
insightsCachedLocale: true,
timezone: true,
// v1.4.36 W3 T3 — per-user opt-out list mirroring the Coach
@@ -378,6 +384,7 @@ export const POST = apiHandler((request: NextRequest) =>
glucoseUnit: true,
},
});
const cachedText = openInsightsCachedText(dbUser);
const modeAtRead = dbUser?.insightsPrivacyMode ?? "aggregated";
// v1.34 — the commit guard below (line ~966) matches on these exact
// columns instead of the broad `updatedAt` timestamp. Every User write
@@ -439,12 +446,12 @@ export const POST = apiHandler((request: NextRequest) =>
if (
!forceRefresh &&
dbUser?.insightsCachedAt &&
dbUser.insightsCachedText &&
cachedText &&
cachedLocaleMatches &&
Date.now() - dbUser.insightsCachedAt.getTime() < 24 * 60 * 60 * 1000
) {
try {
const cached = JSON.parse(dbUser.insightsCachedText);
const cached = JSON.parse(cachedText);
// v1.4.16 B5c: detect legacy payloads (pre-rationale) so the UI
// can show a "regenerate for new explainability features" CTA.
// We don't auto-regenerate — that would burn rate-limit tokens
@@ -454,9 +461,9 @@ export const POST = apiHandler((request: NextRequest) =>
// a briefingless cache, it does not short-circuit a provider-backed
// account, and degrades to the cached payload on a 429 below.
const carriesBriefing =
cachedPayloadCarriesBriefing(dbUser.insightsCachedText) &&
cachedPayloadCarriesBriefing(cachedText) &&
generatedOnLocalToday(
readBriefingGeneratedAt(dbUser.insightsCachedText),
readBriefingGeneratedAt(cachedText),
postTimezone,
new Date(),
);
@@ -1042,12 +1049,9 @@ export const POST = apiHandler((request: NextRequest) =>
// state still fires exactly when the user really has no briefing.
if (ungrounded.length > 0 && insights && typeof insights === "object") {
let fallbackBriefing: unknown = null;
if (dbUser?.insightsCachedText) {
if (cachedText) {
try {
const prev = JSON.parse(dbUser.insightsCachedText) as Record<
string,
unknown
>;
const prev = JSON.parse(cachedText) as Record<string, unknown>;
fallbackBriefing = prev.dailyBriefing ?? null;
} catch {
// Unparseable previous payload — nothing safe to stand in.
@@ -1109,7 +1113,7 @@ export const POST = apiHandler((request: NextRequest) =>
},
data: {
insightsCachedAt: new Date(),
insightsCachedText: JSON.stringify(stored),
...insightsCacheWrite(JSON.stringify(stored)),
insightsCachedLocale: locale,
insightsSnapshotHash: hashInsightSnapshot({
features: compactFeatures,
+12 -8
View File
@@ -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,
);
});
+2 -1
View File
@@ -4,6 +4,7 @@ import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { isCodexOAuthConfigured } from "@/lib/ai/codex-oauth";
import { INSIGHTS_CACHE_CLEARED } from "@/lib/insights/cached-text-cipher";
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
@@ -76,7 +77,7 @@ export const PUT = apiHandler(async (request: NextRequest) => {
}
data.insightsPrivacyMode = mode;
data.insightsCachedAt = null;
data.insightsCachedText = null;
Object.assign(data, INSIGHTS_CACHE_CLEARED);
data.insightsCachedLocale = null;
}
@@ -493,13 +493,14 @@ describe("a batch of duplicates only (v1.42)", () => {
await post([weight("uuid-new", new Date(NOW - DAY))]);
expect(enqueuePrDetection).toHaveBeenCalledTimes(1);
expect(enqueueReminderSatisfy).toHaveBeenCalledTimes(1);
// The enqueue is telemetry on the wide event, never an audit row.
expect(
vi
.mocked(auditLog)
.mock.calls.some(
([action]) => action === "personal_records.detection_enqueued",
),
).toBe(true);
).toBe(false);
});
});
+3 -7
View File
@@ -1302,13 +1302,9 @@ async function postBatch(request: NextRequest): Promise<Response> {
});
try {
await enqueuePrDetection(user.id, { silent });
await auditLog("personal_records.detection_enqueued", {
userId: user.id,
details: {
source: "measurement.batch",
batchSize: entries.length,
silent,
},
// Telemetry, not an audit event: it rides the wide event.
annotate({
meta: { pr_detection_enqueued: true, pr_detection_silent: silent },
});
} catch (err) {
// Enqueue failure is non-fatal — the 30-minute fallback cron
@@ -1,6 +1,12 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
// The breadcrumb's contract is the call; whether `auditLog` writes a row
// (only for a delegate's request) is tested in its own suite.
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn(async () => undefined),
}));
vi.mock("@/lib/db", () => ({
prisma: {
measurement: { findMany: vi.fn(), count: vi.fn() },
@@ -58,6 +64,7 @@ import { GET } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
@@ -1023,17 +1030,18 @@ describe("GET /api/measurements/series — 422 multi-issue (v1.4.43 W6)", () =>
expect(body.details.issues.length).toBeGreaterThanOrEqual(2);
});
it("writes a measurements.series.validation-failed audit row", async () => {
it("files a measurements.series.validation-failed breadcrumb through auditLog", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await GET(req("kind=garbage&days=0"));
expect(res.status).toBe(422);
await new Promise((r) => setTimeout(r, 5));
expect(prisma.auditLog.create).toHaveBeenCalledTimes(1);
const call = vi.mocked(prisma.auditLog.create).mock.calls[0]?.[0] as {
data: { userId: string; action: string };
};
expect(call.data.userId).toBe("user-1");
expect(call.data.action).toBe("measurements.series.validation-failed");
expect(auditLog).toHaveBeenCalledTimes(1);
const [action, opts] = vi.mocked(auditLog).mock.calls[0] as unknown as [
string,
{ userId: string },
];
expect(opts.userId).toBe("user-1");
expect(action).toBe("measurements.series.validation-failed");
});
it("surfaces received_keys + received_shape_excerpt + zod_issues in the wide-event meta (v1.4.48 H-iOS-2)", async () => {
@@ -1086,9 +1094,7 @@ describe("GET /api/measurements/series — 422 multi-issue (v1.4.43 W6)", () =>
it("does not block the 422 when the audit-row write rejects", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.auditLog.create).mockRejectedValueOnce(
new Error("db down"),
);
vi.mocked(auditLog).mockRejectedValueOnce(new Error("db down"));
const res = await GET(req("kind=garbage&days=0"));
expect(res.status).toBe(422);
});
@@ -1126,13 +1132,13 @@ describe("GET /api/measurements/series — 422 multi-issue (v1.4.43 W6)", () =>
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
await GET(req("kind=garbage&days=0"));
expect(prisma.auditLog.create).toHaveBeenCalledTimes(1);
const call = vi.mocked(prisma.auditLog.create).mock.calls[0]?.[0] as {
data: { details: string };
};
const parsed = JSON.parse(call.data.details) as {
issues: Array<Record<string, unknown>>;
};
expect(auditLog).toHaveBeenCalledTimes(1);
const parsed = (
vi.mocked(auditLog).mock.calls[0] as unknown as [
string,
{ details: { issues: Array<Record<string, unknown>> } },
]
)[1].details;
for (const issue of parsed.issues) {
expect(Object.keys(issue).sort()).toEqual(["code", "path"]);
}
@@ -344,7 +344,7 @@ describe("POST /api/medications/[id]/intake/import — durable kickoff", () => {
await expect(res.json()).resolves.toEqual({
data: null,
error: "Medication intake import already in progress",
meta: { requestId: expect.any(String) },
meta: { requestId: expect.any(String), errorCode: "http.conflict" },
});
expect(prisma.medicationIntakeImportJob.create).not.toHaveBeenCalled();
expect(getGlobalBoss).not.toHaveBeenCalled();
@@ -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 () => {
+9 -6
View File
@@ -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,
);
});
@@ -122,7 +122,10 @@ describe("POST /api/settings/email/test", () => {
expect(response.status).toBe(500);
const json = await response.json();
expect(json.meta).toEqual({ requestId: expect.any(String) });
expect(json.meta).toEqual({
requestId: expect.any(String),
errorCode: "http.internal",
});
});
it("answers a delivered test with 200", async () => {
@@ -187,7 +187,11 @@ describe("PUT /api/settings/webhook — private targets", () => {
expect(response.status).toBe(422);
const json = await response.json();
expect(json.error).toBe("Invalid data");
expect(json.meta).toEqual({ requestId: expect.any(String) });
// No private-origin code: only the status's generic one.
expect(json.meta).toEqual({
requestId: expect.any(String),
errorCode: "http.invalid",
});
});
it("saves a listed loopback target and refuses an unlisted one with the listable code", async () => {
@@ -476,7 +480,10 @@ describe("POST /api/settings/{webhook,ntfy}/test — the refusal names itself",
expect(response.status).toBe(500);
const json = await response.json();
expect(json.meta).toEqual({ requestId: expect.any(String) });
expect(json.meta).toEqual({
requestId: expect.any(String),
errorCode: "http.internal",
});
expect(emitIfSampledMock).toHaveBeenCalledWith(
expect.objectContaining({ level: "error" }),
);
+3 -7
View File
@@ -881,13 +881,9 @@ async function postBatch(request: NextRequest): Promise<Response> {
const silent = workouts.length > PR_DETECTION_SILENT_THRESHOLD;
try {
await enqueuePrDetection(user.id, { silent });
await auditLog("personal_records.detection_enqueued", {
userId: user.id,
details: {
source: "workout.batch",
batchSize: workouts.length,
silent,
},
// Telemetry, not an audit event: it rides the wide event.
annotate({
meta: { pr_detection_enqueued: true, pr_detection_silent: silent },
});
} catch (err) {
annotate({