mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +00:00
Merge branch 'fix/v1437-data' into release/v1.43.7
This commit is contained in:
commit
dee6a27ae4
100 files changed
+2314
-506
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
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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)", () => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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" }),
|
||||
);
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in new issue
Block a user