feat(nutrients): source-aware day totals; manual water write path

NutrientIntakeDay's composite primary key gains `source` (migration
0249_nutrient_intake_day_source): a manual water entry and the
Apple-synced day total now live as two rows instead of the manual
write being silently clobbered by the next Apple last-writer-wins
sync. Every existing row already defaults to source=APPLE_HEALTH, so
the migration is a pure PK-constraint change with no data rewrite.

- POST /api/nutrients/batch keeps writing the APPLE_HEALTH row,
  byte-identical upsert semantics against the wider key.
- GET /api/nutrients sums amounts within (nutrient, day) across
  sources before folding into the window summary.
- POST /api/nutrients/water (new): manual water quick-add, add/set
  modes against the MANUAL row only, module-gated, rate-limited,
  idempotency-key aware, hard-evicts the dashboard snapshot cache.
- GET /api/nutrients/daily (new): dense per-day series for one
  nutrient (summed across sources) plus the EFSA reference resolved
  against the caller's profile sex — omitted when sex is unknown.
- catalog.ts gains resolveNutrientReference(), the sex-resolution
  helper both new routes and the insights cards share.

docs/api/openapi.yaml regenerated for the two new routes.
This commit is contained in:
Marc-André Bombeck committed 2026-07-17 16:08:39 +02:00
1 parent bef3b9dd3f
commit da2de015fe
15 files changed
+1075 -11

No files matched your search

