diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index f26730199..e5daa12bc 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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 diff --git a/prisma/migrations/0249_nutrient_intake_day_source/migration.sql b/prisma/migrations/0249_nutrient_intake_day_source/migration.sql new file mode 100644 index 000000000..861d9a849 --- /dev/null +++ b/prisma/migrations/0249_nutrient_intake_day_source/migration.sql @@ -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"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 3dafaa7b7..e71bca08a 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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") } diff --git a/src/app/api/nutrients/batch/__tests__/route.test.ts b/src/app/api/nutrients/batch/__tests__/route.test.ts index 140ef2529..5afdb0047 100644 --- a/src/app/api/nutrients/batch/__tests__/route.test.ts +++ b/src/app/api/nutrients/batch/__tests__/route.test.ts @@ -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: { diff --git a/src/app/api/nutrients/batch/route.ts b/src/app/api/nutrients/batch/route.ts index ae78c001a..73506e330 100644 --- a/src/app/api/nutrients/batch/route.ts +++ b/src/app/api/nutrients/batch/route.ts @@ -165,11 +165,18 @@ async function postBatch(request: NextRequest): Promise { } 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 { nutrient: entry.nutrient, amount: entry.amount, unit: definition.unit, + source: "APPLE_HEALTH", externalSourceVersion: entry.externalSourceVersion ?? null, }, update: { diff --git a/src/app/api/nutrients/daily/__tests__/route.test.ts b/src/app/api/nutrients/daily/__tests__/route.test.ts new file mode 100644 index 000000000..e8a7ed9a3 --- /dev/null +++ b/src/app/api/nutrients/daily/__tests__/route.test.ts @@ -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}$/) }, + }), + }), + ); + }); +}); diff --git a/src/app/api/nutrients/daily/route.ts b/src/app/api/nutrients/daily/route.ts new file mode 100644 index 000000000..dd0bfd4c6 --- /dev/null +++ b/src/app/api/nutrients/daily/route.ts @@ -0,0 +1,91 @@ +/** + * `GET /api/nutrients/daily?nutrient=&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(); + 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, + }); +}); diff --git a/src/app/api/nutrients/route.ts b/src/app/api/nutrients/route.ts index 0204ab006..29b87314b 100644 --- a/src/app/api/nutrients/route.ts +++ b/src/app/api/nutrients/route.ts @@ -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; } } diff --git a/src/app/api/nutrients/water/__tests__/route.test.ts b/src/app/api/nutrients/water/__tests__/route.test.ts new file mode 100644 index 000000000..8c9bf7a1e --- /dev/null +++ b/src/app/api/nutrients/water/__tests__/route.test.ts @@ -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: + (fn: (...args: Args) => Promise) => + (...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", + }); + }); +}); diff --git a/src/app/api/nutrients/water/route.ts b/src/app/api/nutrients/water/route.ts new file mode 100644 index 000000000..d6d0f225f --- /dev/null +++ b/src/app/api/nutrients/water/route.ts @@ -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 { + 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, + }); +} diff --git a/src/lib/nutrients/__tests__/catalog.test.ts b/src/lib/nutrients/__tests__/catalog.test.ts index afbcf99e4..3974c7353 100644 --- a/src/lib/nutrients/__tests__/catalog.test.ts +++ b/src/lib/nutrients/__tests__/catalog.test.ts @@ -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(); + } + }); +}); diff --git a/src/lib/nutrients/catalog.ts b/src/lib/nutrients/catalog.ts index 03b5e273e..c8af5db0c 100644 --- a/src/lib/nutrients/catalog.ts +++ b/src/lib/nutrients/catalog.ts @@ -305,3 +305,38 @@ const NUTRIENT_CODE_SET: ReadonlySet = 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, + }; +} diff --git a/src/lib/openapi/routes/nutrients.ts b/src/lib/openapi/routes/nutrients.ts index 1b96543ff..b08015045 100644 --- a/src/lib/openapi/routes/nutrients.ts +++ b/src/lib/openapi/routes/nutrients.ts @@ -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 = { }, }, }, + "/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, + }, + }, + }, }; diff --git a/src/lib/query-keys/nutrients.ts b/src/lib/query-keys/nutrients.ts index 62d512401..0751e207b 100644 --- a/src/lib/query-keys/nutrients.ts +++ b/src/lib/query-keys/nutrients.ts @@ -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=&days=N`. Feeds the + * `/insights/nutrients` hydration + caffeine charts. + */ + nutrientDaily: (nutrient: string, days: number) => + ["nutrients", "daily", nutrient, days] as const, }; diff --git a/src/lib/validations/nutrients.ts b/src/lib/validations/nutrients.ts index ce7c1dc4b..506f67ad9 100644 --- a/src/lib/validations/nutrients.ts +++ b/src/lib/validations/nutrients.ts @@ -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).", + });