fix(nutrients): bound the manual water writes and put nutrient totals in the backup

The water quick-add validated calendar realism but never the upper day bound the
batch route applies, so a far-future day was accepted and — every read filters
`day >= since` with no ceiling — became the permanent latest day on the settings
card and the dashboard tile. Hoist the bound into one shared helper both routes
use.

`mode: "add"` bounded a single request to the catalog's plausible daily max but
nothing across requests, so repeated quick-adds drove the stored total
arbitrarily high while the batch route enforced the same cap strictly. Clamp
after the increment so the write stays atomic.

Thiamin's cap sat at exactly 1000 mg while its PRI is exactly 1.0 mg, so a
microgram-read-as-milligram sample landed on precisely the cap and the `>`
comparison let it through. 500 still clears any real supplement dose.

NutrientIntakeDay was in no export path, which contradicted the schema's own
reason for denormalising the unit column. Carry it in the full backup with its
unit and source — source is part of the composite key, so without it a restore
cannot tell a manual entry from a synced day total.
This commit is contained in:
Marc-André Bombeck committed 2026-07-19 13:40:03 +02:00
1 parent 8ead442d44
commit 94157a50c7
16 files changed
+389 -90

No files matched your search

@@ -16,6 +16,7 @@ vi.mock("@/lib/db", () => ({
medication: { findMany: vi.fn() },
medicationIntakeEvent: { findMany: vi.fn() },
moodEntry: { findMany: vi.fn() },
nutrientIntakeDay: { findMany: vi.fn().mockResolvedValue([]) },
// v1.15.0 — cycle tables read by the full-backup helper.
cycleProfile: { findUnique: vi.fn() },
menstrualCycle: { findMany: vi.fn() },
@@ -70,6 +71,9 @@ function mkReq(url: string): NextRequest {
beforeEach(() => {
vi.resetAllMocks();
// `resetAllMocks` clears the inline default, and the full-backup payload
// builder reads this unconditionally.
vi.mocked(prisma.nutrientIntakeDay.findMany).mockResolvedValue([] as never);
});
afterEach(() => {
@@ -24,6 +24,7 @@ vi.mock("@/lib/db", () => ({
medication: { findMany: vi.fn() },
medicationIntakeEvent: { findMany: vi.fn(), count: vi.fn() },
moodEntry: { findMany: vi.fn(), count: vi.fn() },
nutrientIntakeDay: { findMany: vi.fn().mockResolvedValue([]) },
user: { findUnique: vi.fn() },
passkey: { findMany: vi.fn() },
apiToken: { count: vi.fn() },
@@ -14,6 +14,7 @@ vi.mock("@/lib/db", () => ({
medication: { findMany: vi.fn().mockResolvedValue([]) },
medicationIntakeEvent: { findMany: vi.fn().mockResolvedValue([]) },
moodEntry: { findMany: vi.fn().mockResolvedValue([]) },
nutrientIntakeDay: { findMany: vi.fn().mockResolvedValue([]) },
cycleProfile: { findUnique: vi.fn().mockResolvedValue(null) },
menstrualCycle: { findMany: vi.fn().mockResolvedValue([]) },
cycleDayLog: { findMany: vi.fn().mockResolvedValue([]) },
+1
View File
@@ -116,6 +116,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
export_family_history_count: counts.familyHistory,
export_workout_count: counts.workouts,
export_document_count: counts.documents,
export_nutrient_day_count: counts.nutrientDays,
export_archive_bytes: archive.byteLength,
},
});
+1
View File
@@ -71,6 +71,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
export_family_history_count: counts.familyHistory,
export_workout_count: counts.workouts,
export_document_count: counts.documents,
export_nutrient_day_count: counts.nutrientDays,
},
});
+4 -12
View File
@@ -7,7 +7,8 @@
* user's IANA timezone (the `stats:` day-key contract — the server
* trusts the string after regex + calendar sanity, no re-derivation).
*
* Upsert key = the composite PK (userId, day, nutrient). Re-post
* Upsert key = the composite PK (userId, day, nutrient, source), and this
* route always owns the APPLE_HEALTH row. Re-post
* replaces amount / unit / externalSourceVersion — last-writer-wins,
* the day-total contract; per-entry status is `updated` then, never
* `duplicate`. Validation failures (`unit_mismatch` | `value_out_of_range`
@@ -42,6 +43,7 @@ import { checkRateLimit } from "@/lib/rate-limit";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { invalidateUserDashboardSnapshot } from "@/lib/cache/invalidate";
import { NUTRIENT_CATALOG } from "@/lib/nutrients/catalog";
import { maxAcceptableNutrientDay } from "@/lib/nutrients/day-bounds";
import {
MAX_NUTRIENT_ENTRIES_PER_BATCH,
nutrientBatchSchema,
@@ -64,16 +66,6 @@ function isRealCalendarDay(day: string): boolean {
);
}
/**
* Upper day bound with timezone slack: "tomorrow" in the most-ahead
* IANA zone (UTC+14) is at most the UTC calendar date + 2 days, so a
* key beyond that cannot be a legitimate local day and is corruption.
*/
function maxAcceptableDay(now: Date): string {
const limit = new Date(now.getTime() + 2 * 24 * 60 * 60 * 1000);
return limit.toISOString().slice(0, 10);
}
type EntryStatus = "inserted" | "updated" | "skipped";
interface EntryResult {
index: number;
@@ -133,7 +125,7 @@ async function postBatch(request: NextRequest): Promise<Response> {
}
const { entries } = parsed.data;
const dayCeiling = maxAcceptableDay(new Date());
const dayCeiling = maxAcceptableNutrientDay(new Date());
const results: EntryResult[] = new Array(entries.length);
const skipped: Array<{ index: number; reason: string }> = [];
@@ -13,6 +13,7 @@ vi.mock("@/lib/db", () => ({
prisma: {
nutrientIntakeDay: {
upsert: vi.fn(),
update: vi.fn(),
},
},
}));
@@ -56,6 +57,7 @@ import { getSession } from "@/lib/auth/session";
import { checkRateLimit } from "@/lib/rate-limit";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { invalidateUserDashboardSnapshot } from "@/lib/cache/invalidate";
import { NUTRIENT_CATALOG } from "@/lib/nutrients/catalog";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
@@ -216,3 +218,69 @@ describe("POST /api/nutrients/water", () => {
});
});
});
describe("POST /api/nutrients/water — day bound and increment cap", () => {
// The batch route bounded `day` with UTC+14 slack; this route validated
// calendar realism only. Every read filters `day: { gte: since }` with no
// upper bound, so a far-future row became the permanent `latestDay` on the
// settings card and the permanent `lastSeenAt` on the dashboard water tile.
it("rejects a far-future day with 422", async () => {
const res = await POST(
postReq({ amountMl: 500, mode: "set", day: "2999-01-01" }),
);
expect(res.status).toBe(422);
expect(prisma.nutrientIntakeDay.upsert).not.toHaveBeenCalled();
});
it("still accepts a day two days ahead — the UTC+14 slack the batch route allows", async () => {
const soon = new Date(Date.now() + 24 * 60 * 60 * 1000)
.toISOString()
.slice(0, 10);
const res = await POST(postReq({ amountMl: 500, mode: "set", day: soon }));
expect(res.status).toBe(200);
});
it("accepts a past day", async () => {
const past = new Date(Date.now() - 3 * 24 * 60 * 60 * 1000)
.toISOString()
.slice(0, 10);
const res = await POST(postReq({ amountMl: 500, mode: "set", day: past }));
expect(res.status).toBe(200);
});
// `mode: "add"` was unbounded across requests: the schema caps a SINGLE
// write, so repeated quick-adds drove the stored day total arbitrarily high
// while the batch route enforced the same cap strictly on the Apple row.
it("clamps an incremented total back to the catalog's plausible daily max", async () => {
vi.mocked(prisma.nutrientIntakeDay.upsert).mockResolvedValue({
day: "2026-07-16",
nutrient: "water",
source: "MANUAL",
amount: 99_000,
unit: "ml",
} as never);
vi.mocked(prisma.nutrientIntakeDay.update).mockResolvedValue({
day: "2026-07-16",
nutrient: "water",
source: "MANUAL",
amount: NUTRIENT_CATALOG.water.plausibleDailyMax,
unit: "ml",
} as never);
const res = await POST(postReq({ amountMl: 500, mode: "add" }));
expect(res.status).toBe(200);
expect(prisma.nutrientIntakeDay.update).toHaveBeenCalledWith(
expect.objectContaining({
data: { amount: NUTRIENT_CATALOG.water.plausibleDailyMax },
}),
);
const body = (await res.json()) as WaterResponse;
expect(body.data.amount).toBe(NUTRIENT_CATALOG.water.plausibleDailyMax);
});
it("leaves a total under the cap untouched", async () => {
const res = await POST(postReq({ amountMl: 500, mode: "add" }));
expect(res.status).toBe(200);
expect(prisma.nutrientIntakeDay.update).not.toHaveBeenCalled();
});
});
+21 -2
View File
@@ -30,6 +30,7 @@ import { checkRateLimit } from "@/lib/rate-limit";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { invalidateUserDashboardSnapshot } from "@/lib/cache/invalidate";
import { NUTRIENT_CATALOG } from "@/lib/nutrients/catalog";
import { maxAcceptableNutrientDay } from "@/lib/nutrients/day-bounds";
import { nutrientWaterWriteSchema } from "@/lib/validations/nutrients";
import { DEFAULT_TIMEZONE, userDayKey } from "@/lib/tz/format";
@@ -79,7 +80,12 @@ async function postWater(request: NextRequest): Promise<Response> {
const userTz = user.timezone || DEFAULT_TIMEZONE;
const day = parsed.data.day ?? userDayKey(new Date(), userTz);
if (!isRealCalendarDay(day)) {
// Calendar realism AND the upper bound the batch route already applied. A
// client-supplied far-future day used to be accepted here, and since every
// read filters `day: { gte: since }` with no upper bound it became the
// permanent `latestDay` on the settings card and the permanent `lastSeenAt`
// on the dashboard water tile.
if (!isRealCalendarDay(day) || day > maxAcceptableNutrientDay(new Date())) {
return apiError("Invalid day", 422, {
errorCode: "nutrient.water.invalid_day",
});
@@ -95,7 +101,7 @@ async function postWater(request: NextRequest): Promise<Response> {
},
};
const row = await prisma.nutrientIntakeDay.upsert({
let row = await prisma.nutrientIntakeDay.upsert({
where: key,
create: {
userId: user.id,
@@ -111,6 +117,19 @@ async function postWater(request: NextRequest): Promise<Response> {
: { amount: amountMl },
});
// The request schema bounds a SINGLE write to the catalog's plausible daily
// max, but `mode: "add"` is unbounded across requests — repeated quick-adds
// drove the stored day total arbitrarily high while the batch route enforced
// the same cap strictly on the Apple-sourced row. Clamp after the fact rather
// than reading first: the increment stays atomic, and the clamp converges on
// the cap no matter how the increments interleave.
if (row.amount > definition.plausibleDailyMax) {
row = await prisma.nutrientIntakeDay.update({
where: key,
data: { amount: definition.plausibleDailyMax },
});
}
// Interactive single-entry write — hard-evict (not mark-stale) so the
// dashboard water tile reflects the new total on the very next read,
// matching the mood / medication / measurement posture.