@@ -267,10 +267,11 @@ describe("POST /api/nutrients/batch — upsert semantics", () => {
expect(prisma.nutrientIntakeDay.upsert).toHaveBeenCalledWith({
where: {
userId_day_nutrient: {
userId_day_nutrient_source: {
userId: "user-1",
day,
nutrient: "vitamin_d",
source: "APPLE_HEALTH",
},
},
create: {
@@ -279,6 +280,7 @@ describe("POST /api/nutrients/batch — upsert semantics", () => {
nutrient: "vitamin_d",
amount: 22.5,
unit: "ug",
source: "APPLE_HEALTH",
externalSourceVersion: "yazio-9.9",
},
update: {
+9 -1
View File
@@ -165,11 +165,18 @@ async function postBatch(request: NextRequest): Promise<Response> {
}
try {
// v1.29 — `source` joined the composite PK (migration 0249) so a
// manual water entry (source=MANUAL, written by
// `POST /api/nutrients/water`) coexists with the Apple-synced day
// total instead of one clobbering the other on the next sync. The
// batch route always owns the APPLE_HEALTH row — byte-identical
// upsert behaviour to pre-0249, just against the wider key.
const key = {
userId_day_nutrient: {
userId_day_nutrient_source: {
userId: user.id,
day: entry.day,
nutrient: entry.nutrient,
source: "APPLE_HEALTH",
},
};
@@ -189,6 +196,7 @@ async function postBatch(request: NextRequest): Promise<Response> {
nutrient: entry.nutrient,
amount: entry.amount,
unit: definition.unit,
source: "APPLE_HEALTH",
externalSourceVersion: entry.externalSourceVersion ?? null,
},
update: {
@@ -0,0 +1,165 @@
/**
* v1.29 — `GET /api/nutrients/daily` contract tests.
*
* Pins the per-day-bucketed series feeding the hydration/caffeine
* charts: module gate, query validation, the dense (zero-filled) day
* range, summing across sources within a day, and the sex-resolved
* (or omitted) EFSA reference.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
nutrientIntakeDay: {
findMany: vi.fn(),
},
},
}));
vi.mock("@/lib/modules/gate", () => ({
requireModuleEnabled: 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 { prisma } from "@/lib/db";
import { apiError } from "@/lib/api-response";
import { getSession } from "@/lib/auth/session";
import { requireModuleEnabled } from "@/lib/modules/gate";
function sessionFor(gender: string | null) {
return {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: {
id: "user-1",
username: "tester",
role: "USER" as const,
timezone: "Europe/Berlin",
gender,
},
};
}
function getReq(query: string): NextRequest {
return new NextRequest(`http://localhost/api/nutrients/daily${query}`);
}
interface DailyResponse {
data: {
nutrient: string;
unit: string;
windowDays: number;
days: Array<{ day: string; amount: number }>;
reference: { kind: string; direction: string; value: number } | null;
};
}
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(getSession).mockResolvedValue(sessionFor("FEMALE") as never);
vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true } as never);
vi.mocked(prisma.nutrientIntakeDay.findMany).mockResolvedValue([] as never);
});
describe("GET /api/nutrients/daily", () => {
it("refuses with the 403 module.disabled envelope when the module is off", async () => {
vi.mocked(requireModuleEnabled).mockResolvedValue({
enabled: false,
response: apiError('Module "nutrients" is not enabled', 403, {
errorCode: "module.disabled",
module: "nutrients",
}),
} as never);
const res = await GET(getReq("?nutrient=water&days=30"));
expect(res.status).toBe(403);
expect(prisma.nutrientIntakeDay.findMany).not.toHaveBeenCalled();
});
it("rejects an unknown nutrient code with 422", async () => {
const res = await GET(getReq("?nutrient=not-a-code&days=30"));
expect(res.status).toBe(422);
});
it("rejects an out-of-range days value with 422", async () => {
for (const bad of ["0", "91", "abc"]) {
const res = await GET(getReq(`?nutrient=water&days=${bad}`));
expect(res.status, `days=${bad}`).toBe(422);
}
});
it("defaults to a 30-day dense window with zero-filled days for a fresh account", async () => {
const res = await GET(getReq("?nutrient=water"));
expect(res.status).toBe(200);
const body = (await res.json()) as DailyResponse;
expect(body.data.windowDays).toBe(30);
expect(body.data.unit).toBe("ml");
expect(body.data.days).toHaveLength(30);
expect(body.data.days.every((d) => d.amount === 0)).toBe(true);
// Ascending, contiguous day keys, ending on "today".
for (let i = 1; i < body.data.days.length; i++) {
const prev = new Date(`${body.data.days[i - 1].day}T00:00:00.000Z`);
const curr = new Date(`${body.data.days[i].day}T00:00:00.000Z`);
expect(curr.getTime() - prev.getTime()).toBe(24 * 60 * 60 * 1000);
}
});
it("sums amounts across sources within the same day before bucketing", async () => {
vi.mocked(prisma.nutrientIntakeDay.findMany).mockResolvedValue([
{ day: "2026-07-16", amount: 900 },
{ day: "2026-07-16", amount: 600 },
{ day: "2026-07-15", amount: 400 },
] as never);
const res = await GET(getReq("?nutrient=water&days=3"));
const body = (await res.json()) as DailyResponse;
const byDay = new Map(body.data.days.map((d) => [d.day, d.amount]));
expect(byDay.get("2026-07-16")).toBe(1500);
expect(byDay.get("2026-07-15")).toBe(400);
});
it("resolves the sex-split reference against the caller's profile sex", async () => {
vi.mocked(getSession).mockResolvedValue(sessionFor("FEMALE") as never);
const res = await GET(getReq("?nutrient=water&days=7"));
const body = (await res.json()) as DailyResponse;
expect(body.data.reference).toEqual({
kind: "AI",
direction: "target",
value: 2000,
source: expect.stringContaining("EFSA"),
});
});
it("omits the reference when the profile has no sex on file — never guesses", async () => {
vi.mocked(getSession).mockResolvedValue(sessionFor(null) as never);
const res = await GET(getReq("?nutrient=water&days=7"));
const body = (await res.json()) as DailyResponse;
expect(body.data.reference).toBeNull();
});
it("scopes the query to the authenticated user and the requested nutrient", async () => {
await GET(getReq("?nutrient=caffeine&days=14"));
expect(prisma.nutrientIntakeDay.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
userId: "user-1",
nutrient: "caffeine",
day: { gte: expect.stringMatching(/^\d{4}-\d{2}-\d{2}$/) },
}),
}),
);
});
});
+91
View File
@@ -0,0 +1,91 @@
/**
* `GET /api/nutrients/daily?nutrient=<code>&days=N` — per-day summed
* series for one nutrient, plus its resolved EFSA reference (v1.29).
*
* Feeds the `/insights/nutrients` hydration + caffeine charts: a dense
* day-bucketed series (one row per calendar day in the window, 0 for a
* day with no logged data) summed ACROSS sources — a day can carry an
* APPLE_HEALTH row and a MANUAL row since migration 0249, and the
* chart shows one honest bar per day. The window is anchored to the
* caller's own local "today" (`User.timezone`), not a UTC floor —
* unlike the coarser window on `GET /api/nutrients`, a chart with a
* one-day edge wobble reads as a visible bug.
*
* The reference resolves against the caller's profile sex (from the
* session's own `User` row — no extra query) and is `null` when the
* profile has no sex on file, matching the catalog's own contract:
* omit, never guess. Module-gated like every other nutrients route.
*/
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess, returnAllZodIssues } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { requireModuleEnabled } from "@/lib/modules/gate";
import {
NUTRIENT_CATALOG,
resolveNutrientReference,
} from "@/lib/nutrients/catalog";
import { nutrientDailyQuerySchema } from "@/lib/validations/nutrients";
import { DEFAULT_TIMEZONE, shiftDateKey, userDayKey } from "@/lib/tz/format";
export const dynamic = "force-dynamic";
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const gate = await requireModuleEnabled(user.id, "nutrients");
if (!gate.enabled) return gate.response;
const parsed = nutrientDailyQuerySchema.safeParse({
nutrient: request.nextUrl.searchParams.get("nutrient") ?? undefined,
days: request.nextUrl.searchParams.get("days") ?? undefined,
});
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422, {
errorCode: "nutrient.daily.invalid",
});
}
const { nutrient, days } = parsed.data;
const definition = NUTRIENT_CATALOG[nutrient];
const userTz = user.timezone || DEFAULT_TIMEZONE;
const todayKey = userDayKey(new Date(), userTz);
const sinceKey = shiftDateKey(todayKey, -(days - 1));
const rows = await prisma.nutrientIntakeDay.findMany({
where: { userId: user.id, nutrient, day: { gte: sinceKey } },
select: { day: true, amount: true },
});
// Sum across sources within a day BEFORE bucketing — the same fold
// `GET /api/nutrients` applies (migration 0249 note there).
const sumByDay = new Map<string, number>();
for (const row of rows) {
sumByDay.set(row.day, (sumByDay.get(row.day) ?? 0) + row.amount);
}
const daySeries: Array<{ day: string; amount: number }> = [];
for (let i = 0; i < days; i++) {
const key = shiftDateKey(sinceKey, i);
daySeries.push({ day: key, amount: sumByDay.get(key) ?? 0 });
}
const sex =
user.gender === "MALE" || user.gender === "FEMALE" ? user.gender : null;
const reference = resolveNutrientReference(nutrient, sex);
annotate({
action: { name: "nutrient.daily.read" },
meta: { nutrient, window_days: days, row_count: rows.length },
});
return apiSuccess({
nutrient,
unit: definition.unit,
windowDays: days,
days: daySeries,
reference,
});
});
+11 -5
View File
@@ -49,23 +49,29 @@ export const GET = apiHandler(async (request: NextRequest) => {
select: { nutrient: true, unit: true, day: true, amount: true },
});
// Fold to one summary row per nutrient. Rows arrive day-DESC inside
// each nutrient, so the first row seen per code is the latest.
// v1.29 — `source` joined the PK (migration 0249): a day can now carry
// an APPLE_HEALTH row AND a MANUAL row, so the fold sums amounts
// WITHIN (nutrient, day) before folding across days. Rows arrive
// day-DESC inside each nutrient, so the first DAY seen per code is
// the latest; a second row for that same day (the other source) adds
// to the running total instead of opening a new "day".
const byCode = new Map<
string,
{ unit: string; latestDay: string; latestAmount: number; days: number }
>();
for (const row of rows) {
const existing = byCode.get(row.nutrient);
if (existing) {
existing.days += 1;
} else {
if (!existing) {
byCode.set(row.nutrient, {
unit: row.unit,
latestDay: row.day,
latestAmount: row.amount,
days: 1,
});
} else if (row.day === existing.latestDay) {
existing.latestAmount += row.amount;
} else {
existing.days += 1;
}
}
@@ -0,0 +1,218 @@
/**
* v1.29 — `POST /api/nutrients/water` contract tests.
*
* Pins the manual quick-add posture: module gate first, rate limit,
* `add` vs `set` upsert shape, the MANUAL-only key (never touches the
* APPLE_HEALTH row), the local-day default, and the dashboard-snapshot
* hard-evict on success.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
nutrientIntakeDay: {
upsert: vi.fn(),
},
},
}));
vi.mock("@/lib/modules/gate", () => ({
requireModuleEnabled: vi.fn(),
}));
vi.mock("@/lib/cache/invalidate", () => ({
invalidateUserDashboardSnapshot: vi.fn(),
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn(),
rateLimitHeaders: () => ({}),
}));
vi.mock("@/lib/idempotency", () => ({
withIdempotency:
<Args extends unknown[]>(fn: (...args: Args) => Promise<Response>) =>
(...args: Args) =>
fn(...args),
}));
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 { POST } from "../route";
import { prisma } from "@/lib/db";
import { apiError } from "@/lib/api-response";
import { getSession } from "@/lib/auth/session";
import { checkRateLimit } from "@/lib/rate-limit";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { invalidateUserDashboardSnapshot } from "@/lib/cache/invalidate";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: {
id: "user-1",
username: "tester",
role: "USER" as const,
timezone: "Europe/Berlin",
gender: "FEMALE",
},
};
function postReq(body: unknown): NextRequest {
return new NextRequest("http://localhost/api/nutrients/water", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
interface WaterResponse {
data: {
day: string;
nutrient: string;
source: string;
amount: number;
unit: string;
};
}
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true } as never);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
remaining: 59,
resetAt: Date.now() + 60_000,
});
vi.mocked(prisma.nutrientIntakeDay.upsert).mockResolvedValue({
day: "2026-07-16",
nutrient: "water",
source: "MANUAL",
amount: 500,
unit: "ml",
} as never);
});
describe("POST /api/nutrients/water", () => {
it("refuses with the 403 module.disabled envelope when the module is off", async () => {
vi.mocked(requireModuleEnabled).mockResolvedValue({
enabled: false,
response: apiError('Module "nutrients" is not enabled', 403, {
errorCode: "module.disabled",
module: "nutrients",
}),
} as never);
const res = await POST(postReq({ amountMl: 200, mode: "add" }));
expect(res.status).toBe(403);
const body = (await res.json()) as { meta?: { errorCode?: string } };
expect(body.meta?.errorCode).toBe("module.disabled");
expect(prisma.nutrientIntakeDay.upsert).not.toHaveBeenCalled();
});
it("returns 429 when the rate limit is exceeded", async () => {
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: false,
remaining: 0,
resetAt: Date.now() + 60_000,
});
const res = await POST(postReq({ amountMl: 200, mode: "add" }));
expect(res.status).toBe(429);
expect(prisma.nutrientIntakeDay.upsert).not.toHaveBeenCalled();
});
it("rejects an out-of-range or missing amountMl with 422", async () => {
for (const bad of [
{ amountMl: 0, mode: "add" },
{ amountMl: -5, mode: "add" },
{ amountMl: 30000, mode: "add" },
{ mode: "add" },
]) {
const res = await POST(postReq(bad));
expect(res.status, JSON.stringify(bad)).toBe(422);
}
});
it("rejects an unknown mode with 422", async () => {
const res = await POST(postReq({ amountMl: 200, mode: "double" }));
expect(res.status).toBe(422);
});
it("rejects a malformed day with 422 (calendar-invalid)", async () => {
const res = await POST(
postReq({ amountMl: 200, mode: "add", day: "2026-02-31" }),
);
expect(res.status).toBe(422);
const body = (await res.json()) as { meta?: { errorCode?: string } };
expect(body.meta?.errorCode).toBe("nutrient.water.invalid_day");
});
it("add mode upserts the MANUAL row with an atomic increment — never the APPLE_HEALTH row", async () => {
const res = await POST(
postReq({ amountMl: 250, mode: "add", day: "2026-07-16" }),
);
expect(res.status).toBe(200);
expect(prisma.nutrientIntakeDay.upsert).toHaveBeenCalledWith({
where: {
userId_day_nutrient_source: {
userId: "user-1",
day: "2026-07-16",
nutrient: "water",
source: "MANUAL",
},
},
create: {
userId: "user-1",
day: "2026-07-16",
nutrient: "water",
amount: 250,
unit: "ml",
source: "MANUAL",
},
update: { amount: { increment: 250 } },
});
expect(invalidateUserDashboardSnapshot).toHaveBeenCalledWith("user-1");
});
it("set mode overwrites the MANUAL row's amount instead of incrementing", async () => {
await POST(postReq({ amountMl: 1800, mode: "set", day: "2026-07-16" }));
expect(prisma.nutrientIntakeDay.upsert).toHaveBeenCalledWith(
expect.objectContaining({ update: { amount: 1800 } }),
);
});
it("defaults `day` to the caller's current local day when omitted", async () => {
await POST(postReq({ amountMl: 200, mode: "add" }));
const call = vi.mocked(prisma.nutrientIntakeDay.upsert).mock
.calls[0][0] as {
where: { userId_day_nutrient_source: { day: string } };
};
expect(call.where.userId_day_nutrient_source.day).toMatch(
/^\d{4}-\d{2}-\d{2}$/,
);
});
it("returns the MANUAL row shape on success", async () => {
const res = await POST(
postReq({ amountMl: 250, mode: "add", day: "2026-07-16" }),
);
const body = (await res.json()) as WaterResponse;
expect(body.data).toEqual({
day: "2026-07-16",
nutrient: "water",
source: "MANUAL",
amount: 500,
unit: "ml",
});
});
});
+131
View File
@@ -0,0 +1,131 @@
/**
* `POST /api/nutrients/water` — manual water quick-add (v1.29).
*
* Writes ONLY the `source="MANUAL"` row for `(userId, day, "water")` —
* migration 0249 widened the composite PK so this never touches the
* `source="APPLE_HEALTH"` row the batch route owns. `mode: "add"`
* increments the manual day total (the quick-add chips: +200/+300/
* +500 mL + a custom amount); `mode: "set"` overwrites it (the "edit
* today's total" undo path — there is no per-entry ledger, honest to
* the day-total storage model). `day` defaults to the caller's current
* local day (`User.timezone`) when omitted.
*
* Module gate first, like every other nutrients route. Idempotency-key
* aware (`withIdempotency`) so a network retry of a quick-add tap can
* never double-increment the day total.
*/
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import {
apiError,
apiSuccess,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { withIdempotency } from "@/lib/idempotency";
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 { nutrientWaterWriteSchema } from "@/lib/validations/nutrients";
import { DEFAULT_TIMEZONE, userDayKey } from "@/lib/tz/format";
const WRITE_RATE_LIMIT_MAX = 60;
const WRITE_RATE_LIMIT_WINDOW_MS = 60 * 1000;
/** Calendar sanity for a client-supplied YYYY-MM-DD key (2026-02-31 etc). */
function isRealCalendarDay(day: string): boolean {
const [y, m, d] = day.split("-").map(Number);
const dt = new Date(Date.UTC(y, m - 1, d));
return (
dt.getUTCFullYear() === y &&
dt.getUTCMonth() === m - 1 &&
dt.getUTCDate() === d
);
}
export const POST = apiHandler(withIdempotency<[NextRequest]>(postWater));
async function postWater(request: NextRequest): Promise<Response> {
const { user } = await requireAuth();
const gate = await requireModuleEnabled(user.id, "nutrients");
if (!gate.enabled) return gate.response;
const rl = await checkRateLimit(
`nutrients:water:${user.id}`,
WRITE_RATE_LIMIT_MAX,
WRITE_RATE_LIMIT_WINDOW_MS,
);
if (!rl.allowed) {
return apiError("Too many requests, try again later", 429);
}
const { data: rawBody, error: jsonError } = await safeJson(request, {
maxBytes: 4 * 1024,
});
if (jsonError) return jsonError;
const parsed = nutrientWaterWriteSchema.safeParse(rawBody);
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422, {
errorCode: "nutrient.water.invalid",
});
}
const { amountMl, mode } = parsed.data;
const userTz = user.timezone || DEFAULT_TIMEZONE;
const day = parsed.data.day ?? userDayKey(new Date(), userTz);
if (!isRealCalendarDay(day)) {
return apiError("Invalid day", 422, {
errorCode: "nutrient.water.invalid_day",
});
}
const definition = NUTRIENT_CATALOG.water;
const key = {
userId_day_nutrient_source: {
userId: user.id,
day,
nutrient: "water",
source: "MANUAL",
},
};
const row = await prisma.nutrientIntakeDay.upsert({
where: key,
create: {
userId: user.id,
day,
nutrient: "water",
amount: amountMl,
unit: definition.unit,
source: "MANUAL",
},
update:
mode === "add"
? { amount: { increment: amountMl } }
: { amount: amountMl },
});
// 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.
invalidateUserDashboardSnapshot(user.id);
annotate({
action: { name: "nutrient.water.write" },
meta: { mode, day, amount_ml: amountMl },
});
return apiSuccess({
day: row.day,
nutrient: "water" as const,
source: "MANUAL" as const,
amount: row.amount,
unit: row.unit,
});
}