chore(release): v1.28.34 — nutrient intake lands as quiet context (server side)

Supplement-style intake written to Apple Health by nutrition apps —
vitamins, minerals, water, caffeine — can now sync as daily totals into a
dedicated NutrientIntakeDay store (migration 0241): composite (user, day,
nutrient) identity, re-post replaces, no soft delete, no rollup coupling.
A code-side catalog carries the 26 nutrient codes with canonical units,
plausibility caps, and EFSA dietary reference values (direction-aware;
chromium cites NIH ODS where EFSA declined a DRV).

Deliberately not a nutrition feature: no diary, no calories, no dashboard
surface. POST /api/nutrients/batch is gated behind the new opt-in
nutrients module (default off — with the module off the server refuses
ingest entirely), rate-limited, idempotency-wrapped, with per-entry
unit-mismatch and out-of-range skips. GET /api/nutrients serves a
catalog-ordered summary; the only visible surface is a read-only card
under Settings → Sources. Coach/correlation access is a later slice; the
iOS reading side is coordinated in the companion-app tracker.
This commit is contained in:
Marc-André Bombeck committed 2026-07-14 13:10:47 +02:00
1 parent 14a9a08a6e
commit 5445fc67ea
34 files changed
+2766 -12

No files matched your search

@@ -111,6 +111,13 @@ const MODULE_ROUTE_TREES: ReadonlyArray<string> = [
// ungated insights AI route fails this test BY NAME rather than leaking the
// surface over a Bearer token when the account turned insights off.
"src/app/api/insights",
// v1.28 — the nutrient-intake sync (opt-in `nutrients` module). Unlike the
// data-layer-exempt siblings this domain is REFUSE-INGEST-WHEN-OFF (the
// mental-health posture): both the batch ingest and the window-summary
// read call `requireModuleEnabled(user.id, "nutrients")` directly, so a
// phone whose user never opted in cannot land rows server-side. Walking
// the tree means a NEW ungated nutrients route fails BY NAME.
"src/app/api/nutrients",
];
/**
@@ -0,0 +1,146 @@
/**
* v1.28 — `GET /api/nutrients` window-summary contract tests.
*
* Pins the read shape the settings card consumes: catalog-ordered
* per-nutrient summaries (latest day + total, days-with-data), the
* module gate, and the `days` query validation.
*/
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";
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 },
};
function getReq(query = ""): NextRequest {
return new NextRequest(`http://localhost/api/nutrients${query}`);
}
interface OverviewResponse {
data: {
windowDays: number;
nutrients: Array<{
nutrient: string;
unit: string;
latestDay: string;
latestAmount: number;
daysWithData: number;
}>;
};
}
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true });
vi.mocked(prisma.nutrientIntakeDay.findMany).mockResolvedValue([] as never);
});
describe("GET /api/nutrients", () => {
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",
}),
});
const res = await GET(getReq());
expect(res.status).toBe(403);
const body = (await res.json()) as { meta?: { errorCode?: string } };
expect(body.meta?.errorCode).toBe("module.disabled");
expect(prisma.nutrientIntakeDay.findMany).not.toHaveBeenCalled();
});
it("rejects an out-of-range days value with 422", async () => {
for (const bad of ["0", "366", "abc"]) {
const res = await GET(getReq(`?days=${bad}`));
expect(res.status, `days=${bad}`).toBe(422);
}
});
it("defaults to a 14-day window and returns an empty list for a fresh account", async () => {
const res = await GET(getReq());
expect(res.status).toBe(200);
const body = (await res.json()) as OverviewResponse;
expect(body.data).toEqual({ windowDays: 14, nutrients: [] });
});
it("folds rows into catalog-ordered summaries with latest day/amount and day counts", async () => {
// Rows arrive nutrient ASC, day DESC (the route's orderBy) — the
// fold takes the first row per nutrient as the latest.
vi.mocked(prisma.nutrientIntakeDay.findMany).mockResolvedValue([
{ nutrient: "caffeine", unit: "mg", day: "2026-07-13", amount: 310 },
{ nutrient: "caffeine", unit: "mg", day: "2026-07-12", amount: 250 },
{ nutrient: "caffeine", unit: "mg", day: "2026-07-11", amount: 400 },
{ nutrient: "vitamin_d", unit: "ug", day: "2026-07-13", amount: 22.5 },
{ nutrient: "vitamin_d", unit: "ug", day: "2026-07-10", amount: 20 },
] as never);
const res = await GET(getReq("?days=7"));
const body = (await res.json()) as OverviewResponse;
expect(body.data.windowDays).toBe(7);
// Catalog order: vitamin_d (vitamins block) before caffeine (tail).
expect(body.data.nutrients).toEqual([
{
nutrient: "vitamin_d",
unit: "ug",
latestDay: "2026-07-13",
latestAmount: 22.5,
daysWithData: 2,
},
{
nutrient: "caffeine",
unit: "mg",
latestDay: "2026-07-13",
latestAmount: 310,
daysWithData: 3,
},
]);
});
it("scopes the query to the authenticated user and the window floor", async () => {
await GET(getReq("?days=14"));
expect(prisma.nutrientIntakeDay.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
userId: "user-1",
day: { gte: expect.stringMatching(/^\d{4}-\d{2}-\d{2}$/) },
}),
}),
);
});
});
@@ -0,0 +1,354 @@
/**
* v1.28 — `POST /api/nutrients/batch` contract tests.
*
* Pins the ingest posture: module gate first (403 `module.disabled`),
* per-entry skip guards (unit mismatch / plausibility / day key), the
* insert-vs-update statuses off the composite-PK probe, and the batch
* envelope errors (`too_large`, multi-issue `invalid`).
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
nutrientIntakeDay: {
findUnique: vi.fn(),
upsert: vi.fn(),
},
},
}));
vi.mock("@/lib/modules/gate", () => ({
requireModuleEnabled: vi.fn(),
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
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 { auditLog } from "@/lib/auth/audit";
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 },
};
function postReq(body: unknown): NextRequest {
return new NextRequest("http://localhost/api/nutrients/batch", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
/** Yesterday's UTC day key — always a valid, in-window day. */
function recentDay(offsetDays = 1): string {
return new Date(Date.now() - offsetDays * 24 * 60 * 60 * 1000)
.toISOString()
.slice(0, 10);
}
interface BatchResponse {
data: {
processed: number;
inserted: number;
updated: number;
skipped: Array<{ index: number; reason: string }>;
entries: Array<{ index: number; status: string; reason?: string }>;
};
}
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never);
vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true });
vi.mocked(auditLog).mockResolvedValue(undefined as never);
vi.mocked(prisma.nutrientIntakeDay.findUnique).mockResolvedValue(null);
vi.mocked(prisma.nutrientIntakeDay.upsert).mockResolvedValue({} as never);
});
describe("POST /api/nutrients/batch — module gate", () => {
it("refuses ingest 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",
}),
});
const res = await POST(
postReq({
entries: [
{ day: recentDay(), nutrient: "vitamin_d", unit: "ug", amount: 20 },
],
}),
);
expect(res.status).toBe(403);
const body = (await res.json()) as {
data: null;
meta?: { errorCode?: string; module?: string };
};
expect(body.data).toBeNull();
expect(body.meta?.errorCode).toBe("module.disabled");
expect(body.meta?.module).toBe("nutrients");
expect(prisma.nutrientIntakeDay.upsert).not.toHaveBeenCalled();
});
});
describe("POST /api/nutrients/batch — envelope errors", () => {
it("rejects a 501-entry batch with 422 nutrient.batch.too_large before Zod", async () => {
const entries = Array.from({ length: 501 }, () => ({
day: recentDay(),
nutrient: "vitamin_c",
unit: "mg",
amount: 100,
}));
const res = await POST(postReq({ entries }));
expect(res.status).toBe(422);
const body = (await res.json()) as { meta?: { errorCode?: string } };
expect(body.meta?.errorCode).toBe("nutrient.batch.too_large");
});
it("surfaces multiple simultaneous Zod issues as one 422 nutrient.batch.invalid", async () => {
const res = await POST(
postReq({
entries: [
{ day: "not-a-day", nutrient: "vitamin_d", unit: "ug", amount: 20 },
{ day: recentDay(), nutrient: "kryptonite", unit: "mg", amount: 1 },
{ day: recentDay(), nutrient: "iron", unit: "mg", amount: -5 },
],
}),
);
expect(res.status).toBe(422);
const body = (await res.json()) as {
details: { issues: unknown[] };
meta?: { errorCode?: string };
};
expect(body.meta?.errorCode).toBe("nutrient.batch.invalid");
expect(body.details.issues.length).toBeGreaterThanOrEqual(3);
});
it("returns 429 when the per-user rate bucket is exhausted", async () => {
vi.mocked(checkRateLimit).mockResolvedValue({ allowed: false } as never);
const res = await POST(
postReq({
entries: [
{ day: recentDay(), nutrient: "water", unit: "ml", amount: 1500 },
],
}),
);
expect(res.status).toBe(429);
});
});
describe("POST /api/nutrients/batch — per-entry skip guards", () => {
it("skips a unit mismatch (µg posted as mg) without touching the store", async () => {
const res = await POST(
postReq({
entries: [
// vitamin_d is canonically µg; posting mg is the 1000× hazard.
{ day: recentDay(), nutrient: "vitamin_d", unit: "mg", amount: 20 },
],
}),
);
expect(res.status).toBe(200);
const body = (await res.json()) as BatchResponse;
expect(body.data.entries[0]).toEqual({
index: 0,
status: "skipped",
reason: "unit_mismatch",
});
expect(body.data.skipped).toEqual([{ index: 0, reason: "unit_mismatch" }]);
expect(prisma.nutrientIntakeDay.upsert).not.toHaveBeenCalled();
});
it("skips a value above the plausibility cap", async () => {
const res = await POST(
postReq({
entries: [
// caffeine cap is 2000 mg.
{ day: recentDay(), nutrient: "caffeine", unit: "mg", amount: 2001 },
],
}),
);
const body = (await res.json()) as BatchResponse;
expect(body.data.entries[0].status).toBe("skipped");
expect(body.data.entries[0].reason).toBe("value_out_of_range");
expect(prisma.nutrientIntakeDay.upsert).not.toHaveBeenCalled();
});
it("skips an impossible calendar day and a far-future day as day_invalid", async () => {
const res = await POST(
postReq({
entries: [
// Regex-shaped but not a real date.
{ day: "2026-02-31", nutrient: "iron", unit: "mg", amount: 10 },
// Beyond tomorrow in every IANA timezone.
{ day: "2999-01-01", nutrient: "iron", unit: "mg", amount: 10 },
],
}),
);
const body = (await res.json()) as BatchResponse;
expect(body.data.entries.map((e) => e.reason)).toEqual([
"day_invalid",
"day_invalid",
]);
expect(prisma.nutrientIntakeDay.upsert).not.toHaveBeenCalled();
});
it("a skipped entry never fails the batch — siblings still land", async () => {
const res = await POST(
postReq({
entries: [
{ day: recentDay(), nutrient: "vitamin_d", unit: "mg", amount: 20 },
{ day: recentDay(), nutrient: "magnesium", unit: "mg", amount: 300 },
],
}),
);
const body = (await res.json()) as BatchResponse;
expect(body.data.processed).toBe(2);
expect(body.data.inserted).toBe(1);
expect(body.data.skipped).toHaveLength(1);
expect(prisma.nutrientIntakeDay.upsert).toHaveBeenCalledTimes(1);
});
});
describe("POST /api/nutrients/batch — upsert semantics", () => {
it("reports inserted for a fresh (day, nutrient) and writes the catalog's canonical unit", async () => {
const day = recentDay();
const res = await POST(
postReq({
entries: [
{
day,
nutrient: "vitamin_d",
unit: "ug",
amount: 22.5,
externalSourceVersion: "yazio-9.9",
},
],
}),
);
const body = (await res.json()) as BatchResponse;
expect(body.data.inserted).toBe(1);
expect(body.data.updated).toBe(0);
expect(body.data.entries[0].status).toBe("inserted");
expect(prisma.nutrientIntakeDay.upsert).toHaveBeenCalledWith({
where: {
userId_day_nutrient: {
userId: "user-1",
day,
nutrient: "vitamin_d",
},
},
create: {
userId: "user-1",
day,
nutrient: "vitamin_d",
amount: 22.5,
unit: "ug",
externalSourceVersion: "yazio-9.9",
},
update: {
amount: 22.5,
unit: "ug",
externalSourceVersion: "yazio-9.9",
},
});
});
it("reports updated (never duplicate) when the composite key already exists", async () => {
vi.mocked(prisma.nutrientIntakeDay.findUnique).mockResolvedValue({
userId: "user-1",
} as never);
const res = await POST(
postReq({
entries: [
{ day: recentDay(), nutrient: "caffeine", unit: "mg", amount: 310 },
],
}),
);
const body = (await res.json()) as BatchResponse;
expect(body.data.inserted).toBe(0);
expect(body.data.updated).toBe(1);
expect(body.data.entries[0].status).toBe("updated");
expect(
body.data.entries.some((e) => e.status === ("duplicate" as string)),
).toBe(false);
});
it("writes the audit-ledger breadcrumb only when at least one row landed", async () => {
await POST(
postReq({
entries: [
{ day: recentDay(), nutrient: "vitamin_d", unit: "mg", amount: 20 },
],
}),
);
expect(auditLog).not.toHaveBeenCalled();
await POST(
postReq({
entries: [
{ day: recentDay(), nutrient: "water", unit: "ml", amount: 1800 },
],
}),
);
expect(auditLog).toHaveBeenCalledWith(
"nutrient.batch.ingest",
expect.objectContaining({ userId: "user-1" }),
);
});
it("a thrown upsert marks only that entry skipped upsert_failed", async () => {
vi.mocked(prisma.nutrientIntakeDay.upsert)
.mockRejectedValueOnce(new Error("boom"))
.mockResolvedValueOnce({} as never);
const res = await POST(
postReq({
entries: [
{ day: recentDay(), nutrient: "zinc", unit: "mg", amount: 10 },
{ day: recentDay(), nutrient: "iron", unit: "mg", amount: 12 },
],
}),
);
const body = (await res.json()) as BatchResponse;
expect(body.data.entries[0].status).toBe("skipped");
expect(body.data.entries[1].status).toBe("inserted");
expect(body.data.inserted).toBe(1);
});
});
+248
View File
@@ -0,0 +1,248 @@
/**
* `POST /api/nutrients/batch` — micronutrient day-total ingest (v1.28).
*
* iOS posts daily totals for the closed 26-code catalog (vitamins,
* minerals, water, caffeine) computed via `HKStatisticsCollectionQuery`
* day-anchored cumulative sums; `day` arrives as YYYY-MM-DD in the
* user's IANA timezone (the `stats:` day-key contract — the server
* trusts the string after regex + calendar sanity, no re-derivation).
*
* Upsert key = the composite PK (userId, day, nutrient). Re-post
* replaces amount / unit / externalSourceVersion — last-writer-wins,
* the day-total contract; per-entry status is `updated` then, never
* `duplicate`. Per-entry failures come back `skipped` with a reason
* (`unit_mismatch` | `value_out_of_range` | `day_invalid` |
* `upsert_failed`), never a batch failure — the measurements-batch
* posture.
*
* Module gate FIRST: the opt-in `nutrients` module (default off)
* refuses ingest with the 403 `module.disabled` envelope, so a phone
* whose user never opted in cannot land rows server-side
* (refuse-ingest posture, mirroring mental health — not surface-only).
*/
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import {
apiError,
apiSuccess,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { withIdempotency } from "@/lib/idempotency";
import { checkRateLimit } from "@/lib/rate-limit";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { NUTRIENT_CATALOG } from "@/lib/nutrients/catalog";
import {
MAX_NUTRIENT_ENTRIES_PER_BATCH,
nutrientBatchSchema,
} from "@/lib/validations/nutrients";
const BATCH_RATE_LIMIT_MAX = 60;
const BATCH_RATE_LIMIT_WINDOW_MS = 60 * 1000;
/**
* Calendar sanity for an already regex-shaped YYYY-MM-DD key: reject
* impossible dates (2026-02-31) via a UTC round-trip.
*/
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
);
}
/**
* Upper day bound with timezone slack: "tomorrow" in the most-ahead
* IANA zone (UTC+14) is at most the UTC calendar date + 2 days, so a
* key beyond that cannot be a legitimate local day and is corruption.
*/
function maxAcceptableDay(now: Date): string {
const limit = new Date(now.getTime() + 2 * 24 * 60 * 60 * 1000);
return limit.toISOString().slice(0, 10);
}
type EntryStatus = "inserted" | "updated" | "skipped";
interface EntryResult {
index: number;
status: EntryStatus;
reason?: string;
}
export const POST = apiHandler(withIdempotency<[NextRequest]>(postBatch));
async function postBatch(request: NextRequest): Promise<Response> {
const { user } = await requireAuth();
// Module gate first: a disabled module must refuse before any body
// work, so iOS can branch on the 403 `module.disabled` errorCode and
// stop syncing rather than retry.
const gate = await requireModuleEnabled(user.id, "nutrients");
if (!gate.enabled) return gate.response;
const rl = await checkRateLimit(
`nutrients:batch:${user.id}`,
BATCH_RATE_LIMIT_MAX,
BATCH_RATE_LIMIT_WINDOW_MS,
);
if (!rl.allowed) {
return apiError("Too many batch submissions, try again later", 429);
}
const { data: rawBody, error: jsonError } = await safeJson(request, {
maxBytes: 1024 * 1024,
});
if (jsonError) return jsonError;
if (
typeof rawBody === "object" &&
rawBody !== null &&
"entries" in rawBody &&
Array.isArray((rawBody as { entries: unknown }).entries) &&
(rawBody as { entries: unknown[] }).entries.length >
MAX_NUTRIENT_ENTRIES_PER_BATCH
) {
return apiError(
`Batch exceeds the ${MAX_NUTRIENT_ENTRIES_PER_BATCH}-entry limit`,
422,
{ errorCode: "nutrient.batch.too_large" },
);
}
const parsed = nutrientBatchSchema.safeParse(rawBody);
if (!parsed.success) {
annotate({
action: { name: "nutrient.batch.validation-failed" },
meta: { issue_count: parsed.error.issues.length },
});
return returnAllZodIssues(parsed.error, 422, {
errorCode: "nutrient.batch.invalid",
});
}
const { entries } = parsed.data;
const dayCeiling = maxAcceptableDay(new Date());
const results: EntryResult[] = [];
const skipped: Array<{ index: number; reason: string }> = [];
let inserted = 0;
let updated = 0;
for (let i = 0; i < entries.length; i++) {
const entry = entries[i];
const definition = NUTRIENT_CATALOG[entry.nutrient];
// The wire carries the unit deliberately: a µg/mg confusion is a
// silent 1000× corruption; anything but the catalog's canonical
// unit skips the entry rather than converting.
if (entry.unit !== definition.unit) {
skipped.push({ index: i, reason: "unit_mismatch" });
results.push({ index: i, status: "skipped", reason: "unit_mismatch" });
continue;
}
if (entry.amount > definition.plausibleDailyMax) {
skipped.push({ index: i, reason: "value_out_of_range" });
results.push({
index: i,
status: "skipped",
reason: "value_out_of_range",
});
continue;
}
if (!isRealCalendarDay(entry.day) || entry.day > dayCeiling) {
skipped.push({ index: i, reason: "day_invalid" });
results.push({ index: i, status: "skipped", reason: "day_invalid" });
continue;
}
try {
const key = {
userId_day_nutrient: {
userId: user.id,
day: entry.day,
nutrient: entry.nutrient,
},
};
// Probe-then-upsert so the response reliably distinguishes
// `inserted` from `updated` (the mood-bulk pattern; two round
// trips per entry is fine under the 500-entry cap).
const existing = await prisma.nutrientIntakeDay.findUnique({
where: key,
select: { userId: true },
});
await prisma.nutrientIntakeDay.upsert({
where: key,
create: {
userId: user.id,
day: entry.day,
nutrient: entry.nutrient,
amount: entry.amount,
unit: definition.unit,
externalSourceVersion: entry.externalSourceVersion ?? null,
},
update: {
// Last-writer-wins on the day total: iOS re-posts the current
// and previous local day on every sync as the totals grow.
amount: entry.amount,
unit: definition.unit,
externalSourceVersion: entry.externalSourceVersion ?? null,
},
});
if (existing) {
updated += 1;
results.push({ index: i, status: "updated" });
} else {
inserted += 1;
results.push({ index: i, status: "inserted" });
}
} catch (err: unknown) {
const reason =
err instanceof Error ? err.message.slice(0, 120) : "upsert_failed";
skipped.push({ index: i, reason });
results.push({ index: i, status: "skipped", reason });
}
}
if (inserted + updated > 0) {
await auditLog("nutrient.batch.ingest", {
userId: user.id,
ipAddress: getClientIp(request),
details: {
processed: entries.length,
inserted,
updated,
skipped: skipped.length,
},
});
}
annotate({
action: { name: "nutrient.batch.ingest" },
meta: {
processed: entries.length,
inserted,
updated,
skipped: skipped.length,
},
});
return apiSuccess({
processed: entries.length,
inserted,
updated,
skipped,
entries: results,
});
}
+97
View File
@@ -0,0 +1,97 @@
/**
* `GET /api/nutrients?days=N` — synced-nutrient window summary (v1.28).
*
* Feeds the read-only settings card (Settings → Sources): per nutrient
* with data inside the window, the latest synced day + total and the
* count of days carrying data — the smallest honest surface for "what
* does the server hold". Catalog order, no pagination (≤ 26 rows).
* Module-gated like the ingest path: 403 `module.disabled` when the
* opt-in `nutrients` module is off. `userId` is narrowed from auth.
*/
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_CODES, isNutrientCode } from "@/lib/nutrients/catalog";
import { nutrientOverviewQuerySchema } from "@/lib/validations/nutrients";
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 = nutrientOverviewQuerySchema.safeParse({
days: request.nextUrl.searchParams.get("days") ?? undefined,
});
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422, {
errorCode: "nutrient.read.invalid",
});
}
const { days } = parsed.data;
// Window floor as a UTC day key. The stored `day` is a local-timezone
// key, so the UTC floor is off by at most one calendar day at the
// window edge — fine for a "last N days" summary card.
const since = new Date(Date.now() - (days - 1) * 24 * 60 * 60 * 1000)
.toISOString()
.slice(0, 10);
const rows = await prisma.nutrientIntakeDay.findMany({
where: { userId: user.id, day: { gte: since } },
orderBy: [{ nutrient: "asc" }, { day: "desc" }],
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.
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 {
byCode.set(row.nutrient, {
unit: row.unit,
latestDay: row.day,
latestAmount: row.amount,
days: 1,
});
}
}
// Catalog order; a stored code that ever fell out of the catalog is
// dropped from the card rather than crashing the label lookup.
const nutrients = NUTRIENT_CODES.filter(
(code) => isNutrientCode(code) && byCode.has(code),
).map((code) => {
const summary = byCode.get(code)!;
return {
nutrient: code,
unit: summary.unit,
latestDay: summary.latestDay,
latestAmount: summary.latestAmount,
daysWithData: summary.days,
};
});
annotate({
action: { name: "nutrient.intake.read" },
meta: {
window_days: days,
nutrient_count: nutrients.length,
row_count: rows.length,
},
});
return apiSuccess({ windowDays: days, nutrients });
});
@@ -0,0 +1,108 @@
/**
* v1.28 — `<NutrientIntakeCard>` render contract.
*
* The read-only Settings → Sources list for the opt-in `nutrients`
* module: renders NOTHING while the module is off (the Modules hub is
* the consent surface), and a flat name / latest-total / days-count
* list when it is on. Test strategy mirrors `modules-section.test.tsx`:
* mock `@tanstack/react-query` + `useAuth`, render under SSR.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import type { AuthUser } from "@/hooks/use-auth";
interface QueryState {
data?: unknown;
isLoading: boolean;
}
let queryState: QueryState = { data: undefined, isLoading: false };
vi.mock("@tanstack/react-query", () => ({
useQuery: () => queryState,
}));
const authSpy = vi.fn<() => { user: AuthUser | null }>();
vi.mock("@/hooks/use-auth", async () => {
const actual =
await vi.importActual<typeof import("@/hooks/use-auth")>(
"@/hooks/use-auth",
);
return { ...actual, useAuth: () => authSpy() };
});
import { NutrientIntakeCard } from "../nutrient-intake-card";
function user(modules: Record<string, boolean>): AuthUser {
return { id: "user-1", modules } as unknown as AuthUser;
}
function render(): string {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<NutrientIntakeCard />
</I18nProvider>,
);
}
beforeEach(() => {
queryState = { data: undefined, isLoading: false };
authSpy.mockReturnValue({ user: user({ nutrients: true }) });
});
describe("<NutrientIntakeCard>", () => {
it("renders nothing while the nutrients module is off", () => {
authSpy.mockReturnValue({ user: user({ nutrients: false }) });
expect(render()).toBe("");
authSpy.mockReturnValue({ user: user({}) });
expect(render()).toBe("");
});
it("renders the synced list with name, latest total (µg rendered), and day count", () => {
queryState = {
isLoading: false,
data: {
windowDays: 14,
nutrients: [
{
nutrient: "vitamin_d",
unit: "ug",
latestDay: "2026-07-13",
latestAmount: 22.5,
daysWithData: 5,
},
{
nutrient: "caffeine",
unit: "mg",
latestDay: "2026-07-13",
latestAmount: 310,
daysWithData: 14,
},
],
},
};
const html = render();
expect(html).toContain('data-slot="nutrient-intake-card"');
expect(html).toContain("Vitamin D");
expect(html).toContain("Caffeine");
// Wire `ug` renders as µg; the day key renders verbatim.
expect(html).toContain("µg");
expect(html).toContain("2026-07-13");
const rows = html.match(/data-slot="nutrient-intake-row"/g) ?? [];
expect(rows).toHaveLength(2);
// Read-only surface: the only interactive element is the modules link.
expect(html).toContain('data-slot="nutrient-intake-modules-link"');
expect(html).not.toContain("<button");
});
it("renders the empty state when the module is on but nothing has synced", () => {
queryState = {
isLoading: false,
data: { windowDays: 14, nutrients: [] },
};
const html = render();
expect(html).toContain("No nutrient data has been synced yet.");
});
});
@@ -47,6 +47,7 @@ import {
FileScan,
FileText,
MessageCircleHeart,
Leaf,
Moon,
Plug,
Pill,
@@ -96,6 +97,8 @@ const MODULE_ICONS: Record<ModuleKey, LucideIcon> = {
inboundDocuments: FileScan,
// v1.25.0 — opt-in mental-health screeners (PHQ-9 / GAD-7).
mentalHealth: Brain,
// v1.28 — opt-in micronutrient-intake sync (Apple Health day totals).
nutrients: Leaf,
};
export function ModulesSection() {
@@ -0,0 +1,130 @@
"use client";
/**
* `<NutrientIntakeCard>` — Settings → Sources, read-only synced-nutrient
* list (v1.28).
*
* The smallest honest surface for the opt-in `nutrients` module: a flat
* list of what the server holds — per nutrient the latest synced day
* total and the days-with-data count over the last 14 days — so the
* user can verify the sync works and see exactly what the account
* stores. No route slug, no nav entry, no chart, no edit affordance;
* the module toggle in the Modules hub is the consent surface and the
* card links to it. Rendered only when the module is on (resolved
* module map from `useAuth().user.modules`, the environment-section
* pattern); reads unwrap through `apiGet`, the key rides the
* centralized factory.
*/
import { useQuery } from "@tanstack/react-query";
import Link from "next/link";
import { Leaf } from "lucide-react";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { Skeleton } from "@/components/ui/skeleton";
import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
import { apiGet } from "@/lib/api/api-fetch";
import { queryKeys } from "@/lib/query-keys";
const WINDOW_DAYS = 14;
/** Wire `ug` renders as µg; mg / ml pass through. */
const UNIT_LABELS: Record<string, string> = { mg: "mg", ug: "µg", ml: "ml" };
interface NutrientOverviewRow {
nutrient: string;
unit: string;
latestDay: string;
latestAmount: number;
daysWithData: number;
}
interface NutrientIntakeOverview {
windowDays: number;
nutrients: NutrientOverviewRow[];
}
function formatAmount(amount: number): string {
// Day totals arrive as floats; one decimal is plenty for a glance
// list and trailing zeros read as noise.
return String(Math.round(amount * 10) / 10);
}
export function NutrientIntakeCard() {
const { t } = useTranslations();
const { user } = useAuth();
const enabled = user?.modules?.nutrients === true;
const overview = useQuery({
queryKey: queryKeys.nutrientIntake(WINDOW_DAYS),
enabled,
queryFn: () =>
apiGet<NutrientIntakeOverview>(`/api/nutrients?days=${WINDOW_DAYS}`),
});
if (!enabled) return null;
const rows = overview.data?.nutrients ?? [];
return (
<SettingsCard data-slot="nutrient-intake-card">
<SettingsCardHeader
icon={Leaf}
title={t("settings.sections.sources.nutrients.title")}
description={t("settings.sections.sources.nutrients.description", {
days: WINDOW_DAYS,
})}
className="mb-3"
/>
{overview.isLoading ? (
<div className="space-y-1 pl-7" aria-hidden="true">
<Skeleton className="h-9 w-full rounded-md" />
<Skeleton className="h-9 w-full rounded-md" />
<Skeleton className="h-9 w-full rounded-md" />
</div>
) : rows.length === 0 ? (
<p className="text-muted-foreground pl-7 text-sm">
{t("settings.sections.sources.nutrients.empty")}
</p>
) : (
<ul className="divide-border divide-y rounded-md border" role="list">
{rows.map((row) => (
<li
key={row.nutrient}
data-slot="nutrient-intake-row"
className="flex flex-wrap items-baseline justify-between gap-x-3 gap-y-0.5 p-2"
>
<span className="text-sm">
{t(`nutrients.names.${row.nutrient}`)}
</span>
<span className="text-muted-foreground text-xs tabular-nums">
{t("settings.sections.sources.nutrients.latest", {
amount: formatAmount(row.latestAmount),
unit: UNIT_LABELS[row.unit] ?? row.unit,
date: row.latestDay,
})}
{" · "}
{t("settings.sections.sources.nutrients.daysWithData", {
count: row.daysWithData,
days: WINDOW_DAYS,
})}
</span>
</li>
))}
</ul>
)}
<p className="text-muted-foreground mt-3 text-xs">
{t("settings.sections.sources.nutrients.moduleHint")}{" "}
<Link
href="/settings/modules"
className="text-primary underline underline-offset-2"
data-slot="nutrient-intake-modules-link"
>
{t("settings.sections.sources.nutrients.moduleHintLink")}
</Link>
</p>
</SettingsCard>
);
}
@@ -44,6 +44,7 @@ import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { NutrientIntakeCard } from "@/components/settings/nutrient-intake-card";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import {
@@ -580,6 +581,10 @@ export function SourcesSection() {
</Link>
</p>
</SettingsCard>
{/* v1.28 — read-only synced-nutrient list for the opt-in `nutrients`
module (renders nothing while the module is off). */}
<NutrientIntakeCard />
</div>
);
}
@@ -93,6 +93,7 @@ function moduleMap(
mcp: true,
inboundDocuments: true,
mentalHealth: true,
nutrients: true,
};
return { ...base, ...overrides };
}
+9 -1
View File
@@ -973,7 +973,15 @@ export const HK_QUANTITY_TYPE_DEFERRED = new Set<string>([
// `AppleSleepingBreathingDisturbances` (the per-night quantity) →
// BREATHING_DISTURBANCES, and `HKCategoryTypeIdentifierSleepApneaEvent`
// (the fired screening event) → BREATHING_DISTURBANCE_EVENT.
// Nutrition — the maintainer directive, indefinite hold
// Nutrition — the Measurement path stays PERMANENTLY closed to
// Dietary types. Energy / macros (and sodium): indefinite hold, the
// anti-goal against diet-app territory stands. Water + caffeine (and
// the 24 micro Dietary vitamin/mineral identifiers, which were never
// listed here because no sample ever mapped) ride the dedicated
// day-total endpoint `POST /api/nutrients/batch` (closed catalog in
// `src/lib/nutrients/catalog.ts`) — never Measurements, so the two
// entries below stay deferred and a stray sample-grain post keeps
// skipping as unmappable.
"HKQuantityTypeIdentifierDietaryWater",
"HKQuantityTypeIdentifierDietaryCaffeine",
"HKQuantityTypeIdentifierDietaryEnergyConsumed",
+6 -2
View File
@@ -357,14 +357,18 @@ describe("registry — opt-in marker", () => {
// are highly sensitive: `mcp` (remote external-assistant endpoint),
// `environment` (outbound weather fetch tied to a coarse location),
// `inboundDocuments` (sends an uploaded clinical document to the OCR/vision
// provider), and `mentalHealth` (a depression / anxiety self-assessment — at
// least as sensitive as mood, surfaced only on explicit opt-in). Every other
// provider), `mentalHealth` (a depression / anxiety self-assessment — at
// least as sensitive as mood, surfaced only on explicit opt-in), and
// `nutrients` (supplement-pattern intake synced from the phone's health
// store — the device-side HealthKit permission is not visible consent to
// server/AI-side storage, so ingest refuses while off). Every other
// module is the default-on disabled-allowlist.
const OPT_IN_KEYS = new Set([
"mcp",
"environment",
"inboundDocuments",
"mentalHealth",
"nutrients",
]);
it("marks only the egress-surface modules opt-in (every other is default-on)", () => {
+20
View File
@@ -147,6 +147,15 @@ export const MODULE_KEYS = [
// assessment routes refuse server-side. Item answers are always encrypted and
// always excluded from the AI Coach / MCP regardless of this toggle.
"mentalHealth",
// v1.28 — micronutrient-intake sync (vitamins, minerals, water,
// caffeine as day totals from Apple Health). OPT-IN (off by default):
// the HealthKit read permission is consent to read on the DEVICE, not
// visible consent to "a server — and later an AI assistant — holds my
// supplement pattern"; the project already answered this for less
// sensitive data (`environment`, weather, is opt-in). With it off the
// ingest route refuses (403 `module.disabled`) and the settings card
// does not render. The `optIn` marker inverts the per-user default.
"nutrients",
] as const;
export type ModuleKey = (typeof MODULE_KEYS)[number];
@@ -323,6 +332,17 @@ export const MODULE_REGISTRY: Readonly<Record<ModuleKey, ModuleDefinition>> =
// off the AI Coach / MCP regardless.
optIn: true,
},
nutrients: {
key: "nutrients",
labelKey: "modules.nutrients.label",
descriptionKey: "modules.nutrients.description",
category: "device",
// Off by default: supplement-pattern intake synced from the phone's
// health store is server/AI-consent-sensitive even though the
// HealthKit read permission was granted on the device. Ships dark;
// ingest refuses while off (refuse-ingest posture, not surface-only).
optIn: true,
},
});
const MODULE_KEY_SET: ReadonlySet<string> = new Set(MODULE_KEYS);
+153
View File
@@ -0,0 +1,153 @@
/**
* v1.28 — nutrient-catalog invariants.
*
* The catalog is the closed source of truth for the intake sync: codes,
* HK identifiers, canonical units (the µg/mg guard), plausibility caps,
* and the EFSA references a later Coach block reads. These tests pin
* the invariants the routes and the wire contract rely on.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import {
NUTRIENT_CATALOG,
NUTRIENT_CODES,
NUTRIENT_DEFINITIONS,
isNutrientCode,
} from "../catalog";
describe("nutrient catalog", () => {
it("carries exactly the 26 maintainer-scoped codes (no sodium, no potassium, no macros)", () => {
expect(NUTRIENT_CODES).toHaveLength(26);
expect(Object.keys(NUTRIENT_CATALOG).sort()).toEqual(
[...NUTRIENT_CODES].sort(),
);
// The maintainer exclusions stay excluded.
for (const banned of [
"sodium",
"potassium",
"energy",
"carbohydrates",
"protein",
"fat",
"sugar",
"fiber",
]) {
expect(isNutrientCode(banned), `banned code present: ${banned}`).toBe(
false,
);
}
});
it("codes and HealthKit identifiers are unique, and every HK id is a Dietary quantity type", () => {
const codes = new Set<string>();
const hkIds = new Set<string>();
for (const def of NUTRIENT_DEFINITIONS) {
expect(codes.has(def.code)).toBe(false);
expect(hkIds.has(def.hkIdentifier)).toBe(false);
codes.add(def.code);
hkIds.add(def.hkIdentifier);
expect(def.hkIdentifier).toMatch(
/^HKQuantityTypeIdentifierDietary[A-Za-z0-9]+$/,
);
}
});
it("every unit is canonical (mg | ug | ml) and water is the only ml type", () => {
for (const def of NUTRIENT_DEFINITIONS) {
expect(["mg", "ug", "ml"]).toContain(def.unit);
}
const mlCodes = NUTRIENT_DEFINITIONS.filter((d) => d.unit === "ml").map(
(d) => d.code,
);
expect(mlCodes).toEqual(["water"]);
// The known-µg micros stay µg — a silent switch to mg here would be
// the exact 1000× hazard the unit echo guards against.
for (const ugCode of [
"vitamin_a",
"biotin",
"folate",
"vitamin_b12",
"vitamin_d",
"vitamin_k",
"selenium",
"chromium",
"molybdenum",
"iodine",
] as const) {
expect(NUTRIENT_CATALOG[ugCode].unit).toBe("ug");
}
expect(NUTRIENT_CATALOG.caffeine.unit).toBe("mg");
});
it("every code carries a finite positive plausibility cap", () => {
for (const def of NUTRIENT_DEFINITIONS) {
expect(Number.isFinite(def.plausibleDailyMax)).toBe(true);
expect(def.plausibleDailyMax).toBeGreaterThan(0);
}
// The design-pinned guards.
expect(NUTRIENT_CATALOG.caffeine.plausibleDailyMax).toBe(2000);
expect(NUTRIENT_CATALOG.water.plausibleDailyMax).toBe(20000);
});
it("every code has a reference entry carrying kind, direction, a citation, and a resolvable value", () => {
for (const def of NUTRIENT_DEFINITIONS) {
const ref = def.reference;
expect(ref, `missing reference: ${def.code}`).toBeDefined();
expect(["PRI", "AI", "safeLevel"]).toContain(ref.kind);
expect(["target", "upperGuidance"]).toContain(ref.direction);
expect(ref.source.length).toBeGreaterThan(0);
// Value shape: a uniform adult value XOR a full sex split — never
// both, never a half split (profile resolution depends on this).
const hasAdult = ref.adult != null;
const hasMale = ref.male != null;
const hasFemale = ref.female != null;
expect(hasMale, `half sex-split: ${def.code}`).toBe(hasFemale);
expect(hasAdult !== hasMale, `value shape: ${def.code}`).toBe(true);
for (const v of [ref.adult, ref.male, ref.female]) {
if (v != null) expect(v).toBeGreaterThan(0);
}
}
});
it("caffeine is the one upperGuidance reference; everything else is a target", () => {
for (const def of NUTRIENT_DEFINITIONS) {
expect(def.reference.direction).toBe(
def.code === "caffeine" ? "upperGuidance" : "target",
);
}
expect(NUTRIENT_CATALOG.caffeine.reference.kind).toBe("safeLevel");
expect(NUTRIENT_CATALOG.caffeine.reference.adult).toBe(400);
});
it("every citation names EFSA, except the documented chromium exception (EFSA sets no DRV)", () => {
for (const def of NUTRIENT_DEFINITIONS) {
if (def.code === "chromium") {
expect(def.reference.source).toContain("NIH");
expect(def.reference.source).toContain("EFSA");
continue;
}
expect(def.reference.source, `citation: ${def.code}`).toContain("EFSA");
}
});
it("iron carries the EFSA sex split (11 male / 16 female)", () => {
expect(NUTRIENT_CATALOG.iron.reference.male).toBe(11);
expect(NUTRIENT_CATALOG.iron.reference.female).toBe(16);
});
it("every labelKey follows nutrients.names.<code> and resolves in the EN bundle", () => {
const en = JSON.parse(
readFileSync(join(__dirname, "../../../../messages/en.json"), "utf8"),
) as { nutrients?: { names?: Record<string, string> } };
for (const def of NUTRIENT_DEFINITIONS) {
expect(def.labelKey).toBe(`nutrients.names.${def.code}`);
expect(
en.nutrients?.names?.[def.code],
`EN name missing: ${def.code}`,
).toBeTruthy();
}
});
});
+307
View File
@@ -0,0 +1,307 @@
/**
* Nutrient catalog — the closed code set for the micronutrient-intake
* sync (v1.28).
*
* 26 codes: the 24 HealthKit `Dietary*` vitamin/mineral quantity types
* plus DietaryWater and DietaryCaffeine. Energy, carbohydrates,
* protein, fat, sugar, fiber AND sodium/potassium are deliberately NOT
* here — the anti-goal against diet-app territory stands, and
* sodium/potassium are food-composition signals, not "substances
* taken" (maintainer decision, out until explicit user demand).
*
* This catalog is the single source of truth for:
* - the closed `nutrient` code enum on the wire and in storage;
* - the canonical storage unit per code, which is ALSO the pinned
* HealthKit query unit for iOS (mass types in mg or µg as listed,
* water in mL — never IU). The batch route rejects any entry whose
* echoed `unit` differs, because a silent µg/mg confusion is a
* 1000× corruption and the one-string echo is the cheapest guard;
* - the generous per-day plausibility cap (ingest guard against
* unit-confused or corrupted writers, NOT a health judgement —
* values above high-dose supplement reality get skipped);
* - the EFSA dietary reference values a later Coach block compares
* against. Every reference names its EFSA value kind (PRI /
* AI / safeLevel) and a direction: `target` for reference intakes,
* `upperGuidance` for caffeine's safe-level ceiling. Sex-split
* values resolve against the user profile at consumption time —
* a profile without sex omits the reference, never guesses.
*
* Reference values are the EFSA DRV Finder adult values (Dietary
* Reference Values for the EU population); each entry cites its source
* verbatim in `reference.source`. Where EFSA expresses a vitamin per
* energy unit (thiamin, niacin), the entry carries the value at the
* 10 MJ/day reference energy intake and says so in the citation.
* Chromium is the one exception: EFSA concluded no DRV can be set, so
* the entry cites the NIH ODS adequate intake instead — spelled out in
* its source string.
*/
export const NUTRIENT_CODES = [
// 13 vitamins
"vitamin_a",
"thiamin",
"riboflavin",
"niacin",
"pantothenic_acid",
"vitamin_b6",
"biotin",
"folate",
"vitamin_b12",
"vitamin_c",
"vitamin_d",
"vitamin_e",
"vitamin_k",
// 11 minerals (sodium + potassium excluded by directive)
"calcium",
"iron",
"magnesium",
"phosphorus",
"zinc",
"copper",
"manganese",
"selenium",
"chromium",
"molybdenum",
"iodine",
// taken substances with correlation value
"water",
"caffeine",
] as const;
export type NutrientCode = (typeof NUTRIENT_CODES)[number];
/** Canonical storage units. `ug` is µg on the wire — ASCII on purpose. */
export type NutrientUnit = "mg" | "ug" | "ml";
export interface NutrientReference {
/** EFSA value type, named in Coach prose later (Slice B). */
kind: "PRI" | "AI" | "safeLevel";
/** `target` = reference intake; `upperGuidance` = do-not-exceed. */
direction: "target" | "upperGuidance";
/** Uniform adult value where the source is uniform. */
adult?: number;
/** Sex-split values where the source splits (e.g. iron 11 / 16). */
male?: number;
female?: number;
/** Citation, quoted verbatim wherever the reference surfaces. */
source: string;
}
export interface NutrientDefinition {
code: NutrientCode;
/** HealthKit quantity type identifier iOS reads this code from. */
hkIdentifier: string;
/** Canonical storage unit AND the pinned HK query unit. */
unit: NutrientUnit;
/** i18n key for the display name (settings card + exports). */
labelKey: string;
/**
* Generous per-day ingest cap in the canonical unit. Entries above it
* are skipped `value_out_of_range` — sized well above high-dose
* supplement reality so only unit confusion / corruption trips it.
*/
plausibleDailyMax: number;
reference: NutrientReference;
}
function def(
code: NutrientCode,
hkSuffix: string,
unit: NutrientUnit,
plausibleDailyMax: number,
reference: NutrientReference,
): NutrientDefinition {
return {
code,
hkIdentifier: `HKQuantityTypeIdentifierDietary${hkSuffix}`,
unit,
labelKey: `nutrients.names.${code}`,
plausibleDailyMax,
reference,
};
}
export const NUTRIENT_CATALOG: Readonly<
Record<NutrientCode, NutrientDefinition>
> = Object.freeze({
// ── Vitamins ─────────────────────────────────────────────────────
vitamin_a: def("vitamin_a", "VitaminA", "ug", 15000, {
kind: "PRI",
direction: "target",
male: 750,
female: 650,
source: "EFSA DRV 2015 (retinol equivalents, adults)",
}),
thiamin: def("thiamin", "Thiamin", "mg", 1000, {
kind: "PRI",
direction: "target",
adult: 1.0,
source: "EFSA DRV 2016 (PRI 0.1 mg/MJ ≈ 1.0 mg/d at 10 MJ/d)",
}),
riboflavin: def("riboflavin", "Riboflavin", "mg", 1000, {
kind: "PRI",
direction: "target",
adult: 1.6,
source: "EFSA DRV 2017 (adults)",
}),
niacin: def("niacin", "Niacin", "mg", 3000, {
kind: "PRI",
direction: "target",
adult: 16,
source: "EFSA DRV 2014 (PRI 1.6 mg NE/MJ ≈ 16 mg NE/d at 10 MJ/d)",
}),
pantothenic_acid: def("pantothenic_acid", "PantothenicAcid", "mg", 2000, {
kind: "AI",
direction: "target",
adult: 5,
source: "EFSA DRV 2014 (adults)",
}),
vitamin_b6: def("vitamin_b6", "VitaminB6", "mg", 1000, {
kind: "PRI",
direction: "target",
male: 1.7,
female: 1.6,
source: "EFSA DRV 2016 (adults)",
}),
biotin: def("biotin", "Biotin", "ug", 20000, {
kind: "AI",
direction: "target",
adult: 40,
source: "EFSA DRV 2014 (adults)",
}),
folate: def("folate", "Folate", "ug", 5000, {
kind: "PRI",
direction: "target",
adult: 330,
source: "EFSA DRV 2014 (dietary folate equivalents, adults)",
}),
vitamin_b12: def("vitamin_b12", "VitaminB12", "ug", 5000, {
kind: "AI",
direction: "target",
adult: 4,
source: "EFSA DRV 2015 (cobalamin, adults)",
}),
vitamin_c: def("vitamin_c", "VitaminC", "mg", 10000, {
kind: "PRI",
direction: "target",
male: 110,
female: 95,
source: "EFSA DRV 2013 (adults)",
}),
vitamin_d: def("vitamin_d", "VitaminD", "ug", 1000, {
kind: "AI",
direction: "target",
adult: 15,
source: "EFSA DRV 2016 (adults, assuming minimal cutaneous synthesis)",
}),
vitamin_e: def("vitamin_e", "VitaminE", "mg", 2000, {
kind: "AI",
direction: "target",
male: 13,
female: 11,
source: "EFSA DRV 2015 (alpha-tocopherol, adults)",
}),
vitamin_k: def("vitamin_k", "VitaminK", "ug", 10000, {
kind: "AI",
direction: "target",
adult: 70,
source: "EFSA DRV 2017 (phylloquinone, adults)",
}),
// ── Minerals ─────────────────────────────────────────────────────
calcium: def("calcium", "Calcium", "mg", 5000, {
kind: "PRI",
direction: "target",
adult: 950,
source: "EFSA DRV 2015 (adults ≥ 25 y)",
}),
iron: def("iron", "Iron", "mg", 500, {
kind: "PRI",
direction: "target",
male: 11,
female: 16,
source: "EFSA DRV 2015 (adults; 16 mg premenopausal women)",
}),
magnesium: def("magnesium", "Magnesium", "mg", 2000, {
kind: "AI",
direction: "target",
male: 350,
female: 300,
source: "EFSA DRV 2015 (adults)",
}),
phosphorus: def("phosphorus", "Phosphorus", "mg", 5000, {
kind: "AI",
direction: "target",
adult: 550,
source: "EFSA DRV 2015 (adults)",
}),
zinc: def("zinc", "Zinc", "mg", 200, {
kind: "PRI",
direction: "target",
male: 9.4,
female: 7.5,
source: "EFSA DRV 2014 (adults, at 300 mg/d phytate intake)",
}),
copper: def("copper", "Copper", "mg", 20, {
kind: "AI",
direction: "target",
male: 1.6,
female: 1.3,
source: "EFSA DRV 2015 (adults)",
}),
manganese: def("manganese", "Manganese", "mg", 50, {
kind: "AI",
direction: "target",
adult: 3,
source: "EFSA DRV 2013 (adults)",
}),
selenium: def("selenium", "Selenium", "ug", 1000, {
kind: "AI",
direction: "target",
adult: 70,
source: "EFSA DRV 2014 (adults)",
}),
chromium: def("chromium", "Chromium", "ug", 2000, {
kind: "AI",
direction: "target",
male: 35,
female: 25,
source: "NIH ODS 2018 (adults 19–50 y; EFSA 2014 sets no DRV for chromium)",
}),
molybdenum: def("molybdenum", "Molybdenum", "ug", 2000, {
kind: "AI",
direction: "target",
adult: 65,
source: "EFSA DRV 2013 (adults)",
}),
iodine: def("iodine", "Iodine", "ug", 2000, {
kind: "AI",
direction: "target",
adult: 150,
source: "EFSA DRV 2014 (adults)",
}),
// ── Taken substances with correlation value ──────────────────────
water: def("water", "Water", "ml", 20000, {
kind: "AI",
direction: "target",
male: 2500,
female: 2000,
source: "EFSA DRV 2010 (total water incl. food moisture, adults)",
}),
caffeine: def("caffeine", "Caffeine", "mg", 2000, {
kind: "safeLevel",
direction: "upperGuidance",
adult: 400,
source: "EFSA 2015 scientific opinion on the safety of caffeine (adults)",
}),
});
/** Catalog rows in canonical display order (the tuple order above). */
export const NUTRIENT_DEFINITIONS: ReadonlyArray<NutrientDefinition> =
NUTRIENT_CODES.map((code) => NUTRIENT_CATALOG[code]);
const NUTRIENT_CODE_SET: ReadonlySet<string> = new Set(NUTRIENT_CODES);
/** True when `code` is a known catalog code. */
export function isNutrientCode(code: string): code is NutrientCode {
return NUTRIENT_CODE_SET.has(code);
}
+3
View File
@@ -52,6 +52,7 @@ import { mentalHealthPaths } from "./mental-health";
import { medicationPaths, medicationResource } from "./medications";
import { metaPaths } from "./meta";
import { moodPaths } from "./mood";
import { nutrientPaths } from "./nutrients";
import { onboardingPaths } from "./onboarding";
import { profilePaths } from "./profile";
import { settingsPaths } from "./settings";
@@ -94,6 +95,8 @@ export const openApiPaths: NonNullable<ZodOpenApiObject["paths"]> = {
...inboundDocumentPaths,
...customMetricPaths,
...onboardingPaths,
// v1.28 — nutrient-intake sync (appended; spread order is load-bearing).
...nutrientPaths,
};
export const openApiComponents: NonNullable<ZodOpenApiObject["components"]> = {
+93
View File
@@ -0,0 +1,93 @@
/**
* OpenAPI route table — nutrient intake sync (v1.28).
*
* Part of the OpenAPI route table; aggregated in `./index.ts`. Request
* and response bodies come from `src/lib/validations/nutrients.ts` so
* the wire contract stays single-source. The batch description embeds
* the code ↔ HealthKit-identifier ↔ unit table generated from the
* code-side catalog — the iOS client reads the pinned HK query unit
* per type from here (this is the documented home of that table).
*/
import type { ZodOpenApiObject } from "zod-openapi";
import { NUTRIENT_DEFINITIONS } from "@/lib/nutrients/catalog";
import {
nutrientBatchSchema,
nutrientBatchResponseSchema,
nutrientOverviewQuerySchema,
nutrientOverviewSchema,
} from "@/lib/validations/nutrients";
import { dataEnvelope, errorEnvelope, stdResponses } from "./shared";
const moduleDisabled = {
"403": {
description:
"The opt-in `nutrients` module is off for this account (errorCode `module.disabled`). Stop syncing and do not retry; offer the enable-in-settings hint. Only run the HealthKit read-authorization flow while the module is on.",
content: { "application/json": { schema: errorEnvelope } },
},
} as const;
/**
* The catalog table, rendered into the endpoint description so the
* OpenAPI file is the one authoritative home of the code ↔ HK ↔ unit
* contract (µg is `ug` on the wire; water is mL; never IU).
*/
const catalogTable = NUTRIENT_DEFINITIONS.map(
(d) => `| ${d.code} | ${d.hkIdentifier} | ${d.unit} |`,
).join("\n");
export const nutrientPaths: NonNullable<ZodOpenApiObject["paths"]> = {
"/api/nutrients/batch": {
post: {
tags: ["Nutrients"],
summary: "Ingest micronutrient day totals (v1.28)",
description:
"Bulk upsert of supplement-style daily totals synced from HealthKit — vitamins, minerals, water, caffeine. Day totals only: compute HKStatisticsCollectionQuery cumulative sums day-anchored in the user's current IANA timezone; `day` is yyyy-MM-dd in that timezone (the same rule as the `stats:` day keys). Never post raw samples. Upsert key is (day, nutrient): a re-post replaces the stored total and reports `updated`. Post the current + previous local day per sync; backfill 30 days on first enable. Max 500 entries; Idempotency-Key supported; rate limit 60/min. Per-entry skips (`unit_mismatch` | `value_out_of_range` | `day_invalid`) are terminal — log and drop, do not retry the entry. Behind the opt-in `nutrients` module (403 `module.disabled` when off). Query HealthKit with exactly the listed unit per code (`ug` = microgram; do not convert to IU):\n\n| code | HealthKit identifier | unit |\n| --- | --- | --- |\n" +
catalogTable,
requestBody: {
required: true,
content: { "application/json": { schema: nutrientBatchSchema } },
},
responses: {
"200": {
description:
"Batch processed; per-entry statuses inserted | updated | skipped.",
content: {
"application/json": {
schema: dataEnvelope(
nutrientBatchResponseSchema,
"NutrientBatchEnvelope",
),
},
},
},
...moduleDisabled,
...stdResponses,
},
},
},
"/api/nutrients": {
get: {
tags: ["Nutrients"],
summary: "Synced-nutrient window summary (v1.28)",
description:
"Per-nutrient summary of the last `days` days (default 14): latest synced day + total and the days-with-data count, in catalog order. Read-only; feeds the settings card. Behind the opt-in `nutrients` module.",
requestParams: { query: nutrientOverviewQuerySchema },
responses: {
"200": {
description: "The window summary.",
content: {
"application/json": {
schema: dataEnvelope(
nutrientOverviewSchema,
"NutrientOverviewEnvelope",
),
},
},
},
...moduleDisabled,
...stdResponses,
},
},
},
};
+2
View File
@@ -33,6 +33,7 @@ import { measurementReminderKeys } from "./measurement-reminders";
import { medicationKeys } from "./medications";
import { mentalHealthKeys } from "./mental-health";
import { moodKeys } from "./mood";
import { nutrientKeys } from "./nutrients";
import { settingsKeys } from "./settings";
import { workoutKeys } from "./workouts";
@@ -58,6 +59,7 @@ export const queryKeys = {
...environmentKeys,
...documentKeys,
...customMetrics,
...nutrientKeys,
};
/**
+9
View File
@@ -0,0 +1,9 @@
/**
* Query keys — nutrient-intake sync (v1.28). One read: the window
* summary behind the read-only Settings → Sources card. Part of the
* centralized factory; aggregated in `./index.ts`.
*/
export const nutrientKeys = {
/** Per-nutrient window summary from `GET /api/nutrients?days=N`. */
nutrientIntake: (days: number) => ["nutrients", "intake", days] as const,
};
+108
View File
@@ -0,0 +1,108 @@
/**
* Zod schemas — nutrient intake sync (v1.28).
*
* Shared between the runtime request parsing
* (`src/app/api/nutrients/**`) and the OpenAPI route table
* (`src/lib/openapi/routes/nutrients.ts`) so the wire contract stays
* single-source. The closed `nutrient` code set comes from the
* code-side catalog (`src/lib/nutrients/catalog.ts`).
*/
import { z } from "zod/v4";
import { NUTRIENT_CODES } from "@/lib/nutrients/catalog";
export const MAX_NUTRIENT_ENTRIES_PER_BATCH = 500;
export const nutrientCodeEnum = z.enum(NUTRIENT_CODES).meta({
id: "NutrientCode",
description:
"Closed nutrient catalog code — 24 HealthKit Dietary vitamin/mineral types plus water and caffeine. Energy, macros and sodium/potassium are out of scope.",
});
/**
* One day-total entry. `day` is YYYY-MM-DD in the user's IANA timezone,
* computed client-side (the `stats:` day-key contract); the server
* trusts the string after regex + calendar sanity, no re-derivation.
* `unit` deliberately rides the wire even though the catalog pins it:
* a µg/mg confusion is a silent 1000× corruption and the one-string
* echo is the cheapest guard — a mismatch skips the entry.
*/
export const nutrientEntrySchema = z
.object({
day: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
nutrient: nutrientCodeEnum,
unit: z.string().min(1).max(10),
amount: z.number().finite().min(0),
externalSourceVersion: z.string().min(1).max(120).optional(),
})
.meta({
id: "NutrientIntakeEntry",
description:
"One nutrient day total. day = YYYY-MM-DD in the user's IANA timezone; unit must equal the catalog's canonical unit for the code (mg | ug | ml) or the entry is skipped unit_mismatch.",
});
export const nutrientBatchSchema = z
.object({
entries: z
.array(nutrientEntrySchema)
.min(1)
.max(MAX_NUTRIENT_ENTRIES_PER_BATCH),
})
.meta({
id: "NutrientBatchRequest",
description:
"Bulk day-total upsert, max 500 entries. Upsert key is (day, nutrient); a re-post replaces the stored total (last-writer-wins day-total contract).",
});
/** Per-entry outcome — a re-post is by definition an update, never a duplicate. */
export const nutrientEntryResultSchema = z
.object({
index: z.number().int().min(0),
status: z.enum(["inserted", "updated", "skipped"]),
reason: z.string().optional(),
})
.meta({
id: "NutrientEntryResult",
description:
"Per-entry ingest outcome. skipped reasons: unit_mismatch | value_out_of_range | day_invalid | upsert_failed. Log and drop a skipped entry — do not retry it.",
});
export const nutrientBatchResponseSchema = z
.object({
processed: z.number().int().min(0),
inserted: z.number().int().min(0),
updated: z.number().int().min(0),
skipped: z.array(
z.object({ index: z.number().int().min(0), reason: z.string() }),
),
entries: z.array(nutrientEntryResultSchema),
})
.meta({
id: "NutrientBatchResponse",
description:
"Always 200 when the envelope parses; per-entry failures surface as skipped entries, never a batch failure.",
});
/** `GET /api/nutrients` query — window size in days, default 14. */
export const nutrientOverviewQuerySchema = z.object({
days: z.coerce.number().int().min(1).max(365).default(14),
});
export const nutrientOverviewSchema = z
.object({
windowDays: z.number().int().min(1),
nutrients: z.array(
z.object({
nutrient: nutrientCodeEnum,
unit: z.string(),
latestDay: z.string(),
latestAmount: z.number(),
daysWithData: z.number().int().min(1),
}),
),
})
.meta({
id: "NutrientIntakeOverview",
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.",
});