feat(nutrients): source-aware day totals; manual water write path

NutrientIntakeDay's composite primary key gains `source` (migration
0249_nutrient_intake_day_source): a manual water entry and the
Apple-synced day total now live as two rows instead of the manual
write being silently clobbered by the next Apple last-writer-wins
sync. Every existing row already defaults to source=APPLE_HEALTH, so
the migration is a pure PK-constraint change with no data rewrite.

- POST /api/nutrients/batch keeps writing the APPLE_HEALTH row,
  byte-identical upsert semantics against the wider key.
- GET /api/nutrients sums amounts within (nutrient, day) across
  sources before folding into the window summary.
- POST /api/nutrients/water (new): manual water quick-add, add/set
  modes against the MANUAL row only, module-gated, rate-limited,
  idempotency-key aware, hard-evicts the dashboard snapshot cache.
- GET /api/nutrients/daily (new): dense per-day series for one
  nutrient (summed across sources) plus the EFSA reference resolved
  against the caller's profile sex — omitted when sex is unknown.
- catalog.ts gains resolveNutrientReference(), the sex-resolution
  helper both new routes and the insights cards share.

docs/api/openapi.yaml regenerated for the two new routes.
This commit is contained in:
Marc-André Bombeck committed 2026-07-17 16:08:39 +02:00
1 parent bef3b9dd3f
commit da2de015fe
15 files changed
+1075 -11

No files matched your search

@@ -267,10 +267,11 @@ describe("POST /api/nutrients/batch — upsert semantics", () => {
expect(prisma.nutrientIntakeDay.upsert).toHaveBeenCalledWith({
where: {
userId_day_nutrient: {
userId_day_nutrient_source: {
userId: "user-1",
day,
nutrient: "vitamin_d",
source: "APPLE_HEALTH",
},
},
create: {
@@ -279,6 +280,7 @@ describe("POST /api/nutrients/batch — upsert semantics", () => {
nutrient: "vitamin_d",
amount: 22.5,
unit: "ug",
source: "APPLE_HEALTH",
externalSourceVersion: "yazio-9.9",
},
update: {
+9 -1
View File
@@ -165,11 +165,18 @@ async function postBatch(request: NextRequest): Promise<Response> {
}
try {
// v1.29 — `source` joined the composite PK (migration 0249) so a
// manual water entry (source=MANUAL, written by
// `POST /api/nutrients/water`) coexists with the Apple-synced day
// total instead of one clobbering the other on the next sync. The
// batch route always owns the APPLE_HEALTH row — byte-identical
// upsert behaviour to pre-0249, just against the wider key.
const key = {
userId_day_nutrient: {
userId_day_nutrient_source: {
userId: user.id,
day: entry.day,
nutrient: entry.nutrient,
source: "APPLE_HEALTH",
},
};
@@ -189,6 +196,7 @@ async function postBatch(request: NextRequest): Promise<Response> {
nutrient: entry.nutrient,
amount: entry.amount,
unit: definition.unit,
source: "APPLE_HEALTH",
externalSourceVersion: entry.externalSourceVersion ?? null,
},
update: {