mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 22:02:16 +00:00
Merge branch 'fix/v1438-api' into release/v1.43.8
This commit is contained in:
commit
4a0db28713
99 files changed
+5219
-1018
No files matched your search
@@ -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)),
|
||||
|
||||
@@ -73,7 +73,8 @@ import {
|
||||
} from "@/lib/sharing/grant-view";
|
||||
import { GrantError, inviteGrant } from "@/lib/sharing/grants";
|
||||
import { inviteGrantSchema } from "@/lib/validations/account-sharing";
|
||||
import { apiListSuccess, capRows, overfetch } from "@/lib/api/list-cap";
|
||||
import { apiListSuccess } from "@/lib/api/list-cap";
|
||||
import { combinedCap, listPage } from "@/lib/http/list-query";
|
||||
|
||||
/**
|
||||
* Ten invitations an hour, per account.
|
||||
@@ -92,28 +93,25 @@ export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "sharing.grants.list" } });
|
||||
|
||||
const [givenFound, receivedFound] = await Promise.all([
|
||||
prisma.accountGrant.findMany({
|
||||
where: { grantorId: user.id },
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: overfetch(MAX_GRANTS_PER_DIRECTION),
|
||||
include: { grantee: { select: GRANT_PARTY_SELECT } },
|
||||
}),
|
||||
prisma.accountGrant.findMany({
|
||||
where: { granteeId: user.id },
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: overfetch(MAX_GRANTS_PER_DIRECTION),
|
||||
include: { grantor: { select: GRANT_PARTY_SELECT } },
|
||||
}),
|
||||
]);
|
||||
const { rows: given, cap: givenCap } = capRows(
|
||||
givenFound,
|
||||
MAX_GRANTS_PER_DIRECTION,
|
||||
);
|
||||
const { rows: received, cap: receivedCap } = capRows(
|
||||
receivedFound,
|
||||
MAX_GRANTS_PER_DIRECTION,
|
||||
);
|
||||
const [{ rows: given, cap: givenCap }, { rows: received, cap: receivedCap }] =
|
||||
await Promise.all([
|
||||
listPage(MAX_GRANTS_PER_DIRECTION, (take) =>
|
||||
prisma.accountGrant.findMany({
|
||||
where: { grantorId: user.id },
|
||||
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
|
||||
take,
|
||||
include: { grantee: { select: GRANT_PARTY_SELECT } },
|
||||
}),
|
||||
),
|
||||
listPage(MAX_GRANTS_PER_DIRECTION, (take) =>
|
||||
prisma.accountGrant.findMany({
|
||||
where: { granteeId: user.id },
|
||||
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
|
||||
take,
|
||||
include: { grantor: { select: GRANT_PARTY_SELECT } },
|
||||
}),
|
||||
),
|
||||
]);
|
||||
|
||||
// Revoked and expired rows come back too. They are the consent record — "who
|
||||
// had access, from when to when, and who ended it" is the question the table
|
||||
@@ -122,10 +120,7 @@ export const GET = apiHandler(async () => {
|
||||
const now = new Date();
|
||||
// One cap per direction, one signal: either list cut short is a partial
|
||||
// answer.
|
||||
const cap = {
|
||||
truncated: givenCap.truncated || receivedCap.truncated,
|
||||
limit: MAX_GRANTS_PER_DIRECTION,
|
||||
};
|
||||
const cap = combinedCap(givenCap, receivedCap);
|
||||
return apiListSuccess(
|
||||
{
|
||||
given: given.map((g) => toGrantView(g, g.grantee, now)),
|
||||
|
||||
@@ -15,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,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();
|
||||
});
|
||||
});
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"',
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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" },
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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));
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user