fix(nutrients): audit every batch ingest call, not only the ones that wrote a row

nutrient.batch.ingest only reached the durable AuditLog table when
inserted + updated > 0, so a batch where every entry was rejected
(unit mismatch, out-of-range value, invalid day, a write failure) left
the exact same empty trail as a phone that never called the endpoint
at all. That made an empty nutrient_intake_days table unanswerable
from the durable record - the question this surface exists to answer.

Record the call unconditionally, the same shape measurement.batch.ingest
and workout.batch.ingest already use (counts only, no per-entry health
values, no user-typed content), plus an aggregate skip-reason breakdown
so an operator can tell, from the row alone, that the phone posted and
why nothing landed.

HTTP status and the per-entry response shape are unchanged - a batch
endpoint returning 200 with per-entry outcomes on a partial failure is
the correct, documented contract and native clients already decode it.
This commit is contained in:
Marc-André Bombeck committed 2026-07-30 23:18:48 +02:00
1 parent a1c5d78f72
commit 845b89255d
2 files changed
+77 -10

No files matched your search

+43
View File
@@ -187,6 +187,49 @@ describe("POST /api/nutrients/batch (real Postgres)", () => {
expect(count).toBe(0);
});
// Before this, `nutrient.batch.ingest` audited only when
// `inserted + updated > 0` — the ONLY durable record of the call — so a
// batch where every entry was rejected left the exact same empty
// AuditLog trail as a phone that never posted at all. An operator could
// not tell the two apart from the durable record.
it("records an audit-log row even when every entry is rejected", async () => {
const { POST } = await import("@/app/api/nutrients/batch/route");
const res = await POST(
postReq({
entries: [
{ day: recentDay(1), nutrient: "vitamin_d", unit: "mg", amount: 20 },
{ day: recentDay(2), nutrient: "iron", unit: "mg", amount: 999 },
],
}),
);
const json = (await res.json()) as BatchEnvelope;
expect(json.data.inserted).toBe(0);
expect(json.data.updated).toBe(0);
expect(json.data.skipped).toHaveLength(2);
const rows = await getPrismaClient().auditLog.findMany({
where: { userId: OPTED_IN_USER, action: "nutrient.batch.ingest" },
});
expect(rows).toHaveLength(1);
const details = JSON.parse(rows[0].details ?? "{}") as {
processed: number;
inserted: number;
updated: number;
skipped: number;
skippedByReason: Record<string, number>;
};
expect(details).toMatchObject({
processed: 2,
inserted: 0,
updated: 0,
skipped: 2,
});
expect(details.skippedByReason).toMatchObject({
unit_mismatch: 1,
value_out_of_range: 1,
});
});
it("refuses ingest 403 module.disabled for an account that never opted in", async () => {
const { POST } = await import("@/app/api/nutrients/batch/route");
cookieJar.clear();