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

+204 -2
View File
@@ -10788,6 +10788,69 @@ paths:
"403": *a17
"422": *a3
"429": *a4
/api/nutrients/daily:
get:
tags:
- Nutrients
summary: One nutrient's day-bucketed series (v1.29)
description: Dense per-day series for one catalog code over the last `days` days (default 30) — one entry per calendar
day, 0 for a day with no logged data, summed ACROSS SOURCES (a day may carry both an APPLE_HEALTH row and a
MANUAL row since migration 0249). Feeds the `/insights/nutrients` hydration + caffeine charts. Also returns the
EFSA reference resolved against the caller's profile sex — `null` when the profile has no sex on file (never
guessed). Behind the opt-in `nutrients` module.
parameters:
- in: query
name: nutrient
schema:
$ref: "#/components/schemas/NutrientCode"
required: true
description: Closed nutrient catalog code — 24 HealthKit Dietary vitamin/mineral types plus water and caffeine. Energy,
macros and sodium/potassium are out of scope.
- in: query
name: days
schema:
default: 30
type: integer
minimum: 1
maximum: 90
responses:
"200":
description: The day-bucketed series + resolved reference.
content:
application/json:
schema:
$ref: "#/components/schemas/NutrientDailySeriesEnvelope"
"401": *a1
"403": *a17
"422": *a3
"429": *a4
/api/nutrients/water:
post:
tags:
- Nutrients
summary: Manual water quick-add (v1.29)
description: "Writes ONLY the `source=\"MANUAL\"` row for `(day, \"water\")` — never the `source=\"APPLE_HEALTH\"` row
the batch endpoint owns, so a manual entry and an Apple sync coexist instead of one clobbering the other. `mode:
\"add\"` increments the manual day total (quick-add chips); `mode: \"set\"` overwrites it (the \"edit today's
total\" undo path — there is no per-entry ledger). `day` defaults to the caller's current local day when
omitted. Idempotency-Key supported; rate limit 60/min. Behind the opt-in `nutrients` module."
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/NutrientWaterWriteRequest"
responses:
"200":
description: The MANUAL water row after the write.
content:
application/json:
schema:
$ref: "#/components/schemas/NutrientWaterWriteEnvelope"
"401": *a1
"403": *a17
"422": *a3
"429": *a4
/api/integrations/healthkit:
get:
tags:
@@ -12347,7 +12410,7 @@ components:
- order
tiles:
minItems: 1
maxItems: 61
maxItems: 62
type: array
items:
type: object
@@ -12406,6 +12469,7 @@ components:
- wrist-temperature
- mood
- medications
- nutrients
- blutdruck
- puls
- sauerstoff
@@ -12945,6 +13009,26 @@ components:
- caffeine
description: Closed nutrient catalog code — 24 HealthKit Dietary vitamin/mineral types plus water and caffeine. Energy,
macros and sodium/potassium are out of scope.
NutrientWaterWriteRequest:
type: object
properties:
amountMl:
type: number
minimum: 1
maximum: 20000
mode:
type: string
enum:
- add
- set
day:
type: string
pattern: ^\d{4}-\d{2}-\d{2}$
required:
- amountMl
- mode
description: Manual water quick-add against the MANUAL source row. add increments today's manual total; set overwrites
it. Never touches the APPLE_HEALTH row.
HealthKitConfigPatchRequest:
type: object
properties:
@@ -29987,6 +30071,123 @@ components:
additionalProperties: false
description: "Per-nutrient window summary in catalog order: latest synced day + total, and the count of days carrying
data inside the window. Nutrients without data in the window are omitted."
NutrientDailySeriesEnvelope:
type: object
properties:
data:
$ref: "#/components/schemas/NutrientDailySeries"
error:
type: "null"
meta:
type: object
properties:
requestId:
type: string
additionalProperties: false
required:
- data
- error
additionalProperties: false
NutrientDailySeries:
type: object
properties:
nutrient:
$ref: "#/components/schemas/NutrientCode"
unit:
type: string
windowDays:
type: integer
minimum: 1
maximum: 9007199254740991
days:
type: array
items:
type: object
properties:
day:
type: string
amount:
type: number
required:
- day
- amount
additionalProperties: false
reference:
anyOf:
- type: object
properties:
kind:
type: string
enum:
- PRI
- AI
- safeLevel
direction:
type: string
enum:
- target
- upperGuidance
value:
type: number
source:
type: string
required:
- kind
- direction
- value
- source
additionalProperties: false
- type: "null"
required:
- nutrient
- unit
- windowDays
- days
- reference
additionalProperties: false
description: Dense day-bucketed series (one entry per day in the window, 0 for a day with no data) summed across
sources, plus the EFSA reference resolved against the caller's profile sex (null when sex is unknown on the
profile).
NutrientWaterWriteEnvelope:
type: object
properties:
data:
$ref: "#/components/schemas/NutrientWaterWriteResponse"
error:
type: "null"
meta:
type: object
properties:
requestId:
type: string
additionalProperties: false
required:
- data
- error
additionalProperties: false
NutrientWaterWriteResponse:
type: object
properties:
day:
type: string
nutrient:
type: string
const: water
source:
type: string
const: MANUAL
amount:
type: number
unit:
type: string
required:
- day
- nutrient
- source
- amount
- unit
additionalProperties: false
description: The MANUAL water row after the add/set write.
HealthKitConfigEnvelope:
type: object
properties:
@@ -30451,7 +30652,7 @@ components:
additionalProperties: false
tiles:
minItems: 1
maxItems: 61
maxItems: 62
type: array
items:
type: object
@@ -30510,6 +30711,7 @@ components:
- wrist-temperature
- mood
- medications
- nutrients
- blutdruck
- puls
- sauerstoff
@@ -0,0 +1,19 @@
-- v1.29 — `source` joins the `nutrient_intake_days` primary key so a
-- manual water entry and the Apple-synced day total coexist as two rows
-- instead of the manual write being silently clobbered by the next
-- Apple last-writer-wins sync (the batch route always upserts the
-- (userId, day, nutrient) row with the CURRENT running total, so a
-- manual increment folded into that same row would vanish on the next
-- sync — a data-loss bug by construction, not a rare race).
--
-- No data rewrite: every existing row already carries
-- `source = 'APPLE_HEALTH'` (the column default since migration 0241),
-- so dropping and recreating the PRIMARY KEY over the four columns is a
-- pure constraint change — same rows, same values, wider key. The
-- existing `(user_id, nutrient, day DESC)` index is untouched; it does
-- not reference `source` and continues to serve the window-scan reads.
ALTER TABLE "nutrient_intake_days" DROP CONSTRAINT "nutrient_intake_days_pkey";
ALTER TABLE "nutrient_intake_days"
ADD CONSTRAINT "nutrient_intake_days_pkey"
PRIMARY KEY ("user_id", "day", "nutrient", "source");
+8 -1
View File
@@ -6349,13 +6349,20 @@ model NutrientIntakeDay {
/// denormalised on purpose: rows stay self-describing in exports
/// even if the catalog ever drifts.
unit String
/// "APPLE_HEALTH" | "MANUAL" (closed set enforced in app code, not a
/// DB enum). v1.29 — joined the composite PK (migration 0249) so a
/// manual water entry and the Apple-synced day total coexist as two
/// rows instead of the manual write being silently clobbered by the
/// next Apple last-writer-wins sync. Apple owns its row (LWW on
/// re-post); manual owns its row (increment/set via
/// `POST /api/nutrients/water`); reads SUM across sources per day.
source String @default("APPLE_HEALTH")
externalSourceVersion String? @map("external_source_version")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@id([userId, day, nutrient])
@@id([userId, day, nutrient, source])
@@index([userId, nutrient, day(sort: Desc)])
@@map("nutrient_intake_days")
}
@@ -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,
});
}
@@ -16,6 +16,7 @@ import {
NUTRIENT_CODES,
NUTRIENT_DEFINITIONS,
isNutrientCode,
resolveNutrientReference,
} from "../catalog";
describe("nutrient catalog", () => {
@@ -151,3 +152,49 @@ describe("nutrient catalog", () => {
}
});
});
describe("resolveNutrientReference (v1.29)", () => {
it("resolves a uniform-adult reference (calcium) regardless of sex, including unknown", () => {
for (const sex of ["MALE", "FEMALE", null] as const) {
const ref = resolveNutrientReference("calcium", sex);
expect(ref).not.toBeNull();
expect(ref?.value).toBe(950);
expect(ref?.kind).toBe("PRI");
expect(ref?.direction).toBe("target");
expect(ref?.source).toBe(NUTRIENT_CATALOG.calcium.reference.source);
}
});
it("resolves a sex-split reference (water, iron) to the matching value per sex", () => {
expect(resolveNutrientReference("water", "MALE")?.value).toBe(2500);
expect(resolveNutrientReference("water", "FEMALE")?.value).toBe(2000);
expect(resolveNutrientReference("iron", "MALE")?.value).toBe(11);
expect(resolveNutrientReference("iron", "FEMALE")?.value).toBe(16);
});
it("omits a sex-split reference when sex is unknown — never guesses", () => {
expect(resolveNutrientReference("water", null)).toBeNull();
expect(resolveNutrientReference("iron", null)).toBeNull();
expect(resolveNutrientReference("magnesium", null)).toBeNull();
expect(resolveNutrientReference("vitamin_a", null)).toBeNull();
});
it("resolves caffeine's upperGuidance ceiling regardless of sex", () => {
const ref = resolveNutrientReference("caffeine", null);
expect(ref).toEqual({
kind: "safeLevel",
direction: "upperGuidance",
value: 400,
source: NUTRIENT_CATALOG.caffeine.reference.source,
});
});
it("resolves every catalog code to a non-null reference for at least one sex", () => {
for (const code of NUTRIENT_CODES) {
const resolved =
resolveNutrientReference(code, "MALE") ??
resolveNutrientReference(code, "FEMALE");
expect(resolved, `no resolvable reference: ${code}`).not.toBeNull();
}
});
});
+35
View File
@@ -305,3 +305,38 @@ const NUTRIENT_CODE_SET: ReadonlySet<string> = new Set(NUTRIENT_CODES);
export function isNutrientCode(code: string): code is NutrientCode {
return NUTRIENT_CODE_SET.has(code);
}
/** A catalog reference resolved to one concrete number for a profile. */
export interface ResolvedNutrientReference {
kind: NutrientReference["kind"];
direction: NutrientReference["direction"];
value: number;
source: string;
}
/**
* Resolve a catalog reference against the user's profile sex (v1.29).
*
* `adult` values (uniform across sexes) resolve unconditionally. A
* sex-split reference (`male` / `female`, no `adult`) resolves only
* when `sex` is known — a profile without sex on file OMITS the
* reference rather than guessing, the catalog's own documented
* contract (see the module docblock). Every insights surface honours
* this: no reference line, no meta sentence, nothing inferred.
*/
export function resolveNutrientReference(
code: NutrientCode,
sex: "MALE" | "FEMALE" | null,
): ResolvedNutrientReference | null {
const ref = NUTRIENT_CATALOG[code].reference;
const value =
ref.adult ??
(sex === "MALE" ? ref.male : sex === "FEMALE" ? ref.female : undefined);
if (value === undefined) return null;
return {
kind: ref.kind,
direction: ref.direction,
value,
source: ref.source,
};
}
+57
View File
@@ -14,8 +14,12 @@ import { NUTRIENT_DEFINITIONS } from "@/lib/nutrients/catalog";
import {
nutrientBatchSchema,
nutrientBatchResponseSchema,
nutrientDailyQuerySchema,
nutrientDailySeriesSchema,
nutrientOverviewQuerySchema,
nutrientOverviewSchema,
nutrientWaterWriteSchema,
nutrientWaterWriteResponseSchema,
} from "@/lib/validations/nutrients";
import { dataEnvelope, errorEnvelope, stdResponses } from "./shared";
@@ -90,4 +94,57 @@ export const nutrientPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
},
"/api/nutrients/daily": {
get: {
tags: ["Nutrients"],
summary: "One nutrient's day-bucketed series (v1.29)",
description:
"Dense per-day series for one catalog code over the last `days` days (default 30) — one entry per calendar day, 0 for a day with no logged data, summed ACROSS SOURCES (a day may carry both an APPLE_HEALTH row and a MANUAL row since migration 0249). Feeds the `/insights/nutrients` hydration + caffeine charts. Also returns the EFSA reference resolved against the caller's profile sex — `null` when the profile has no sex on file (never guessed). Behind the opt-in `nutrients` module.",
requestParams: { query: nutrientDailyQuerySchema },
responses: {
"200": {
description: "The day-bucketed series + resolved reference.",
content: {
"application/json": {
schema: dataEnvelope(
nutrientDailySeriesSchema,
"NutrientDailySeriesEnvelope",
),
},
},
},
...moduleDisabled,
...stdResponses,
},
},
},
"/api/nutrients/water": {
post: {
tags: ["Nutrients"],
summary: "Manual water quick-add (v1.29)",
description:
'Writes ONLY the `source="MANUAL"` row for `(day, "water")` — never the `source="APPLE_HEALTH"` row the batch endpoint owns, so a manual entry and an Apple sync coexist instead of one clobbering the other. `mode: "add"` increments the manual day total (quick-add chips); `mode: "set"` overwrites it (the "edit today\'s total" undo path — there is no per-entry ledger). `day` defaults to the caller\'s current local day when omitted. Idempotency-Key supported; rate limit 60/min. Behind the opt-in `nutrients` module.',
requestBody: {
required: true,
content: {
"application/json": { schema: nutrientWaterWriteSchema },
},
},
responses: {
"200": {
description: "The MANUAL water row after the write.",
content: {
"application/json": {
schema: dataEnvelope(
nutrientWaterWriteResponseSchema,
"NutrientWaterWriteEnvelope",
),
},
},
},
...moduleDisabled,
...stdResponses,
},
},
},
};
+7
View File
@@ -6,4 +6,11 @@
export const nutrientKeys = {
/** Per-nutrient window summary from `GET /api/nutrients?days=N`. */
nutrientIntake: (days: number) => ["nutrients", "intake", days] as const,
/**
* v1.29 — one nutrient's day-bucketed series from
* `GET /api/nutrients/daily?nutrient=<code>&days=N`. Feeds the
* `/insights/nutrients` hydration + caffeine charts.
*/
nutrientDaily: (nutrient: string, days: number) =>
["nutrients", "daily", nutrient, days] as const,
};
+70 -1
View File
@@ -9,7 +9,7 @@
*/
import { z } from "zod/v4";
import { NUTRIENT_CODES } from "@/lib/nutrients/catalog";
import { NUTRIENT_CATALOG, NUTRIENT_CODES } from "@/lib/nutrients/catalog";
export const MAX_NUTRIENT_ENTRIES_PER_BATCH = 500;
@@ -106,3 +106,72 @@ export const nutrientOverviewSchema = z
description:
"Per-nutrient window summary in catalog order: latest synced day + total, and the count of days carrying data inside the window. Nutrients without data in the window are omitted.",
});
/**
* `POST /api/nutrients/water` body (v1.29) — manual water quick-add.
*
* Water only in slice 1 (manual vitamin entry is out of scope — see
* the design memo). `amountMl` is bounded to the catalog's own
* per-day plausibility cap for water; `mode: "add"` increments the
* MANUAL row for `day`, `mode: "set"` overwrites it (the "edit
* today's total" undo path). `day` defaults server-side to the
* caller's current local day when omitted.
*/
export const nutrientWaterWriteSchema = z
.object({
amountMl: z
.number()
.finite()
.min(1)
.max(NUTRIENT_CATALOG.water.plausibleDailyMax),
mode: z.enum(["add", "set"]),
day: z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/)
.optional(),
})
.meta({
id: "NutrientWaterWriteRequest",
description:
"Manual water quick-add against the MANUAL source row. add increments today's manual total; set overwrites it. Never touches the APPLE_HEALTH row.",
});
export const nutrientWaterWriteResponseSchema = z
.object({
day: z.string(),
nutrient: z.literal("water"),
source: z.literal("MANUAL"),
amount: z.number(),
unit: z.string(),
})
.meta({
id: "NutrientWaterWriteResponse",
description: "The MANUAL water row after the add/set write.",
});
/** `GET /api/nutrients/daily` query — one nutrient's day-bucketed series. */
export const nutrientDailyQuerySchema = z.object({
nutrient: nutrientCodeEnum,
days: z.coerce.number().int().min(1).max(90).default(30),
});
const resolvedNutrientReferenceSchema = z.object({
kind: z.enum(["PRI", "AI", "safeLevel"]),
direction: z.enum(["target", "upperGuidance"]),
value: z.number(),
source: z.string(),
});
export const nutrientDailySeriesSchema = z
.object({
nutrient: nutrientCodeEnum,
unit: z.string(),
windowDays: z.number().int().min(1),
days: z.array(z.object({ day: z.string(), amount: z.number() })),
reference: resolvedNutrientReferenceSchema.nullable(),
})
.meta({
id: "NutrientDailySeries",
description:
"Dense day-bucketed series (one entry per day in the window, 0 for a day with no data) summed across sources, plus the EFSA reference resolved against the caller's profile sex (null when sex is unknown on the profile).",
});