mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
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:
1 parent
bef3b9dd3f
commit
da2de015fe
15 files changed
+1075
-11
No files matched your search
+204
-2
@@ -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");
|
||||
@@ -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: {
|
||||
|
||||
@@ -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}$/) },
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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).",
|
||||
});
|
||||
Reference in new issue
Block a user