Merge branch 'fix/v1438-api' into release/v1.43.8

This commit is contained in:
Marc-André Bombeck committed 2026-10-20 15:39:39 +02:00
commit 4a0db28713
99 files changed
+5219 -1018

No files matched your search

+27 -26
View File
@@ -40,7 +40,8 @@ import { prisma } from "@/lib/db";
import { getAuditLogRetentionDays } from "@/lib/jobs/audit-log-cleanup";
import { annotate } from "@/lib/logging/context";
import { GRANT_PARTY_SELECT } from "@/lib/sharing/grant-view";
import { apiListSuccess, capRows, overfetch } from "@/lib/api/list-cap";
import { apiListSuccess } from "@/lib/api/list-cap";
import { listPage } from "@/lib/http/list-query";
export const dynamic = "force-dynamic";
@@ -99,31 +100,31 @@ export const GET = apiHandler(async () => {
const retentionDays = getAuditLogRetentionDays();
const windowStart = new Date(Date.now() - retentionDays * 86_400_000);
const found = await prisma.auditLog.findMany({
where: {
userId: user.id,
actorUserId: { not: null },
// A switch into one's own record is not a delegation and has nothing to
// report. `actingActorFor` already refuses to stamp one, so this is the
// second end of that rule rather than its only enforcement.
NOT: { actorUserId: user.id },
// The window the copy promises. The purge job makes it true eventually;
// this makes it true now, which matters on an instance whose retention
// was shortened after rows were already written.
createdAt: { gte: windowStart },
},
orderBy: { createdAt: "desc" },
take: overfetch(MAX_ROWS),
select: {
id: true,
actorUserId: true,
action: true,
details: true,
createdAt: true,
},
});
const { rows, cap } = capRows(found, MAX_ROWS);
const { rows, cap } = await listPage(MAX_ROWS, (take) =>
prisma.auditLog.findMany({
where: {
userId: user.id,
actorUserId: { not: null },
// A switch into one's own record is not a delegation and has nothing to
// report. `actingActorFor` already refuses to stamp one, so this is the
// second end of that rule rather than its only enforcement.
NOT: { actorUserId: user.id },
// The window the copy promises. The purge job makes it true eventually;
// this makes it true now, which matters on an instance whose retention
// was shortened after rows were already written.
createdAt: { gte: windowStart },
},
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
take,
select: {
id: true,
actorUserId: true,
action: true,
details: true,
createdAt: true,
},
}),
);
const actorIds = [
...new Set(rows.map((r) => r.actorUserId).filter((id) => id !== null)),
+22 -27
View File
@@ -73,7 +73,8 @@ import {
} from "@/lib/sharing/grant-view";
import { GrantError, inviteGrant } from "@/lib/sharing/grants";
import { inviteGrantSchema } from "@/lib/validations/account-sharing";
import { apiListSuccess, capRows, overfetch } from "@/lib/api/list-cap";
import { apiListSuccess } from "@/lib/api/list-cap";
import { combinedCap, listPage } from "@/lib/http/list-query";
/**
* Ten invitations an hour, per account.
@@ -92,28 +93,25 @@ export const GET = apiHandler(async () => {
const { user } = await requireAuth();
annotate({ action: { name: "sharing.grants.list" } });
const [givenFound, receivedFound] = await Promise.all([
prisma.accountGrant.findMany({
where: { grantorId: user.id },
orderBy: { createdAt: "desc" },
take: overfetch(MAX_GRANTS_PER_DIRECTION),
include: { grantee: { select: GRANT_PARTY_SELECT } },
}),
prisma.accountGrant.findMany({
where: { granteeId: user.id },
orderBy: { createdAt: "desc" },
take: overfetch(MAX_GRANTS_PER_DIRECTION),
include: { grantor: { select: GRANT_PARTY_SELECT } },
}),
]);
const { rows: given, cap: givenCap } = capRows(
givenFound,
MAX_GRANTS_PER_DIRECTION,
);
const { rows: received, cap: receivedCap } = capRows(
receivedFound,
MAX_GRANTS_PER_DIRECTION,
);
const [{ rows: given, cap: givenCap }, { rows: received, cap: receivedCap }] =
await Promise.all([
listPage(MAX_GRANTS_PER_DIRECTION, (take) =>
prisma.accountGrant.findMany({
where: { grantorId: user.id },
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
take,
include: { grantee: { select: GRANT_PARTY_SELECT } },
}),
),
listPage(MAX_GRANTS_PER_DIRECTION, (take) =>
prisma.accountGrant.findMany({
where: { granteeId: user.id },
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
take,
include: { grantor: { select: GRANT_PARTY_SELECT } },
}),
),
]);
// Revoked and expired rows come back too. They are the consent record — "who
// had access, from when to when, and who ended it" is the question the table
@@ -122,10 +120,7 @@ export const GET = apiHandler(async () => {
const now = new Date();
// One cap per direction, one signal: either list cut short is a partial
// answer.
const cap = {
truncated: givenCap.truncated || receivedCap.truncated,
limit: MAX_GRANTS_PER_DIRECTION,
};
const cap = combinedCap(givenCap, receivedCap);
return apiListSuccess(
{
given: given.map((g) => toGrantView(g, g.grantee, now)),
+16 -24
View File
@@ -15,13 +15,11 @@ import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import {
apiError,
apiSuccess,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { checkRecordWriteRateLimit } from "@/lib/rate-limit";
import { withIdempotency } from "@/lib/idempotency";
import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
import {
@@ -29,7 +27,8 @@ import {
allergyListQuerySchema,
} from "@/lib/validations/allergy";
import { toAllergyDTO } from "@/lib/records/dto";
import { apiListSuccess, capRows, overfetch } from "@/lib/api/list-cap";
import { apiListSuccess } from "@/lib/api/list-cap";
import { listPage } from "@/lib/http/list-query";
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireRecordAuth("read", "profile");
@@ -46,16 +45,19 @@ export const GET = apiHandler(async (request: NextRequest) => {
}
const limit = parsed.data.limit ?? 100;
const found = await prisma.allergy.findMany({
where: {
userId: user.id,
deletedAt: null,
...(parsed.data.includeInactive === "false" ? { status: "ACTIVE" } : {}),
},
orderBy: { createdAt: "desc" },
take: overfetch(limit),
});
const { rows, cap } = capRows(found, limit);
const { rows, cap } = await listPage(limit, (take) =>
prisma.allergy.findMany({
where: {
userId: user.id,
deletedAt: null,
...(parsed.data.includeInactive === "false"
? { status: "ACTIVE" }
: {}),
},
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
take,
}),
);
annotate({
action: { name: "allergy.list", entity_type: "allergy" },
@@ -88,17 +90,7 @@ async function postAllergy(request: NextRequest): Promise<Response> {
// A READ or WRITE delegate reaches neither: the destination is not on their
// Settings list, and this line refuses them with `sharing.access.denied`
// regardless.
const { user, actor } = await requireRecordAuth("manage", "profile");
// Shared per-account write ceiling — see `checkRecordWriteRateLimit`. The
// batch siblings have always been capped; the per-record creates a looping
// client hits were not.
const writeRl = await checkRecordWriteRateLimit(actor.id);
if (!writeRl.allowed) {
return apiError("Too many writes, try again later", 429, {
errorCode: "record_write.rate_limited",
});
}
const { user } = await requireRecordAuth("manage", "profile");
const { data: rawBody, error: jsonError } = await safeJson(request, {
maxBytes: 16 * 1024,
+5 -3
View File
@@ -5,7 +5,7 @@ import { apiSuccess, returnAllZodIssues } from "@/lib/api-response";
import { analyticsQuerySchema } from "@/lib/validations/analytics";
import { NO_STORE_BUT_BFCACHE } from "@/lib/http/cache-headers";
import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache";
import { summarize, type DataPoint } from "@/lib/analytics/trends";
import { summarize, wireSummary, type DataPoint } from "@/lib/analytics/trends";
import { readSummariesSlice } from "@/lib/analytics/summaries-slice";
import { loadRecoveryEmpty } from "@/lib/insights/recovery-empty-read";
import { resolveEffectiveBpTargets } from "@/lib/analytics/effective-range";
@@ -500,8 +500,10 @@ async function buildAnalyticsResponse(user: AuthedUser, locale: Locale) {
glucoseRows,
(r) => r.glucoseContext,
)) {
glucoseByContext[bucket] = summarize(
ctxRows.map((r): DataPoint => ({ date: r.measuredAt, value: r.value })),
glucoseByContext[bucket] = wireSummary(
summarize(
ctxRows.map((r): DataPoint => ({ date: r.measuredAt, value: r.value })),
),
);
}
@@ -0,0 +1,68 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
auditLog: { findMany: vi.fn(), create: vi.fn() },
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { GET } from "../route";
import { getSession } from "@/lib/auth/session";
import { prisma } from "@/lib/db";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "u", role: "USER" as const, locale: "en" },
};
const req = (qs = "") =>
new NextRequest(`http://localhost/api/auth/me/security-activity${qs}`);
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.auditLog.findMany).mockResolvedValue([] as never);
});
describe("GET /api/auth/me/security-activity", () => {
it("reads one row past the requested page, in a total order", async () => {
const res = await GET(req("?limit=10"));
expect(res.status).toBe(200);
const call = vi.mocked(prisma.auditLog.findMany).mock.calls[0][0] as {
take: number;
orderBy: unknown;
};
expect(call.take).toBe(11);
expect(call.orderBy).toEqual([{ createdAt: "desc" }, { id: "desc" }]);
const body = (await res.json()) as {
meta: { truncated: boolean; limit: number };
};
expect(body.meta).toMatchObject({ truncated: false, limit: 10 });
});
it("answers a limit past the published ceiling with a 422 naming it", async () => {
const res = await GET(req("?limit=500"));
expect(res.status).toBe(422);
const body = (await res.json()) as {
meta: { errorCode: string };
details: { issues: Array<{ path: string }> };
};
expect(body.meta.errorCode).toBe("security_activity.invalid");
expect(body.details.issues.map((i) => i.path)).toEqual(["limit"]);
expect(prisma.auditLog.findMany).not.toHaveBeenCalled();
});
});
+29 -21
View File
@@ -14,7 +14,9 @@ import { annotate } from "@/lib/logging/context";
import { prisma } from "@/lib/db";
import { securityActivityWhere } from "@/lib/auth/security-activity";
import { maskIp } from "@/lib/auth/device-fingerprint";
import { apiListSuccess, capRows, overfetch } from "@/lib/api/list-cap";
import { apiListSuccess } from "@/lib/api/list-cap";
import { listPage, parseListQuery } from "@/lib/http/list-query";
import { returnAllZodIssues } from "@/lib/api-response";
export const dynamic = "force-dynamic";
@@ -32,28 +34,34 @@ export interface SecurityActivityDTO {
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const limitParam = Number(
request.nextUrl.searchParams.get("limit") ?? DEFAULT_LIMIT,
);
const limit =
Number.isFinite(limitParam) && limitParam > 0
? Math.min(Math.floor(limitParam), MAX_LIMIT)
: DEFAULT_LIMIT;
const found = await prisma.auditLog.findMany({
where: securityActivityWhere(user.id),
orderBy: { createdAt: "desc" },
take: overfetch(limit),
select: {
action: true,
createdAt: true,
location: true,
ipAddress: true,
carrier: true,
},
// The shared list query: an out-of-range limit is a 422 naming it, as on
// every other list, rather than a silent clamp to something not asked for.
const query = parseListQuery(request.nextUrl.searchParams, {
defaultLimit: DEFAULT_LIMIT,
maxLimit: MAX_LIMIT,
});
if (!query.success) {
return returnAllZodIssues(query.error, 422, {
errorCode: "security_activity.invalid",
});
}
const { limit } = query.data;
const { rows, cap } = await listPage(limit, (take) =>
prisma.auditLog.findMany({
where: securityActivityWhere(user.id),
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
take,
select: {
action: true,
createdAt: true,
location: true,
ipAddress: true,
carrier: true,
},
}),
);
const { rows, cap } = capRows(found, limit);
const events: SecurityActivityDTO[] = rows.map((r) => ({
action: r.action,
createdAt: r.createdAt.toISOString(),
+1 -9
View File
@@ -31,7 +31,6 @@ import { invalidateUserHealthScore } from "@/lib/cache/invalidate";
import { withIdempotency } from "@/lib/idempotency";
import { mergeBiomarkers as runMerge } from "@/lib/labs/biomarker-merge";
import { annotate } from "@/lib/logging/context";
import { checkRecordWriteRateLimit } from "@/lib/rate-limit";
import { destroyedDetails } from "@/lib/sharing/audit-details";
import { biomarkerMergeSchema } from "@/lib/validations/biomarker-merge";
@@ -45,16 +44,9 @@ async function mergeBiomarkers(
request: NextRequest,
{ params }: RouteParams,
): Promise<Response> {
const { user, actor } = await requireRecordAuth("manage", "labs");
const { user } = await requireRecordAuth("manage", "labs");
const { id } = await params;
const writeRl = await checkRecordWriteRateLimit(actor.id);
if (!writeRl.allowed) {
return apiError("Too many writes, try again later", 429, {
errorCode: "record_write.rate_limited",
});
}
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 4 * 1024,
});
+1 -12
View File
@@ -9,7 +9,6 @@ import {
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { checkRecordWriteRateLimit } from "@/lib/rate-limit";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { withIdempotency } from "@/lib/idempotency";
@@ -76,17 +75,7 @@ async function postBiomarker(request: NextRequest) {
// duplicate pre-check and the `@@unique([userId, name])` backstop both scope
// to the same resolved id, so a delegate adding a marker the owner already
// tracks gets the ordinary 409 rather than a second copy.
const { user, actor } = await requireRecordAuth("write", "labs");
// Shared per-account write ceiling — see `checkRecordWriteRateLimit`. The
// batch siblings have always been capped; the per-record creates a looping
// client hits were not.
const writeRl = await checkRecordWriteRateLimit(actor.id);
if (!writeRl.allowed) {
return apiError("Too many writes, try again later", 429, {
errorCode: "record_write.rate_limited",
});
}
const { user } = await requireRecordAuth("write", "labs");
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 16 * 1024,
@@ -53,6 +53,22 @@ const EXISTING = {
deletedAt: null,
};
/**
* The row as the reads load it: with the newest live entry and the live entry
* count joined on, which the published row carries as `latest` and
* `entryCount`.
*/
const JOINS = {
entries: [
{
value: 42.5,
unit: "kg",
measuredAt: new Date("2026-06-03T07:00:00.000Z"),
},
],
_count: { entries: 7 },
};
const params = (id: string) => ({ params: Promise.resolve({ id }) });
function patchReq(body: unknown): NextRequest {
@@ -77,17 +93,54 @@ beforeEach(() => {
describe("GET /api/custom-metrics/[id]", () => {
it("returns the metric for its owner", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.customMetric.findFirst).mockResolvedValue(
EXISTING as never,
);
vi.mocked(prisma.customMetric.findFirst).mockResolvedValue({
...EXISTING,
...JOINS,
} as never);
const res = await GET(
new NextRequest("http://localhost/api/custom-metrics/cm-1"),
params("cm-1"),
);
expect(res.status).toBe(200);
const body = (await res.json()) as { data: { name: string } | null };
const body = (await res.json()) as {
data: {
name: string;
latest: { value: number; measuredAt: string } | null;
entryCount: number;
} | null;
};
expect(body.data?.name).toBe("Grip strength");
// The published row carries the latest value and the count on its own,
// as it does in the catalogue; the read asks for both joins.
expect(body.data?.latest).toEqual({
value: 42.5,
unit: "kg",
measuredAt: "2026-06-03T07:00:00.000Z",
});
expect(body.data?.entryCount).toBe(7);
const findCall = vi.mocked(prisma.customMetric.findFirst).mock
.calls[0][0] as { include?: Record<string, unknown> };
expect(Object.keys(findCall.include ?? {})).toEqual(["entries", "_count"]);
});
it("answers latest null and a zero count for a metric with no entries", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.customMetric.findFirst).mockResolvedValue({
...EXISTING,
entries: [],
_count: { entries: 0 },
} as never);
const res = await GET(
new NextRequest("http://localhost/api/custom-metrics/cm-1"),
params("cm-1"),
);
const body = (await res.json()) as {
data: { latest: unknown; entryCount: number };
};
expect(body.data.latest).toBeNull();
expect(body.data.entryCount).toBe(0);
});
it("404s a cross-user metric", async () => {
@@ -113,6 +166,7 @@ describe("PATCH /api/custom-metrics/[id]", () => {
);
vi.mocked(prisma.customMetric.update).mockResolvedValue({
...EXISTING,
...JOINS,
targetLow: 20,
targetHigh: 50,
} as never);
@@ -126,6 +180,12 @@ describe("PATCH /api/custom-metrics/[id]", () => {
.calls[0][0] as { data: Record<string, unknown> };
expect(updateCall.data.targetLow).toBe(20);
expect(updateCall.data.targetHigh).toBe(50);
// The edit echo is the same published row as the read.
const body = (await res.json()) as {
data: { entryCount: number; latest: { value: number } | null };
};
expect(body.data.entryCount).toBe(7);
expect(body.data.latest?.value).toBe(42.5);
});
it("persists only the explicit correlation opt-in field", async () => {
@@ -135,6 +195,7 @@ describe("PATCH /api/custom-metrics/[id]", () => {
);
vi.mocked(prisma.customMetric.update).mockResolvedValue({
...EXISTING,
...JOINS,
correlationEnabled: true,
} as never);
@@ -9,7 +9,6 @@ import {
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { checkRecordWriteRateLimit } from "@/lib/rate-limit";
import { auditLog } from "@/lib/auth/audit";
import { invalidateUserCorrelationPatterns } from "@/lib/cache/invalidate";
import { serialiseCustomMetricEntry } from "@/lib/custom-metrics/custom-metric-store";
@@ -108,15 +107,6 @@ async function postCustomMetricEntry(
// was never wrong, and the GET arm above stays delegable meanwhile.
const { user } = await requireAuth();
// Shared per-account write ceiling — see `checkRecordWriteRateLimit`. The
// batch siblings have always been capped; the per-record creates a looping
// client hits were not.
const writeRl = await checkRecordWriteRateLimit(user.id);
if (!writeRl.allowed) {
return apiError("Too many writes, try again later", 429, {
errorCode: "record_write.rate_limited",
});
}
const { id } = await params;
const metric = await prisma.customMetric.findFirst({
+26 -4
View File
@@ -11,7 +11,10 @@ import {
} from "@/lib/api-response";
import { auditLog } from "@/lib/auth/audit";
import { invalidateUserCorrelationPatterns } from "@/lib/cache/invalidate";
import { serialiseCustomMetric } from "@/lib/custom-metrics/custom-metric-store";
import {
CUSTOM_METRIC_LATEST_INCLUDE,
serialiseCustomMetricWithLatest,
} from "@/lib/custom-metrics/custom-metric-store";
import { prisma } from "@/lib/db";
import { annotate } from "@/lib/logging/context";
import { updateCustomMetricSchema } from "@/lib/validations/custom-metrics";
@@ -41,8 +44,11 @@ export const GET = apiHandler(
const { user } = await requireRecordAuth("read", "measurements");
const { id } = await params;
// The same joins as the list read: the published row carries the latest
// value and the entry count, on its own as in the catalogue.
const row = await prisma.customMetric.findFirst({
where: { id, deletedAt: null },
include: CUSTOM_METRIC_LATEST_INCLUDE,
});
if (!row || row.userId !== user.id) {
return apiError("Custom metric not found", 404);
@@ -53,7 +59,13 @@ export const GET = apiHandler(
meta: { customMetricId: id },
});
return apiSuccess(serialiseCustomMetric(row));
return apiSuccess(
serialiseCustomMetricWithLatest({
...row,
latest: row.entries[0] ?? null,
entryCount: row._count.entries,
}),
);
},
);
@@ -135,7 +147,11 @@ export const PATCH = apiHandler(
return apiError("targetLow must not exceed targetHigh", 422);
}
const updated = await prisma.customMetric.update({ where: { id }, data });
const updated = await prisma.customMetric.update({
where: { id },
data,
include: CUSTOM_METRIC_LATEST_INCLUDE,
});
await auditLog("customMetric.update", {
userId: user.id,
@@ -149,7 +165,13 @@ export const PATCH = apiHandler(
});
invalidateUserCorrelationPatterns(user.id);
return apiSuccess(serialiseCustomMetric(updated));
return apiSuccess(
serialiseCustomMetricWithLatest({
...updated,
latest: updated.entries[0] ?? null,
entryCount: updated._count.entries,
}),
);
},
);
+5 -10
View File
@@ -11,7 +11,10 @@ import {
} from "@/lib/api-response";
import { auditLog } from "@/lib/auth/audit";
import { invalidateUserCorrelationPatterns } from "@/lib/cache/invalidate";
import { serialiseCustomMetricWithLatest } from "@/lib/custom-metrics/custom-metric-store";
import {
CUSTOM_METRIC_LATEST_INCLUDE,
serialiseCustomMetricWithLatest,
} from "@/lib/custom-metrics/custom-metric-store";
import { prisma } from "@/lib/db";
import { withIdempotency } from "@/lib/idempotency";
import { annotate } from "@/lib/logging/context";
@@ -34,15 +37,7 @@ export const GET = apiHandler(async () => {
const rows = await prisma.customMetric.findMany({
where: { userId: user.id, deletedAt: null },
orderBy: { name: "asc" },
include: {
entries: {
where: { deletedAt: null },
orderBy: { measuredAt: "desc" },
take: 1,
select: { value: true, unit: true, measuredAt: true },
},
_count: { select: { entries: { where: { deletedAt: null } } } },
},
include: CUSTOM_METRIC_LATEST_INCLUDE,
});
annotate({
+9 -7
View File
@@ -19,7 +19,8 @@ import { toMenstrualCycleDTO } from "@/lib/cycle/dto";
import { median, mad } from "@/lib/cycle";
import { dayDiff } from "@/lib/cycle/day-math";
import { MIN_CYCLES_TO_PREDICT } from "@/lib/cycle/types";
import { apiListSuccess, capRows, overfetch } from "@/lib/api/list-cap";
import { apiListSuccess } from "@/lib/api/list-cap";
import { listPage } from "@/lib/http/list-query";
const DEFAULT_LIMIT = 24;
@@ -51,12 +52,13 @@ export const GET = apiHandler(async (request: NextRequest) => {
}
const limit = parsed.data.limit ?? DEFAULT_LIMIT;
const found = await prisma.menstrualCycle.findMany({
where: { userId: user.id, deletedAt: null },
orderBy: { startDate: "desc" },
take: overfetch(limit),
});
const { rows, cap } = capRows(found, limit);
const { rows, cap } = await listPage(limit, (take) =>
prisma.menstrualCycle.findMany({
where: { userId: user.id, deletedAt: null },
orderBy: [{ startDate: "desc" }, { id: "desc" }],
take,
}),
);
// Completed lengths (non-null lengthDays) drive the length stats.
const lengths = rows
+4 -1
View File
@@ -57,7 +57,10 @@ interface EntryResult {
export const POST = apiHandler(withIdempotency<[NextRequest]>(postBulk));
async function postBulk(request: NextRequest): Promise<Response> {
const { user } = await requireAuth();
// Metered by this route's own bulk bucket below, not the shared per-account
// write bucket: the native client syncs through here in bursts (see
// `meterAccountWrite`).
const { user } = await requireAuth(undefined, { writeBucket: "own" });
const gate = await requireCycleEnabled(user.id, user.gender);
if (!gate.enabled) return gate.response;
+1 -12
View File
@@ -26,7 +26,6 @@ import {
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { checkRecordWriteRateLimit } from "@/lib/rate-limit";
import { withIdempotency } from "@/lib/idempotency";
import { requireCycleEnabled } from "@/lib/cycle/gate";
import {
@@ -95,21 +94,11 @@ async function postDayLog(request: NextRequest): Promise<Response> {
// v1.37.0 — MANAGE. The upsert replaces the day, which is what edit means
// here; the gate below resolves `user.gender` against the RECORD, so the
// module follows the record rather than the caller.
const { user, actor } = await requireRecordAuth("manage", "cycle");
const { user } = await requireRecordAuth("manage", "cycle");
const gate = await requireCycleEnabled(user.id, user.gender);
if (!gate.enabled) return gate.response;
// Shared per-account write ceiling — see `checkRecordWriteRateLimit`. The
// batch siblings have always been capped; the per-record creates a looping
// client hits were not.
const writeRl = await checkRecordWriteRateLimit(actor.id);
if (!writeRl.allowed) {
return apiError("Too many writes, try again later", 429, {
errorCode: "record_write.rate_limited",
});
}
const { data: rawBody, error: jsonError } = await safeJson(request, {
maxBytes: 64 * 1024,
});
+11 -9
View File
@@ -50,6 +50,7 @@ import {
sanitiseZodIssues,
} from "@/lib/api-response";
import { getAiCapability } from "@/lib/ai/capabilities/gate";
import { listPage } from "@/lib/http/list-query";
import { prisma } from "@/lib/db";
import { hashQueryTokens } from "@/lib/documents/content-index";
import {
@@ -710,16 +711,17 @@ export const GET = apiHandler(async (request: Request) => {
// Hardening: the list NEVER fetches the encrypted blob column — a page of
// 50 rows would otherwise drag up to 50 × cap ciphertext bytes per request.
const rows = await prisma.inboundDocument.findMany({
where,
omit: { contentEncrypted: true },
orderBy,
take: limit + 1,
...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}),
});
const { rows: page, cap } = await listPage(limit, (take) =>
prisma.inboundDocument.findMany({
where,
omit: { contentEncrypted: true },
orderBy,
take,
...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}),
}),
);
const hasMore = rows.length > limit;
const page = hasMore ? rows.slice(0, limit) : rows;
const hasMore = cap.truncated;
const nextCursor = hasMore ? (page[page.length - 1]?.id ?? null) : null;
// Fact tallies for the whole page in ONE grouped count — no per-document
+31 -34
View File
@@ -25,7 +25,8 @@ import {
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { checkRecordWriteRateLimit } from "@/lib/rate-limit";
import { apiListSuccess } from "@/lib/api/list-cap";
import { combinedCap, listPage } from "@/lib/http/list-query";
import { withIdempotency } from "@/lib/idempotency";
import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
import {
@@ -109,27 +110,33 @@ export const GET = apiHandler(async (request: NextRequest) => {
// past reads newest-first, which is the opposite direction; resolving both
// here means the client renders two lists instead of splitting and
// re-sorting one, and the ordering lives in one place.
const [upcoming, past] = await Promise.all([
prisma.encounter.findMany({
// `gt` and `gte` are different keys, so the caller's lower bound and this
// arm's floor both survive the spread; only the `lte` pair collided.
where: { ...where, occurredAt: { ...where.occurredAt, gt: now } },
orderBy: [{ occurredAt: "asc" }, { id: "asc" }],
take,
skip,
include: ENCOUNTER_INCLUDE,
}),
prisma.encounter.findMany({
where: {
...where,
occurredAt: { ...where.occurredAt, lte: pastCeiling },
},
orderBy: [{ occurredAt: "desc" }, { id: "desc" }],
take,
skip,
include: ENCOUNTER_INCLUDE,
}),
]);
const [{ rows: upcoming, cap: upcomingCap }, { rows: past, cap: pastCap }] =
await Promise.all([
listPage(take, (overTake) =>
prisma.encounter.findMany({
// `gt` and `gte` are different keys, so the caller's lower bound and
// this arm's floor both survive the spread; only the `lte` pair
// collided.
where: { ...where, occurredAt: { ...where.occurredAt, gt: now } },
orderBy: [{ occurredAt: "asc" }, { id: "asc" }],
take: overTake,
skip,
include: ENCOUNTER_INCLUDE,
}),
),
listPage(take, (overTake) =>
prisma.encounter.findMany({
where: {
...where,
occurredAt: { ...where.occurredAt, lte: pastCeiling },
},
orderBy: [{ occurredAt: "desc" }, { id: "desc" }],
take: overTake,
skip,
include: ENCOUNTER_INCLUDE,
}),
),
]);
// The links ride the list. The card counts what a visit produced, and the
// edit sheet seeds its pickers from the row it was handed — a list without
@@ -152,24 +159,14 @@ export const GET = apiHandler(async (request: NextRequest) => {
upcoming: upcoming.map((row) => toEncounterDTO(row, links.get(row.id))),
past: past.map((row) => toEncounterDTO(row, links.get(row.id))),
};
return apiSuccess(body);
return apiListSuccess(body, combinedCap(upcomingCap, pastCap));
});
// A double-tap on "save" must not file the same visit twice.
export const POST = apiHandler(withIdempotency<[NextRequest]>(postEncounter));
async function postEncounter(request: NextRequest): Promise<Response> {
const { user, actor, grantId } = await requireRecordAuth("write", "profile");
// Shared per-account write ceiling — see `checkRecordWriteRateLimit`. The
// batch siblings have always been capped; the per-record creates a looping
// client hits were not.
const writeRl = await checkRecordWriteRateLimit(actor.id);
if (!writeRl.allowed) {
return apiError("Too many writes, try again later", 429, {
errorCode: "record_write.rate_limited",
});
}
const { user, grantId } = await requireRecordAuth("write", "profile");
const { data: rawBody, error: jsonError } = await safeJson(request, {
maxBytes: 32 * 1024,
+9 -7
View File
@@ -28,7 +28,8 @@ import {
familyHistoryListQuerySchema,
} from "@/lib/validations/family-history";
import { toFamilyHistoryEntryDTO } from "@/lib/records/dto";
import { apiListSuccess, capRows, overfetch } from "@/lib/api/list-cap";
import { apiListSuccess } from "@/lib/api/list-cap";
import { listPage } from "@/lib/http/list-query";
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireRecordAuth("read", "profile");
@@ -44,12 +45,13 @@ export const GET = apiHandler(async (request: NextRequest) => {
}
const limit = parsed.data.limit ?? 100;
const found = await prisma.familyHistoryEntry.findMany({
where: { userId: user.id, deletedAt: null },
orderBy: { createdAt: "desc" },
take: overfetch(limit),
});
const { rows, cap } = capRows(found, limit);
const { rows, cap } = await listPage(limit, (take) =>
prisma.familyHistoryEntry.findMany({
where: { userId: user.id, deletedAt: null },
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
take,
}),
);
annotate({
action: { name: "family-history.list", entity_type: "family_history" },
@@ -150,7 +150,10 @@ describe("GET /api/feature-flags", () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
FIND.mockResolvedValue(null);
const res = await GET(req());
expect(res.headers.get("Deprecation")).toBe("true");
// RFC 9745: the deprecation date as `@` and Unix seconds — 2026-09-24.
expect(res.headers.get("Deprecation")).toBe("@1790208000");
// RFC 8594: the first day it may stop answering.
expect(res.headers.get("Sunset")).toBe("Wed, 31 Mar 2027 00:00:00 GMT");
expect(res.headers.get("Link")).toBe(
'</api/auth/me>; rel="successor-version"',
);
+7 -6
View File
@@ -12,8 +12,10 @@
* "insightStatus", "documentAi" } } }
*
* The master is applied (every sub-switch reads false when it is off), and a
* switch read that fails answers every switch off. The response carries
* `Deprecation: true` and a `Link` to its successor.
* switch read that fails answers every switch off. The response carries the
* deprecation date, the sunset and a `Link` to its successor
* (`src/lib/http/deprecation.ts`), and the wide event names the client build
* that still asked.
*
* `Cache-Control: private, max-age=60`: switches flip rarely.
*/
@@ -22,9 +24,10 @@ import type { NextRequest } from "next/server";
import { apiHandler, requireActorAuth } from "@/lib/api-handler";
import { apiSuccess } from "@/lib/api-response";
import { getAssistantFlags } from "@/lib/feature-flags";
import { deprecated } from "@/lib/http/deprecation";
import { annotate } from "@/lib/logging/context";
export const GET = apiHandler(async (_request: NextRequest) => {
export const GET = apiHandler(async (request: NextRequest) => {
// An actor surface, and the easiest call in the set: the answer comes off
// the `AppSettings` singleton and reads no user row at all, so there is no
// record for a switch to substitute. It stays reachable while a switch is on
@@ -45,7 +48,5 @@ export const GET = apiHandler(async (_request: NextRequest) => {
const response = apiSuccess({ assistant });
response.headers.set("Cache-Control", "private, max-age=60");
response.headers.set("Deprecation", "true");
response.headers.set("Link", '</api/auth/me>; rel="successor-version"');
return response;
return deprecated(response, "/api/feature-flags", request);
});
+26 -22
View File
@@ -32,7 +32,8 @@ import {
illnessEpisodeListQuerySchema,
} from "@/lib/validations/illness";
import { toIllnessEpisodeDTO } from "@/lib/illness/dto";
import { apiListSuccess, capRows, overfetch } from "@/lib/api/list-cap";
import { apiListSuccess } from "@/lib/api/list-cap";
import { listPage } from "@/lib/http/list-query";
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireRecordAuth("read", "illness");
@@ -59,27 +60,30 @@ export const GET = apiHandler(async (request: NextRequest) => {
const { from, to, q, offset, sortDir } = parsed.data;
const limit = parsed.data.limit ?? 50;
const order = sortDir ?? "desc";
const found = await prisma.illnessEpisode.findMany({
where: {
userId: user.id,
deletedAt: null,
...(parsed.data.includeResolved === "false" ? { resolvedAt: null } : {}),
...(from || to
? {
onsetAt: {
...(from ? { gte: new Date(from) } : {}),
...(to ? { lte: new Date(to) } : {}),
},
}
: {}),
...(q ? { label: { contains: q, mode: "insensitive" as const } } : {}),
},
// `id` breaks ties so offset paging never repeats or drops a row.
orderBy: [{ onsetAt: order }, { id: order }],
take: overfetch(limit),
skip: offset ?? 0,
});
const { rows, cap } = capRows(found, limit);
const { rows, cap } = await listPage(limit, (take) =>
prisma.illnessEpisode.findMany({
where: {
userId: user.id,
deletedAt: null,
...(parsed.data.includeResolved === "false"
? { resolvedAt: null }
: {}),
...(from || to
? {
onsetAt: {
...(from ? { gte: new Date(from) } : {}),
...(to ? { lte: new Date(to) } : {}),
},
}
: {}),
...(q ? { label: { contains: q, mode: "insensitive" as const } } : {}),
},
// `id` breaks ties so offset paging never repeats or drops a row.
orderBy: [{ onsetAt: order }, { id: order }],
take,
skip: offset ?? 0,
}),
);
annotate({
action: { name: "illness.episode.list", entity_type: "illness_episode" },
+26 -21
View File
@@ -47,7 +47,8 @@ import { checkRateLimit } from "@/lib/rate-limit";
import { prisma } from "@/lib/db";
import { persistEcgRecording } from "@/lib/ecg/persist-recording";
import { isoDateTime } from "@/lib/validations/datetime";
import { apiListSuccess, capRows, overfetch } from "@/lib/api/list-cap";
import { apiListSuccess } from "@/lib/api/list-cap";
import { listPage } from "@/lib/http/list-query";
export const dynamic = "force-dynamic";
@@ -134,25 +135,26 @@ export const GET = apiHandler(async () => {
// sync.
const { user } = await requireRecordAuth("manage", "record");
const found = await prisma.ecgRecording.findMany({
where: { userId: user.id },
// Everything EXCEPT `waveformEncrypted` — the list never touches the
// encrypted blob, so no decrypt happens on this path.
select: {
id: true,
recordedAt: true,
durationSeconds: true,
samplingFrequency: true,
sampleCount: true,
averageHeartRate: true,
lead: true,
rhythmClassification: true,
source: true,
},
orderBy: { recordedAt: "desc" },
take: overfetch(MAX_RECORDINGS),
});
const { rows, cap } = capRows(found, MAX_RECORDINGS);
const { rows, cap } = await listPage(MAX_RECORDINGS, (take) =>
prisma.ecgRecording.findMany({
where: { userId: user.id },
// Everything EXCEPT `waveformEncrypted` — the list never touches the
// encrypted blob, so no decrypt happens on this path.
select: {
id: true,
recordedAt: true,
durationSeconds: true,
samplingFrequency: true,
sampleCount: true,
averageHeartRate: true,
lead: true,
rhythmClassification: true,
source: true,
},
orderBy: [{ recordedAt: "desc" }, { id: "desc" }],
take,
}),
);
const recordings = rows.map((r) => ({
id: r.id,
@@ -201,7 +203,10 @@ export const GET = apiHandler(async () => {
* to the same row by construction rather than by a cached response.
*/
export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
// Metered by this route's own bulk bucket below, not the shared per-account
// write bucket: the native client syncs through here in bursts (see
// `meterAccountWrite`).
const { user } = await requireAuth(undefined, { writeBucket: "own" });
// A device ingest carries no model text: switching AI off, or the AI
// analysis opt-out, must never refuse a recording.
+21 -19
View File
@@ -29,7 +29,8 @@ import { annotate } from "@/lib/logging/context";
import { prisma } from "@/lib/db";
import { EVENT_MEASUREMENT_TYPES } from "@/lib/validations/measurement";
import type { MeasurementType } from "@/generated/prisma/client";
import { apiListSuccess, capRows, overfetch } from "@/lib/api/list-cap";
import { apiListSuccess } from "@/lib/api/list-cap";
import { listPage } from "@/lib/http/list-query";
export const dynamic = "force-dynamic";
@@ -49,24 +50,25 @@ export const GET = apiHandler(async () => {
// The `insights` module is the AI analysis opt-out, and nothing here was
// written by a model.
const found = await prisma.measurement.findMany({
where: {
userId: user.id,
type: { in: EVENT_TYPE_LIST },
deletedAt: null,
},
select: {
id: true,
type: true,
rhythmClassification: true,
measuredAt: true,
source: true,
deviceType: true,
},
orderBy: { measuredAt: "desc" },
take: overfetch(MAX_EVENTS),
});
const { rows, cap } = capRows(found, MAX_EVENTS);
const { rows, cap } = await listPage(MAX_EVENTS, (take) =>
prisma.measurement.findMany({
where: {
userId: user.id,
type: { in: EVENT_TYPE_LIST },
deletedAt: null,
},
select: {
id: true,
type: true,
rhythmClassification: true,
measuredAt: true,
source: true,
deviceType: true,
},
orderBy: [{ measuredAt: "desc" }, { id: "desc" }],
take,
}),
);
const events = rows.map((r) => ({
id: r.id,
@@ -33,6 +33,12 @@ vi.mock("@/lib/rate-limit", async (importOriginal) => ({
resetAt: new Date(Date.now() + 60_000),
})),
}));
// The real meter, so the ceiling is proven on the path it now takes: the route
// resolves its caller, the resolver charges the bucket. The unit setup stubs
// the meter for every other route test.
vi.mock("@/lib/auth/write-meter", async (importOriginal) =>
importOriginal<typeof import("@/lib/auth/write-meter")>(),
);
const tzMock = vi.hoisted(() => ({ zone: "UTC" }));
vi.mock("@/lib/tz/resolver", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/tz/resolver")>()),
@@ -216,7 +222,7 @@ describe("POST /api/labs/ocr/commit dedup across the user's day", () => {
});
describe("POST /api/labs/ocr/commit write ceiling", () => {
it("charges the shared per-account record-write bucket", async () => {
it("charges the shared per-account record-write bucket where the caller is resolved", async () => {
const res = await POST(postReq({ rows: [numericRow(95)] }));
expect(res.status).toBe(200);
expect(checkRecordWriteRateLimit).toHaveBeenCalledWith("user-1");
-13
View File
@@ -20,7 +20,6 @@ import { fireAndForget } from "@/lib/logging/fire-and-forget";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
getClientIp,
returnAllZodIssues,
@@ -31,7 +30,6 @@ import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { invalidateUserHealthScore } from "@/lib/cache/invalidate";
import { withIdempotency } from "@/lib/idempotency";
import { checkRecordWriteRateLimit } from "@/lib/rate-limit";
import { enqueueReminderSatisfy } from "@/lib/jobs/reminder-satisfy";
import { emitDataArrival } from "@/lib/arrivals/emit-shared";
import { rememberImportAlias } from "@/lib/labs/biomarker-aliases";
@@ -159,17 +157,6 @@ function inBatchKey(
async function commitOcrRows(request: NextRequest) {
const { user } = await requireAuth();
// Shared per-account write ceiling, the same bucket every other lab write
// charges (`POST /api/labs`, `POST /api/biomarkers`). A commit can write a
// whole report's rows and mint markers on the way, so it was the one lab
// write a looping client could repeat without any ceiling.
const writeRl = await checkRecordWriteRateLimit(user.id);
if (!writeRl.allowed) {
return apiError("Too many writes, try again later", 429, {
errorCode: "record_write.rate_limited",
});
}
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 256 * 1024,
});
+1 -12
View File
@@ -10,7 +10,6 @@ import {
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { checkRecordWriteRateLimit } from "@/lib/rate-limit";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { invalidateUserHealthScore } from "@/lib/cache/invalidate";
@@ -156,17 +155,7 @@ async function postLabResult(request: NextRequest) {
// it mints it under `user.id`: the marker joins the record's catalogue, not
// the caller's, which is the only reading of "add a result to your record"
// that leaves the owner with a usable catalogue afterwards.
const { user, actor } = await requireRecordAuth("write", "labs");
// Shared per-account write ceiling — see `checkRecordWriteRateLimit`. The
// batch siblings have always been capped; the per-record creates a looping
// client hits were not.
const writeRl = await checkRecordWriteRateLimit(actor.id);
if (!writeRl.allowed) {
return apiError("Too many writes, try again later", 429, {
errorCode: "record_write.rate_limited",
});
}
const { user } = await requireRecordAuth("write", "labs");
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 16 * 1024,
-9
View File
@@ -21,7 +21,6 @@ import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
getClientIp,
returnAllZodIssues,
@@ -36,7 +35,6 @@ import {
toLifeEventDTO,
} from "@/lib/life-events/service";
import { annotate } from "@/lib/logging/context";
import { checkRecordWriteRateLimit } from "@/lib/rate-limit";
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
@@ -53,13 +51,6 @@ export const POST = apiHandler(withIdempotency<[NextRequest]>(postLifeEvent));
async function postLifeEvent(request: NextRequest): Promise<Response> {
const { user } = await requireAuth();
const writeRl = await checkRecordWriteRateLimit(user.id);
if (!writeRl.allowed) {
return apiError("Too many writes, try again later", 429, {
errorCode: "record_write.rate_limited",
});
}
const { data: rawBody, error: jsonError } = await safeJson(request, {
maxBytes: 16 * 1024,
});
+6 -1
View File
@@ -293,7 +293,12 @@ async function postBatch(request: NextRequest): Promise<Response> {
// a native login mints. `requireAuth` refuses any acting-account carrier for
// every credential shape, so the scoped token is confined to its owner's own
// record here without this route adding an arm of its own.
const auth = await requireAuth(MEASUREMENTS_WRITE_SCOPE);
// Metered by this route's own bulk bucket below, not the shared per-account
// write bucket: the native client syncs through here in bursts (see
// `meterAccountWrite`).
const auth = await requireAuth(MEASUREMENTS_WRITE_SCOPE, {
writeBucket: "own",
});
const { user } = auth;
// A scoped credential is a bridge — a scale, a home-automation rule — and not
-11
View File
@@ -17,7 +17,6 @@ import {
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { checkRecordWriteRateLimit } from "@/lib/rate-limit";
import {
createMeasurementSchema,
createBatchMeasurementSchema,
@@ -403,16 +402,6 @@ async function postMeasurement(request: NextRequest) {
const { user } = auth;
const scoped = isScopedCredential(auth);
// Shared per-account write ceiling — see `checkRecordWriteRateLimit`. The
// batch siblings have always been capped; the per-record creates a looping
// client hits were not.
const writeRl = await checkRecordWriteRateLimit(auth.actor.id);
if (!writeRl.allowed) {
return apiError("Too many writes, try again later", 429, {
errorCode: "record_write.rate_limited",
});
}
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 64 * 1024,
});
@@ -10,7 +10,6 @@ import {
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { checkRecordWriteRateLimit } from "@/lib/rate-limit";
import {
intakeSchema,
listIntakeEventsSchema,
@@ -62,16 +61,6 @@ async function postIntake(request: NextRequest, { params }: RouteParams) {
"medications",
);
// Shared per-account write ceiling — see `checkRecordWriteRateLimit`. The
// batch siblings have always been capped; the per-record creates a looping
// client hits were not.
const writeRl = await checkRecordWriteRateLimit(actor.id);
if (!writeRl.allowed) {
return apiError("Too many writes, try again later", 429, {
errorCode: "record_write.rate_limited",
});
}
// v1.36.1 — the sibling route `POST /api/medications/intake`
// refuses a delegate changing a dose the owner already recorded. This route
// reaches the same rows through the slot upsert, so without the same rule it
@@ -39,7 +39,8 @@ import { categoryForEntry } from "@/lib/medications/side-effects/taxonomy";
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
import { encryptNote, shapeSideEffectNotes } from "@/lib/crypto/note-cipher";
import { withIdempotency } from "@/lib/idempotency";
import { apiListSuccess, capRows, overfetch } from "@/lib/api/list-cap";
import { apiListSuccess } from "@/lib/api/list-cap";
import { listPage } from "@/lib/http/list-query";
type RouteParams = { params: Promise<{ id: string }> };
@@ -67,23 +68,24 @@ export const GET = apiHandler(
const { from, to, limit } = parsed.data;
const found = await prisma.medicationSideEffect.findMany({
where: {
userId: user.id,
medicationId: id,
...(from || to
? {
occurredAt: {
...(from ? { gte: from } : {}),
...(to ? { lt: to } : {}),
},
}
: {}),
},
orderBy: { occurredAt: "desc" },
take: overfetch(limit),
});
const { rows: items, cap } = capRows(found, limit);
const { rows: items, cap } = await listPage(limit, (take) =>
prisma.medicationSideEffect.findMany({
where: {
userId: user.id,
medicationId: id,
...(from || to
? {
occurredAt: {
...(from ? { gte: from } : {}),
...(to ? { lt: to } : {}),
},
}
: {}),
},
orderBy: [{ occurredAt: "desc" }, { id: "desc" }],
take,
}),
);
annotate({
action: { name: "medication.sideEffect.list" },
+4 -1
View File
@@ -197,7 +197,10 @@ interface EntryResult {
export const POST = apiHandler(withIdempotency<[NextRequest]>(postBulk));
async function postBulk(request: NextRequest): Promise<Response> {
const auth = await requireAuth();
// Metered by this route's own bulk bucket below, not the shared per-account
// write bucket: the native client syncs through here in bursts (see
// `meterAccountWrite`).
const auth = await requireAuth(undefined, { writeBucket: "own" });
const { user } = auth;
// A narrow, single-purpose credential (a bridge, an ingest token) is no
// Apple Health sync, so it never writes the dose arrival record below. The
-11
View File
@@ -21,7 +21,6 @@ import {
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { checkRecordWriteRateLimit } from "@/lib/rate-limit";
import { annotate } from "@/lib/logging/context";
import { prisma } from "@/lib/db";
import { auditLog } from "@/lib/auth/audit";
@@ -212,16 +211,6 @@ export const POST = apiHandler(async (request: NextRequest) => {
// notification at the end of this handler.
const { user, actor } = await requireRecordAuth("write", "medications");
// Shared per-account write ceiling — see `checkRecordWriteRateLimit`. The
// batch siblings have always been capped; the per-record creates a looping
// client hits were not.
const writeRl = await checkRecordWriteRateLimit(actor.id);
if (!writeRl.allowed) {
return apiError("Too many writes, try again later", 429, {
errorCode: "record_write.rate_limited",
});
}
const { data: body, error } = await safeJson(request, {
maxBytes: 64 * 1024,
});
+30 -28
View File
@@ -57,7 +57,8 @@ import type {
MeasurementType,
MeasurementSource,
} from "@/generated/prisma/client";
import { apiListSuccess, capRows, overfetch } from "@/lib/api/list-cap";
import { apiListSuccess } from "@/lib/api/list-cap";
import { listPage } from "@/lib/http/list-query";
function shapeRow(row: {
id: string;
@@ -120,33 +121,34 @@ export const GET = apiHandler(async (request: NextRequest) => {
}
const { instrument, limit, offset } = parsed.data;
const pageSize = limit ?? 100;
const found = await prisma.mentalHealthAssessment.findMany({
where: {
userId: user.id,
deletedAt: null,
// Screeners only: the table also stores AUDIT-C (v1.43), which older
// app builds cannot decode in this list.
instrument: instrument ?? { in: [...SCREENER_INSTRUMENTS] },
},
// Unique tiebreaker — `takenAt` is day-resolution and two instruments
// taken in one sitting tie.
orderBy: [{ takenAt: "desc" }, { id: "desc" }],
take: overfetch(pageSize),
skip: offset ?? 0,
select: {
id: true,
instrument: true,
locale: true,
version: true,
totalScore: true,
severityBand: true,
item9Flagged: true,
crisisShownAt: true,
takenAt: true,
createdAt: true,
},
});
const { rows, cap } = capRows(found, pageSize);
const { rows, cap } = await listPage(pageSize, (take) =>
prisma.mentalHealthAssessment.findMany({
where: {
userId: user.id,
deletedAt: null,
// Screeners only: the table also stores AUDIT-C (v1.43), which older
// app builds cannot decode in this list.
instrument: instrument ?? { in: [...SCREENER_INSTRUMENTS] },
},
// Unique tiebreaker — `takenAt` is day-resolution and two instruments
// taken in one sitting tie.
orderBy: [{ takenAt: "desc" }, { id: "desc" }],
take,
skip: offset ?? 0,
select: {
id: true,
instrument: true,
locale: true,
version: true,
totalScore: true,
severityBand: true,
item9Flagged: true,
crisisShownAt: true,
takenAt: true,
createdAt: true,
},
}),
);
annotate({
action: { name: "mental-health.list" },
meta: { count: rows.length },
@@ -68,7 +68,7 @@ vi.mock("next/headers", () => ({
})),
}));
import { PUT } from "../route";
import { GET, PUT } from "../route";
import { prisma } from "@/lib/db";
import { auditLog } from "@/lib/auth/audit";
import { getSession } from "@/lib/auth/session";
@@ -138,6 +138,43 @@ beforeEach(() => {
);
});
describe("GET /api/mood-entries/[id] — the list's wire shape", () => {
it("carries the structured tags and rated factors, as the list does", async () => {
vi.mocked(prisma.moodEntry.findFirst).mockResolvedValue({
...EXISTING_ENTRY,
tagLinks: [
{ rating: null, moodTag: { key: "sleep_good", kind: "BINARY" } },
{ rating: 4, moodTag: { key: "energy", kind: "RATED" } },
],
context: null,
} as never);
const res = await GET(
new NextRequest("http://localhost/api/mood-entries/me1"),
ROUTE_CTX,
);
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: {
tagKeys: string[];
ratedFactors: Array<{ key: string; rating: number }>;
tagLinks?: unknown;
};
};
expect(body.data.tagKeys).toEqual(["sleep_good"]);
expect(body.data.ratedFactors).toEqual([{ key: "energy", rating: 4 }]);
// The join is projected, never shipped raw.
expect(body.data.tagLinks).toBeUndefined();
const findCall = vi.mocked(prisma.moodEntry.findFirst).mock.calls[0][0] as {
include?: Record<string, unknown>;
};
expect(Object.keys(findCall.include ?? {}).sort()).toEqual([
"context",
"tagLinks",
]);
});
});
describe("PUT /api/mood-entries/[id] — 422 multi-issue (v1.4.43 W6)", () => {
it("surfaces TWO simultaneous validation errors", async () => {
// Bad `mood` enum + bad `moodLoggedAt`.
+6 -12
View File
@@ -15,10 +15,11 @@ import { Prisma } from "@/generated/prisma/client";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { moodDateKey } from "@/lib/mood/date-key";
import { MOOD_ENTRY_WIRE_INCLUDE, toMoodEntryWire } from "@/lib/mood/list-read";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
import { deriveA1, shapeLevelA } from "@/lib/mood/level-a";
import { contextForWire, persistMoodContext } from "@/lib/mood/context";
import { encryptNote, shapeMoodNote } from "@/lib/crypto/note-cipher";
import { encryptNote } from "@/lib/crypto/note-cipher";
import { invalidateUserMood } from "@/lib/cache/invalidate";
import { recomputeMoodBucketsForEntry } from "@/lib/rollups/mood-rollups";
import {
@@ -50,9 +51,9 @@ export const GET = apiHandler(
// (not `findUnique`) because `deletedAt` is not part of a unique index.
const entry = await prisma.moodEntry.findFirst({
where: { id, deletedAt: null },
// v1.38 — the day context rides with the entry rather than behind a
// second request: the edit surface needs both or neither.
include: { context: true },
// The same joins and the same projection as the list read, so an
// entry opened on its own carries `tagKeys` and `ratedFactors` too.
include: MOOD_ENTRY_WIRE_INCLUDE,
});
if (!entry || entry.userId !== user.id) {
@@ -64,14 +65,7 @@ export const GET = apiHandler(
meta: { moodEntryId: id },
});
const { context, ...row } = entry;
return apiSuccess({
// v1.37 — level-A values under the keys the write path takes.
...shapeLevelA(shapeMoodNote(row)),
tags: parseTags(row.tags),
// v1.38 — lists decoded, note decrypted, ciphertext dropped.
context: context ? contextForWire(context) : null,
});
return apiSuccess(toMoodEntryWire(entry));
},
);
+4 -1
View File
@@ -194,7 +194,10 @@ interface EntryResult {
export const POST = apiHandler(withIdempotency<[NextRequest]>(postBulk));
async function postBulk(request: NextRequest): Promise<Response> {
const { user } = await requireAuth();
// Metered by this route's own bulk bucket below, not the shared per-account
// write bucket: the native client syncs through here in bursts (see
// `meterAccountWrite`).
const { user } = await requireAuth(undefined, { writeBucket: "own" });
const rl = await checkRateLimit(
`mood-entries:bulk:${user.id}`,
-11
View File
@@ -8,7 +8,6 @@ import {
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { checkRecordWriteRateLimit } from "@/lib/rate-limit";
import {
createMoodEntrySchema,
listMoodEntriesSchema,
@@ -109,16 +108,6 @@ async function postMoodEntry(request: NextRequest) {
// the `externalId` refusal below.
const { user, actor } = await requireRecordAuth("manage", "mind");
// Shared per-account write ceiling — see `checkRecordWriteRateLimit`. The
// batch siblings have always been capped; the per-record creates a looping
// client hits were not.
const writeRl = await checkRecordWriteRateLimit(actor.id);
if (!writeRl.allowed) {
return apiError("Too many writes, try again later", 429, {
errorCode: "record_write.rate_limited",
});
}
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 64 * 1024,
});
+4 -1
View File
@@ -79,7 +79,10 @@ interface EntryResult {
export const POST = apiHandler(withIdempotency<[NextRequest]>(postBatch));
async function postBatch(request: NextRequest): Promise<Response> {
const { user } = await requireAuth();
// Metered by this route's own bulk bucket below, not the shared per-account
// write bucket: the native client syncs through here in bursts (see
// `meterAccountWrite`).
const { user } = await requireAuth(undefined, { writeBucket: "own" });
// Module gate first: a disabled module must refuse before any body
// work, so iOS can branch on the 403 `module.disabled` errorCode and
@@ -66,7 +66,7 @@ describe("GET /api/personal-records", () => {
expect(body.error).toBeNull();
expect(prisma.personalRecord.findMany).toHaveBeenCalledWith({
where: { userId: "user-1" },
orderBy: { achievedAt: "desc" },
orderBy: [{ achievedAt: "desc" }, { id: "desc" }],
// One row past the limit, so the response can say it was cut.
take: 101,
});
@@ -80,7 +80,7 @@ describe("GET /api/personal-records", () => {
expect(res.status).toBe(200);
expect(prisma.personalRecord.findMany).toHaveBeenCalledWith({
where: { userId: "user-1", metricType: "VO2_MAX" },
orderBy: { achievedAt: "desc" },
orderBy: [{ achievedAt: "desc" }, { id: "desc" }],
// One row past the limit, so the response can say it was cut.
take: 101,
});
@@ -114,7 +114,7 @@ describe("GET /api/personal-records", () => {
expect(res.status).toBe(200);
expect(prisma.personalRecord.findMany).toHaveBeenCalledWith({
where: { userId: "user-1" },
orderBy: { achievedAt: "desc" },
orderBy: [{ achievedAt: "desc" }, { id: "desc" }],
// One row past the limit, so the response can say it was cut.
take: 26,
});
@@ -128,7 +128,7 @@ describe("GET /api/personal-records", () => {
expect(res.status).toBe(200);
expect(prisma.personalRecord.findMany).toHaveBeenCalledWith({
where: { userId: "user-1" },
orderBy: { achievedAt: "desc" },
orderBy: [{ achievedAt: "desc" }, { id: "desc" }],
// One row past the limit, so the response can say it was cut.
take: 501,
});
+12 -10
View File
@@ -38,7 +38,8 @@ import {
resolveRecordBenchmark,
} from "@/lib/personal-records/record-benchmark";
import { toProfileSex } from "@/lib/profile/sex";
import { apiListSuccess, capRows, overfetch } from "@/lib/api/list-cap";
import { apiListSuccess } from "@/lib/api/list-cap";
import { listPage } from "@/lib/http/list-query";
export const dynamic = "force-dynamic";
@@ -63,15 +64,16 @@ export const GET = apiHandler(async (request: NextRequest) => {
}
const { metricType, limit } = parsed.data;
const found = await prisma.personalRecord.findMany({
where: {
userId: user.id,
...(metricType ? { metricType } : {}),
},
orderBy: { achievedAt: "desc" },
take: overfetch(limit),
});
const { rows: records, cap } = capRows(found, limit);
const { rows: records, cap } = await listPage(limit, (take) =>
prisma.personalRecord.findMany({
where: {
userId: user.id,
...(metricType ? { metricType } : {}),
},
orderBy: [{ achievedAt: "desc" }, { id: "desc" }],
take,
}),
);
const wantsBand = records.some((r) => mayCarryBenchmark(r.metricType));
const profile = wantsBand
+44 -36
View File
@@ -33,6 +33,8 @@ import {
practitionerListQuerySchema,
} from "@/lib/validations/practitioners";
import { toPractitionerDTO } from "@/lib/practitioners/dto";
import { apiListSuccess } from "@/lib/api/list-cap";
import { listPage } from "@/lib/http/list-query";
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireRecordAuth("read", "profile");
@@ -41,6 +43,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
const parsed = practitionerListQuerySchema.safeParse({
q: params.get("q") ?? undefined,
limit: params.get("limit") ?? undefined,
offset: params.get("offset") ?? undefined,
});
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422, {
@@ -48,50 +51,55 @@ export const GET = apiHandler(async (request: NextRequest) => {
});
}
const rows = await prisma.practitioner.findMany({
where: {
userId: user.id,
deletedAt: null,
// Three plaintext columns, because a person looks a practice up by
// whichever they remember: the doctor's name on the referral, the
// practice name on the door, or the field they need ("Zahnmedizin").
// Searching only name + practice made the specialty attempt return
// nothing and read as "not in the address book".
...(parsed.data.q
? {
OR: [
{
name: {
contains: parsed.data.q,
mode: "insensitive" as const,
const limit = parsed.data.limit ?? 100;
const { rows, cap } = await listPage(limit, (take) =>
prisma.practitioner.findMany({
where: {
userId: user.id,
deletedAt: null,
// Three plaintext columns, because a person looks a practice up by
// whichever they remember: the doctor's name on the referral, the
// practice name on the door, or the field they need ("Zahnmedizin").
// Searching only name + practice made the specialty attempt return
// nothing and read as "not in the address book".
...(parsed.data.q
? {
OR: [
{
name: {
contains: parsed.data.q,
mode: "insensitive" as const,
},
},
},
{
practice: {
contains: parsed.data.q,
mode: "insensitive" as const,
{
practice: {
contains: parsed.data.q,
mode: "insensitive" as const,
},
},
},
{
specialty: {
contains: parsed.data.q,
mode: "insensitive" as const,
{
specialty: {
contains: parsed.data.q,
mode: "insensitive" as const,
},
},
},
],
}
: {}),
},
orderBy: { name: "asc" },
take: parsed.data.limit ?? 100,
});
],
}
: {}),
},
// Two contacts can share a name; the id keeps the page boundary stable.
orderBy: [{ name: "asc" }, { id: "asc" }],
skip: parsed.data.offset ?? 0,
take,
}),
);
annotate({
action: { name: "practitioner.contact.list", entity_type: "practitioner" },
meta: { count: rows.length },
meta: { count: rows.length, truncated: cap.truncated },
});
return apiSuccess(rows.map(toPractitionerDTO));
return apiListSuccess(rows.map(toPractitionerDTO), cap);
});
// A double-tap on "save" must not mint a second copy of the same practice.
+18 -11
View File
@@ -15,6 +15,8 @@
*/
import { NextRequest } from "next/server";
import { listPage } from "@/lib/http/list-query";
import { prisma } from "@/lib/db";
import {
apiSuccess,
@@ -65,16 +67,18 @@ export const GET = apiHandler(async (request: NextRequest) => {
if (q.from) occurredAt.gte = new Date(q.from);
if (q.to) occurredAt.lte = new Date(q.to);
const rows = await prisma.symptomEvent.findMany({
where: {
userId: user.id,
...(q.from || q.to ? { occurredAt } : {}),
...(q.definitionId ? { definitionId: q.definitionId } : {}),
...(q.episodeId ? { episodeId: q.episodeId } : {}),
},
orderBy: [{ occurredAt: "desc" }, { id: "desc" }],
take: q.limit,
});
const { rows, cap } = await listPage(q.limit, (take) =>
prisma.symptomEvent.findMany({
where: {
userId: user.id,
...(q.from || q.to ? { occurredAt } : {}),
...(q.definitionId ? { definitionId: q.definitionId } : {}),
...(q.episodeId ? { episodeId: q.episodeId } : {}),
},
orderBy: [{ occurredAt: "desc" }, { id: "desc" }],
take,
}),
);
annotate({
action: { name: "symptoms.event.list" },
@@ -84,7 +88,10 @@ export const GET = apiHandler(async (request: NextRequest) => {
return apiSuccess({
events: rows.map(toSymptomEventDTO),
limit: q.limit,
hasMore: rows.length === q.limit,
// Exact: true only when a row past this page exists. `rows.length ===
// limit` said true on a page that was exactly full, and the client then
// asked for a page that held nothing.
hasMore: cap.truncated,
});
});
+1 -11
View File
@@ -31,7 +31,6 @@ import {
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { checkRecordWriteRateLimit } from "@/lib/rate-limit";
import { customVaccineCreateSchema } from "@/lib/validations/vaccinations";
import { toCustomVaccineDTO } from "@/lib/vaccinations/dto";
import {
@@ -57,16 +56,7 @@ export const GET = apiHandler(async () => {
});
export const POST = apiHandler(async (request: NextRequest) => {
const { user, actor } = await requireRecordAuth("write", "profile");
// The shared per-account write ceiling, keyed on the ACTOR so a delegate
// spends their own allowance (`checkRecordWriteRateLimit`).
const writeRl = await checkRecordWriteRateLimit(actor.id);
if (!writeRl.allowed) {
return apiError("Too many writes, try again later", 429, {
errorCode: "record_write.rate_limited",
});
}
const { user } = await requireRecordAuth("write", "profile");
const { data: rawBody, error: jsonError } = await safeJson(request, {
maxBytes: 8 * 1024,
+31 -34
View File
@@ -29,8 +29,9 @@ import {
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { checkRecordWriteRateLimit } from "@/lib/rate-limit";
import { withIdempotency } from "@/lib/idempotency";
import { apiListSuccess } from "@/lib/api/list-cap";
import { listPage } from "@/lib/http/list-query";
import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
import {
vaccinationCreateSchema,
@@ -62,6 +63,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
from: params.get("from") ?? undefined,
to: params.get("to") ?? undefined,
limit: params.get("limit") ?? undefined,
offset: params.get("offset") ?? undefined,
});
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422, {
@@ -70,29 +72,34 @@ export const GET = apiHandler(async (request: NextRequest) => {
}
const query = parsed.data;
// Full and bounded rather than paged: an Impfpass is a lifetime document but
// a small one, and the default already covers a long life.
// A lifetime document but a small one: the default page already covers a
// long life, and `meta.truncated` says when it does not.
const take = query.limit ?? 300;
const [rows, series] = await Promise.all([
prisma.vaccinationRecord.findMany({
where: {
userId: user.id,
deletedAt: null,
...(query.antigenSlug ? { antigenSlug: query.antigenSlug } : {}),
...(query.from || query.to
? {
occurredAt: {
...(query.from ? { gte: new Date(query.from) } : {}),
...(query.to ? { lte: new Date(query.to) } : {}),
},
}
: {}),
},
orderBy: { occurredAt: "desc" },
take,
include: VACCINATION_INCLUDE,
}),
const [{ rows, cap }, series] = await Promise.all([
listPage(take, (overTake) =>
prisma.vaccinationRecord.findMany({
where: {
userId: user.id,
deletedAt: null,
...(query.antigenSlug ? { antigenSlug: query.antigenSlug } : {}),
...(query.from || query.to
? {
occurredAt: {
...(query.from ? { gte: new Date(query.from) } : {}),
...(query.to ? { lte: new Date(query.to) } : {}),
},
}
: {}),
},
// Two doses on one day are common (a combined appointment); the id
// keeps the page boundary between them stable.
orderBy: [{ occurredAt: "desc" }, { id: "desc" }],
skip: query.offset ?? 0,
take: overTake,
include: VACCINATION_INCLUDE,
}),
),
// Over the WHOLE live set, never over the filtered page: a position is a
// count of what came before it, so deriving it from a window would report
// the oldest dose in that window as the first one ever given.
@@ -135,24 +142,14 @@ export const GET = apiHandler(async (request: NextRequest) => {
),
renewals,
};
return apiSuccess(body);
return apiListSuccess(body, cap);
});
// A double-tap on "save" must not log the same dose twice.
export const POST = apiHandler(withIdempotency<[NextRequest]>(postVaccination));
async function postVaccination(request: NextRequest): Promise<Response> {
const { user, actor } = await requireRecordAuth("write", "profile");
// Shared per-account write ceiling — see `checkRecordWriteRateLimit`. The
// batch siblings have always been capped; the per-record creates a looping
// client hits were not.
const writeRl = await checkRecordWriteRateLimit(actor.id);
if (!writeRl.allowed) {
return apiError("Too many writes, try again later", 429, {
errorCode: "record_write.rate_limited",
});
}
const { user } = await requireRecordAuth("write", "profile");
const { data: rawBody, error: jsonError } = await safeJson(request, {
maxBytes: 32 * 1024,
+6 -1
View File
@@ -239,7 +239,12 @@ async function postBatch(request: NextRequest): Promise<Response> {
// native login mints. The only route that names it. `requireAuth` refuses
// any acting-account carrier for every credential shape, so the scoped token
// is confined to its owner's own record without an arm of its own here.
const auth = await requireAuth(WORKOUTS_WRITE_SCOPE);
// Metered by this route's own bulk bucket below, not the shared per-account
// write bucket: the native client syncs through here in bursts (see
// `meterAccountWrite`).
const auth = await requireAuth(WORKOUTS_WRITE_SCOPE, {
writeBucket: "own",
});
const { user } = auth;
// A scoped credential is a bridge, not the native client: the source of