Files
HealthLog/src/app/api/nutrients/route.ts
T
Marc-André Bombeck da2de015fe 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.
2026-07-17 16:08:39 +02:00

104 lines
3.6 KiB
TypeScript

/**
* `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 },
});
// 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) {
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;
}
}
// 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 });
});