From 5445fc67eaf189291d2b3aab72cc04a983f5cb34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 14 Jul 2026 13:10:47 +0200 Subject: [PATCH] =?UTF-8?q?chore(release):=20v1.28.34=20=E2=80=94=20nutrie?= =?UTF-8?q?nt=20intake=20lands=20as=20quiet=20context=20(server=20side)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- CHANGELOG.md | 15 + docs/api/openapi.yaml | 334 ++++++++++++++++- messages/de.json | 45 ++- messages/en.json | 45 ++- messages/es.json | 45 ++- messages/fr.json | 45 ++- messages/it.json | 45 ++- messages/pl.json | 45 ++- package.json | 2 +- .../0241_nutrient_intake_days/migration.sql | 37 ++ prisma/schema.prisma | 44 +++ public/sw.js | 2 +- .../module-route-gate-inventory.test.ts | 7 + src/app/api/nutrients/__tests__/route.test.ts | 146 ++++++++ .../nutrients/batch/__tests__/route.test.ts | 354 ++++++++++++++++++ src/app/api/nutrients/batch/route.ts | 248 ++++++++++++ src/app/api/nutrients/route.ts | 97 +++++ .../__tests__/nutrient-intake-card.test.tsx | 108 ++++++ src/components/settings/modules-section.tsx | 3 + .../settings/nutrient-intake-card.tsx | 130 +++++++ src/components/settings/sources-section.tsx | 5 + src/lib/dashboard/__tests__/snapshot.test.ts | 1 + src/lib/measurements/apple-health-mapping.ts | 10 +- src/lib/modules/__tests__/gate.test.ts | 8 +- src/lib/modules/registry.ts | 20 + src/lib/nutrients/__tests__/catalog.test.ts | 153 ++++++++ src/lib/nutrients/catalog.ts | 307 +++++++++++++++ src/lib/openapi/routes/index.ts | 3 + src/lib/openapi/routes/nutrients.ts | 93 +++++ src/lib/query-keys/index.ts | 2 + src/lib/query-keys/nutrients.ts | 9 + src/lib/validations/nutrients.ts | 108 ++++++ .../doctor-report-sections.test.ts | 1 + tests/integration/nutrients-batch.test.ts | 261 +++++++++++++ 34 files changed, 2766 insertions(+), 12 deletions(-) create mode 100644 prisma/migrations/0241_nutrient_intake_days/migration.sql create mode 100644 src/app/api/nutrients/__tests__/route.test.ts create mode 100644 src/app/api/nutrients/batch/__tests__/route.test.ts create mode 100644 src/app/api/nutrients/batch/route.ts create mode 100644 src/app/api/nutrients/route.ts create mode 100644 src/components/settings/__tests__/nutrient-intake-card.test.tsx create mode 100644 src/components/settings/nutrient-intake-card.tsx create mode 100644 src/lib/nutrients/__tests__/catalog.test.ts create mode 100644 src/lib/nutrients/catalog.ts create mode 100644 src/lib/openapi/routes/nutrients.ts create mode 100644 src/lib/query-keys/nutrients.ts create mode 100644 src/lib/validations/nutrients.ts create mode 100644 tests/integration/nutrients-batch.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index aa0aa539b..57594af12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,21 @@ ## [Unreleased] +## [1.28.34] — 2026-07-14 — 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 to HealthLog as daily totals. This is +deliberately not a nutrition feature: no food diary, no calorie tracking, no +dashboard tile. The data lands in a dedicated store (26 nutrients with EFSA +reference values), is visible on a read-only card under Settings → Sources, +and will feed the Coach as context in a later release. + +The module is opt-in and off by default; with it off, the server refuses the +data entirely. Re-posting a day replaces it, units are guarded against +µg/mg mix-ups, and implausible values are skipped per entry. This release is +inert until the iOS companion app ships its reading side (coordination ticket +filed); the wire contract is in the OpenAPI spec. + ## [1.28.33] — 2026-07-14 — Re-importing a cumulative Apple Health export works again Re-uploading a fresh export.zip over existing data could fail with a duplicate-key diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index f81246937..2f1e203f9 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.28.33 + version: 1.28.34 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. @@ -10355,6 +10355,128 @@ paths: "401": *a1 "422": *a3 "429": *a4 + /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): + + + | code | HealthKit identifier | unit | + + | --- | --- | --- | + + | vitamin_a | HKQuantityTypeIdentifierDietaryVitaminA | ug | + + | thiamin | HKQuantityTypeIdentifierDietaryThiamin | mg | + + | riboflavin | HKQuantityTypeIdentifierDietaryRiboflavin | mg | + + | niacin | HKQuantityTypeIdentifierDietaryNiacin | mg | + + | pantothenic_acid | HKQuantityTypeIdentifierDietaryPantothenicAcid | mg | + + | vitamin_b6 | HKQuantityTypeIdentifierDietaryVitaminB6 | mg | + + | biotin | HKQuantityTypeIdentifierDietaryBiotin | ug | + + | folate | HKQuantityTypeIdentifierDietaryFolate | ug | + + | vitamin_b12 | HKQuantityTypeIdentifierDietaryVitaminB12 | ug | + + | vitamin_c | HKQuantityTypeIdentifierDietaryVitaminC | mg | + + | vitamin_d | HKQuantityTypeIdentifierDietaryVitaminD | ug | + + | vitamin_e | HKQuantityTypeIdentifierDietaryVitaminE | mg | + + | vitamin_k | HKQuantityTypeIdentifierDietaryVitaminK | ug | + + | calcium | HKQuantityTypeIdentifierDietaryCalcium | mg | + + | iron | HKQuantityTypeIdentifierDietaryIron | mg | + + | magnesium | HKQuantityTypeIdentifierDietaryMagnesium | mg | + + | phosphorus | HKQuantityTypeIdentifierDietaryPhosphorus | mg | + + | zinc | HKQuantityTypeIdentifierDietaryZinc | mg | + + | copper | HKQuantityTypeIdentifierDietaryCopper | mg | + + | manganese | HKQuantityTypeIdentifierDietaryManganese | mg | + + | selenium | HKQuantityTypeIdentifierDietarySelenium | ug | + + | chromium | HKQuantityTypeIdentifierDietaryChromium | ug | + + | molybdenum | HKQuantityTypeIdentifierDietaryMolybdenum | ug | + + | iodine | HKQuantityTypeIdentifierDietaryIodine | ug | + + | water | HKQuantityTypeIdentifierDietaryWater | ml | + + | caffeine | HKQuantityTypeIdentifierDietaryCaffeine | mg | + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/NutrientBatchRequest" + responses: + "200": + description: Batch processed; per-entry statuses inserted | updated | skipped. + content: + application/json: + schema: + $ref: "#/components/schemas/NutrientBatchEnvelope" + "401": *a1 + "403": &a17 + 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: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a3 + "429": *a4 + /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." + parameters: + - in: query + name: days + schema: + default: 14 + type: integer + minimum: 1 + maximum: 365 + responses: + "200": + description: The window summary. + content: + application/json: + schema: + $ref: "#/components/schemas/NutrientOverviewEnvelope" + "401": *a1 + "403": *a17 + "422": *a3 + "429": *a4 components: schemas: EncryptedExportRequest: @@ -11501,6 +11623,8 @@ components: type: boolean mentalHealth: type: boolean + nutrients: + type: boolean additionalProperties: false description: Partial module enable/disable update. Each key is an optional boolean; an omitted key is left untouched, `false` disables the module, `true` (re-)enables it. Only the toggleable "secondary domains" are accepted — a @@ -12143,6 +12267,76 @@ components: - updatedAt description: Resumable module-tour progress point. `lastStopId` seeds the resume index; `status` is the running/terminal state. + NutrientBatchRequest: + type: object + properties: + entries: + minItems: 1 + maxItems: 500 + type: array + items: + $ref: "#/components/schemas/NutrientIntakeEntry" + required: + - entries + 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). + NutrientIntakeEntry: + type: object + properties: + day: + type: string + pattern: ^\d{4}-\d{2}-\d{2}$ + nutrient: + $ref: "#/components/schemas/NutrientCode" + unit: + type: string + minLength: 1 + maxLength: 10 + amount: + type: number + minimum: 0 + externalSourceVersion: + type: string + minLength: 1 + maxLength: 120 + required: + - day + - nutrient + - unit + - amount + 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. + NutrientCode: + type: string + enum: + - vitamin_a + - thiamin + - riboflavin + - niacin + - pantothenic_acid + - vitamin_b6 + - biotin + - folate + - vitamin_b12 + - vitamin_c + - vitamin_d + - vitamin_e + - vitamin_k + - calcium + - iron + - magnesium + - phosphorus + - zinc + - copper + - manganese + - selenium + - chromium + - molybdenum + - iodine + - water + - 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. MedicationTreatmentClass: type: string enum: @@ -19022,6 +19216,9 @@ components: mentalHealth: type: boolean description: Whether the "mentalHealth" module is enabled for this account. + nutrients: + type: boolean + description: Whether the "nutrients" module is enabled for this account. required: - cycle - mood @@ -19040,6 +19237,7 @@ components: - mcp - inboundDocuments - mentalHealth + - nutrients additionalProperties: false description: Fully-resolved per-user module enable/disable map. Every toggleable module key is present. `false` means the surface should disappear end-to-end (nav, dashboard, insights, …). `cycle` mirrors `cycleTrackingEnabled`; @@ -28359,6 +28557,140 @@ components: - progress additionalProperties: false description: The persisted completion flag and resume point after the write. + NutrientBatchEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/NutrientBatchResponse" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + NutrientBatchResponse: + type: object + properties: + processed: + type: integer + minimum: 0 + maximum: 9007199254740991 + inserted: + type: integer + minimum: 0 + maximum: 9007199254740991 + updated: + type: integer + minimum: 0 + maximum: 9007199254740991 + skipped: + type: array + items: + type: object + properties: + index: + type: integer + minimum: 0 + maximum: 9007199254740991 + reason: + type: string + required: + - index + - reason + additionalProperties: false + entries: + type: array + items: + $ref: "#/components/schemas/NutrientEntryResult" + required: + - processed + - inserted + - updated + - skipped + - entries + additionalProperties: false + description: Always 200 when the envelope parses; per-entry failures surface as skipped entries, never a batch failure. + NutrientEntryResult: + type: object + properties: + index: + type: integer + minimum: 0 + maximum: 9007199254740991 + status: + type: string + enum: + - inserted + - updated + - skipped + reason: + type: string + required: + - index + - status + additionalProperties: false + 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." + NutrientOverviewEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/NutrientIntakeOverview" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + NutrientIntakeOverview: + type: object + properties: + windowDays: + type: integer + minimum: 1 + maximum: 9007199254740991 + nutrients: + type: array + items: + type: object + properties: + nutrient: + $ref: "#/components/schemas/NutrientCode" + unit: + type: string + latestDay: + type: string + latestAmount: + type: number + daysWithData: + type: integer + minimum: 1 + maximum: 9007199254740991 + required: + - nutrient + - unit + - latestDay + - latestAmount + - daysWithData + additionalProperties: false + required: + - windowDays + - nutrients + 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." MedicationScheduleOutput: type: object properties: diff --git a/messages/de.json b/messages/de.json index cca68b5f1..0d465fd6a 100644 --- a/messages/de.json +++ b/messages/de.json @@ -4238,7 +4238,16 @@ "other": "Sonstige", "unknown": "Unbekannt" }, - "subtitle": "Lege fest, welche Quelle gewinnt, wenn zwei dieselbe Messgröße liefern." + "subtitle": "Lege fest, welche Quelle gewinnt, wenn zwei dieselbe Messgröße liefern.", + "nutrients": { + "title": "Synchronisierte Nährstoffzufuhr", + "description": "Tagessummen aus Apple Health der letzten {days} Tage. Nur lesend — die Quell-App besitzt die Werte.", + "empty": "Noch keine Nährstoffdaten synchronisiert.", + "latest": "{amount} {unit} am {date}", + "daysWithData": "{count} von {days} Tagen", + "moduleHint": "Die Synchronisierung steuert das Modul „Nährstoffzufuhr“.", + "moduleHintLink": "Module verwalten" + } }, "ai": { "title": "KI-Anbieter", @@ -7570,6 +7579,10 @@ "mentalHealth": { "label": "Seelisches Wohlbefinden", "description": "Freiwillige PHQ-9- und GAD-7-Selbsttests ergänzend zum Stimmungstagebuch. Standardmäßig aus; Antworten sind verschlüsselt und bleiben aus dem KI-Coach heraus." + }, + "nutrients": { + "label": "Nährstoffzufuhr", + "description": "Speichert tägliche Mikronährstoff-Summen (Vitamine, Mineralstoffe, Wasser, Koffein) aus Apple Health. Standardmäßig aus." } }, "illness": { @@ -8452,5 +8465,35 @@ "valuesDescription": "Alle erfassten Werte dieser Metrik.", "backToMetric": "Zurück zu {metric}" } + }, + "nutrients": { + "names": { + "vitamin_a": "Vitamin A", + "thiamin": "Thiamin (B1)", + "riboflavin": "Riboflavin (B2)", + "niacin": "Niacin (B3)", + "pantothenic_acid": "Pantothensäure (B5)", + "vitamin_b6": "Vitamin B6", + "biotin": "Biotin (B7)", + "folate": "Folat (B9)", + "vitamin_b12": "Vitamin B12", + "vitamin_c": "Vitamin C", + "vitamin_d": "Vitamin D", + "vitamin_e": "Vitamin E", + "vitamin_k": "Vitamin K", + "calcium": "Calcium", + "iron": "Eisen", + "magnesium": "Magnesium", + "phosphorus": "Phosphor", + "zinc": "Zink", + "copper": "Kupfer", + "manganese": "Mangan", + "selenium": "Selen", + "chromium": "Chrom", + "molybdenum": "Molybdän", + "iodine": "Jod", + "water": "Wasser", + "caffeine": "Koffein" + } } } diff --git a/messages/en.json b/messages/en.json index 4a0fc6c37..efdd27279 100644 --- a/messages/en.json +++ b/messages/en.json @@ -4238,7 +4238,16 @@ "other": "Other", "unknown": "Unknown" }, - "subtitle": "Decide which source wins when two report the same metric." + "subtitle": "Decide which source wins when two report the same metric.", + "nutrients": { + "title": "Synced nutrient intake", + "description": "Daily totals synced from Apple Health over the last {days} days. Read-only — the source app owns the numbers.", + "empty": "No nutrient data has been synced yet.", + "latest": "{amount} {unit} on {date}", + "daysWithData": "{count} of {days} days", + "moduleHint": "Syncing is controlled by the Nutrient intake module.", + "moduleHintLink": "Manage modules" + } }, "ai": { "title": "AI provider", @@ -7570,6 +7579,10 @@ "mentalHealth": { "label": "Mental wellbeing", "description": "Opt-in PHQ-9 and GAD-7 screeners beside mood tracking. Off by default; answers are encrypted and kept out of the AI Coach." + }, + "nutrients": { + "label": "Nutrient intake", + "description": "Store daily micronutrient totals (vitamins, minerals, water, caffeine) synced from Apple Health. Off by default." } }, "illness": { @@ -8452,5 +8465,35 @@ "valuesDescription": "Every logged value for this metric.", "backToMetric": "Back to {metric}" } + }, + "nutrients": { + "names": { + "vitamin_a": "Vitamin A", + "thiamin": "Thiamin (B1)", + "riboflavin": "Riboflavin (B2)", + "niacin": "Niacin (B3)", + "pantothenic_acid": "Pantothenic acid (B5)", + "vitamin_b6": "Vitamin B6", + "biotin": "Biotin (B7)", + "folate": "Folate (B9)", + "vitamin_b12": "Vitamin B12", + "vitamin_c": "Vitamin C", + "vitamin_d": "Vitamin D", + "vitamin_e": "Vitamin E", + "vitamin_k": "Vitamin K", + "calcium": "Calcium", + "iron": "Iron", + "magnesium": "Magnesium", + "phosphorus": "Phosphorus", + "zinc": "Zinc", + "copper": "Copper", + "manganese": "Manganese", + "selenium": "Selenium", + "chromium": "Chromium", + "molybdenum": "Molybdenum", + "iodine": "Iodine", + "water": "Water", + "caffeine": "Caffeine" + } } } diff --git a/messages/es.json b/messages/es.json index 41b9df989..5d161c965 100644 --- a/messages/es.json +++ b/messages/es.json @@ -4238,7 +4238,16 @@ "other": "Otro", "unknown": "Desconocido" }, - "subtitle": "Decide qué fuente prevalece cuando dos informan la misma métrica." + "subtitle": "Decide qué fuente prevalece cuando dos informan la misma métrica.", + "nutrients": { + "title": "Ingesta de nutrientes sincronizada", + "description": "Totales diarios sincronizados desde Apple Health en los últimos {days} días. Solo lectura: la app de origen es la dueña de los valores.", + "empty": "Todavía no se han sincronizado datos de nutrientes.", + "latest": "{amount} {unit} el {date}", + "daysWithData": "{count} de {days} días", + "moduleHint": "La sincronización se controla con el módulo de ingesta de nutrientes.", + "moduleHintLink": "Gestionar módulos" + } }, "ai": { "title": "Proveedor de IA", @@ -7570,6 +7579,10 @@ "mentalHealth": { "label": "Bienestar mental", "description": "Cuestionarios PHQ-9 y GAD-7 opcionales, junto al registro de ánimo. Desactivado por defecto; las respuestas se cifran y se mantienen fuera del Coach de IA." + }, + "nutrients": { + "label": "Ingesta de nutrientes", + "description": "Guarda los totales diarios de micronutrientes (vitaminas, minerales, agua, cafeína) sincronizados desde Apple Health. Desactivado por defecto." } }, "illness": { @@ -8452,5 +8465,35 @@ "valuesDescription": "Todos los valores registrados de esta métrica.", "backToMetric": "Volver a {metric}" } + }, + "nutrients": { + "names": { + "vitamin_a": "Vitamina A", + "thiamin": "Tiamina (B1)", + "riboflavin": "Riboflavina (B2)", + "niacin": "Niacina (B3)", + "pantothenic_acid": "Ácido pantoténico (B5)", + "vitamin_b6": "Vitamina B6", + "biotin": "Biotina (B7)", + "folate": "Folato (B9)", + "vitamin_b12": "Vitamina B12", + "vitamin_c": "Vitamina C", + "vitamin_d": "Vitamina D", + "vitamin_e": "Vitamina E", + "vitamin_k": "Vitamina K", + "calcium": "Calcio", + "iron": "Hierro", + "magnesium": "Magnesio", + "phosphorus": "Fósforo", + "zinc": "Zinc", + "copper": "Cobre", + "manganese": "Manganeso", + "selenium": "Selenio", + "chromium": "Cromo", + "molybdenum": "Molibdeno", + "iodine": "Yodo", + "water": "Agua", + "caffeine": "Cafeína" + } } } diff --git a/messages/fr.json b/messages/fr.json index 1b3c8846e..07324721c 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -4238,7 +4238,16 @@ "other": "Autre", "unknown": "Inconnu" }, - "subtitle": "Choisissez la source prioritaire en cas de doublon de mesure." + "subtitle": "Choisissez la source prioritaire en cas de doublon de mesure.", + "nutrients": { + "title": "Apports en nutriments synchronisés", + "description": "Totaux quotidiens synchronisés depuis Apple Health sur les {days} derniers jours. Lecture seule : l'app source reste propriétaire des valeurs.", + "empty": "Aucune donnée de nutriments synchronisée pour l'instant.", + "latest": "{amount} {unit} le {date}", + "daysWithData": "{count} jours sur {days}", + "moduleHint": "La synchronisation est contrôlée par le module Apports en nutriments.", + "moduleHintLink": "Gérer les modules" + } }, "ai": { "title": "Fournisseur d'IA", @@ -7570,6 +7579,10 @@ "mentalHealth": { "label": "Bien-être mental", "description": "Questionnaires PHQ-9 et GAD-7 facultatifs, en complément du suivi de l'humeur. Désactivé par défaut ; les réponses sont chiffrées et tenues à l'écart du Coach IA." + }, + "nutrients": { + "label": "Apports en nutriments", + "description": "Enregistre les totaux quotidiens de micronutriments (vitamines, minéraux, eau, caféine) synchronisés depuis Apple Health. Désactivé par défaut." } }, "illness": { @@ -8452,5 +8465,35 @@ "valuesDescription": "Toutes les valeurs enregistrées pour cette mesure.", "backToMetric": "Retour à {metric}" } + }, + "nutrients": { + "names": { + "vitamin_a": "Vitamine A", + "thiamin": "Thiamine (B1)", + "riboflavin": "Riboflavine (B2)", + "niacin": "Niacine (B3)", + "pantothenic_acid": "Acide pantothénique (B5)", + "vitamin_b6": "Vitamine B6", + "biotin": "Biotine (B7)", + "folate": "Folates (B9)", + "vitamin_b12": "Vitamine B12", + "vitamin_c": "Vitamine C", + "vitamin_d": "Vitamine D", + "vitamin_e": "Vitamine E", + "vitamin_k": "Vitamine K", + "calcium": "Calcium", + "iron": "Fer", + "magnesium": "Magnésium", + "phosphorus": "Phosphore", + "zinc": "Zinc", + "copper": "Cuivre", + "manganese": "Manganèse", + "selenium": "Sélénium", + "chromium": "Chrome", + "molybdenum": "Molybdène", + "iodine": "Iode", + "water": "Eau", + "caffeine": "Caféine" + } } } diff --git a/messages/it.json b/messages/it.json index f256c5613..82a3fcb09 100644 --- a/messages/it.json +++ b/messages/it.json @@ -4238,7 +4238,16 @@ "other": "Altro", "unknown": "Sconosciuto" }, - "subtitle": "Decidi quale fonte prevale quando due riportano la stessa metrica." + "subtitle": "Decidi quale fonte prevale quando due riportano la stessa metrica.", + "nutrients": { + "title": "Assunzione di nutrienti sincronizzata", + "description": "Totali giornalieri sincronizzati da Apple Health negli ultimi {days} giorni. Sola lettura: i valori appartengono all'app di origine.", + "empty": "Nessun dato sui nutrienti ancora sincronizzato.", + "latest": "{amount} {unit} il {date}", + "daysWithData": "{count} giorni su {days}", + "moduleHint": "La sincronizzazione è controllata dal modulo Assunzione di nutrienti.", + "moduleHintLink": "Gestisci moduli" + } }, "ai": { "title": "Provider IA", @@ -7570,6 +7579,10 @@ "mentalHealth": { "label": "Benessere mentale", "description": "Questionari PHQ-9 e GAD-7 facoltativi, accanto al diario dell'umore. Disattivato per impostazione predefinita; le risposte sono cifrate e tenute fuori dal Coach IA." + }, + "nutrients": { + "label": "Assunzione di nutrienti", + "description": "Salva i totali giornalieri di micronutrienti (vitamine, minerali, acqua, caffeina) sincronizzati da Apple Health. Disattivato per impostazione predefinita." } }, "illness": { @@ -8452,5 +8465,35 @@ "valuesDescription": "Tutti i valori registrati per questa metrica.", "backToMetric": "Torna a {metric}" } + }, + "nutrients": { + "names": { + "vitamin_a": "Vitamina A", + "thiamin": "Tiamina (B1)", + "riboflavin": "Riboflavina (B2)", + "niacin": "Niacina (B3)", + "pantothenic_acid": "Acido pantotenico (B5)", + "vitamin_b6": "Vitamina B6", + "biotin": "Biotina (B7)", + "folate": "Folati (B9)", + "vitamin_b12": "Vitamina B12", + "vitamin_c": "Vitamina C", + "vitamin_d": "Vitamina D", + "vitamin_e": "Vitamina E", + "vitamin_k": "Vitamina K", + "calcium": "Calcio", + "iron": "Ferro", + "magnesium": "Magnesio", + "phosphorus": "Fosforo", + "zinc": "Zinco", + "copper": "Rame", + "manganese": "Manganese", + "selenium": "Selenio", + "chromium": "Cromo", + "molybdenum": "Molibdeno", + "iodine": "Iodio", + "water": "Acqua", + "caffeine": "Caffeina" + } } } diff --git a/messages/pl.json b/messages/pl.json index ed624c9c1..0e1183b74 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -4238,7 +4238,16 @@ "other": "Inne", "unknown": "Nieznane" }, - "subtitle": "Zdecyduj, które źródło wygrywa, gdy dwa podają tę samą metrykę." + "subtitle": "Zdecyduj, które źródło wygrywa, gdy dwa podają tę samą metrykę.", + "nutrients": { + "title": "Zsynchronizowane spożycie składników", + "description": "Dzienne sumy z Apple Health z ostatnich {days} dni. Tylko do odczytu — wartości należą do aplikacji źródłowej.", + "empty": "Nie zsynchronizowano jeszcze danych o składnikach odżywczych.", + "latest": "{amount} {unit} dnia {date}", + "daysWithData": "{count} z {days} dni", + "moduleHint": "Synchronizacją steruje moduł spożycia składników odżywczych.", + "moduleHintLink": "Zarządzaj modułami" + } }, "ai": { "title": "Dostawca AI", @@ -7570,6 +7579,10 @@ "mentalHealth": { "label": "Dobrostan psychiczny", "description": "Dobrowolne testy PHQ-9 i GAD-7 obok dziennika nastroju. Domyślnie wyłączone; odpowiedzi są szyfrowane i pozostają poza Coachem AI." + }, + "nutrients": { + "label": "Spożycie składników odżywczych", + "description": "Zapisuje dzienne sumy mikroskładników (witaminy, minerały, woda, kofeina) synchronizowane z Apple Health. Domyślnie wyłączone." } }, "illness": { @@ -8452,5 +8465,35 @@ "valuesDescription": "Wszystkie zapisane wartości tej metryki.", "backToMetric": "Powrót do {metric}" } + }, + "nutrients": { + "names": { + "vitamin_a": "Witamina A", + "thiamin": "Tiamina (B1)", + "riboflavin": "Ryboflawina (B2)", + "niacin": "Niacyna (B3)", + "pantothenic_acid": "Kwas pantotenowy (B5)", + "vitamin_b6": "Witamina B6", + "biotin": "Biotyna (B7)", + "folate": "Foliany (B9)", + "vitamin_b12": "Witamina B12", + "vitamin_c": "Witamina C", + "vitamin_d": "Witamina D", + "vitamin_e": "Witamina E", + "vitamin_k": "Witamina K", + "calcium": "Wapń", + "iron": "Żelazo", + "magnesium": "Magnez", + "phosphorus": "Fosfor", + "zinc": "Cynk", + "copper": "Miedź", + "manganese": "Mangan", + "selenium": "Selen", + "chromium": "Chrom", + "molybdenum": "Molibden", + "iodine": "Jod", + "water": "Woda", + "caffeine": "Kofeina" + } } } diff --git a/package.json b/package.json index cb5b2c809..6962a56da 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.28.33", + "version": "1.28.34", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "PolyForm-Noncommercial-1.0.0", "homepage": "https://healthlog.dev", diff --git a/prisma/migrations/0241_nutrient_intake_days/migration.sql b/prisma/migrations/0241_nutrient_intake_days/migration.sql new file mode 100644 index 000000000..ba42cd536 --- /dev/null +++ b/prisma/migrations/0241_nutrient_intake_days/migration.sql @@ -0,0 +1,37 @@ +-- v1.28 — micronutrient intake daily totals (opt-in `nutrients` module). +-- +-- One row per (user, local day, nutrient): vitamins, minerals, water and +-- caffeine as supplement-style day totals synced from Apple Health. The +-- composite PK is the idempotency key — an iOS re-post of a grown day +-- total replaces the row in place (last-writer-wins day-total contract), +-- so there is no externalId column and no soft delete. `day` is a +-- YYYY-MM-DD TEXT key anchored to the user's IANA timezone (the +-- EnvironmentContext / MedicationComplianceRollup day-key precedent); +-- `nutrient` is a bare TEXT code enforced in code against the closed +-- catalog (`src/lib/nutrients/catalog.ts`) so adding a code never needs +-- a migration. `unit` is denormalised from the catalog on purpose: rows +-- stay self-describing in exports even if the catalog ever drifts. +CREATE TABLE "nutrient_intake_days" ( + "user_id" TEXT NOT NULL, + "day" TEXT NOT NULL, + "nutrient" TEXT NOT NULL, + "amount" DOUBLE PRECISION NOT NULL, + "unit" TEXT NOT NULL, + "source" TEXT NOT NULL DEFAULT 'APPLE_HEALTH', + "external_source_version" TEXT, + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(3) NOT NULL, + + CONSTRAINT "nutrient_intake_days_pkey" PRIMARY KEY ("user_id", "day", "nutrient") +); + +-- Read path: the settings-card / read-endpoint window scan per user, and +-- the later per-nutrient series fetch (day DESC for "latest first"). +CREATE INDEX "nutrient_intake_days_user_id_nutrient_day_idx" + ON "nutrient_intake_days" ("user_id", "nutrient", "day" DESC); + +-- Account deletion cascades; rows are worthless without their owner. +ALTER TABLE "nutrient_intake_days" + ADD CONSTRAINT "nutrient_intake_days_user_id_fkey" + FOREIGN KEY ("user_id") REFERENCES "users"("id") + ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index ca2b01fd0..b12247082 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -805,6 +805,10 @@ model User { // that win over the home location for the days they cover. environmentContexts EnvironmentContext[] environmentTravelLocations EnvironmentTravelLocation[] + // v1.28 — micronutrient intake daily totals synced from Apple Health + // (vitamins, minerals, water, caffeine). Opt-in `nutrients` module, off + // by default; the ingest route refuses while the module is off. + nutrientIntakeDays NutrientIntakeDay[] // v1.25 (W-DOCS-IN) — inbound clinical documents (doctor reports / // discharge letters) the user uploads. The raw document is encrypted at // rest; extracted facts land in a review-then-confirm staging area @@ -6201,3 +6205,43 @@ model DocumentThumbnail { @@index([userId]) @@map("document_thumbnails") } + +// ─── Nutrient intake (v1.28) ─────────────────────────────────────── +// +// Micronutrient daily totals synced from Apple Health — vitamins, +// minerals, water, caffeine as supplement-style "substances taken", +// never a food diary (energy / macros are out of scope by directive). +// One row per (user, local day, nutrient); the composite PK IS the +// idempotency key, so an iOS re-post of a grown day total replaces the +// row in place (last-writer-wins day-total contract — the `stats:` +// overwrite semantics without externalId string parsing). `nutrient` +// is a bare string enforced in code against the closed catalog in +// `src/lib/nutrients/catalog.ts` (the IntegrationStatus precedent: no +// migration to add a code). No soft delete: rows are source-owned day +// totals; replace-on-repost is the correction mechanism and account +// deletion cascades. Behind the opt-in `nutrients` module +// (refuse-ingest-when-off). + +model NutrientIntakeDay { + userId String @map("user_id") + /// YYYY-MM-DD anchored to the user's IANA timezone (iOS-computed — + /// the same day-key contract as the `stats:` externalIds). + day String + /// Closed catalog code ("vitamin_d", "magnesium", "water", ...). + nutrient String + /// Daily total in the catalog's canonical unit. + amount Float + /// Canonical unit echoed from the catalog ("mg" | "ug" | "ml") — + /// denormalised on purpose: rows stay self-describing in exports + /// even if the catalog ever drifts. + unit String + 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]) + @@index([userId, nutrient, day(sort: Desc)]) + @@map("nutrient_intake_days") +} diff --git a/public/sw.js b/public/sw.js index 4d35ea43d..8cef7facb 100644 --- a/public/sw.js +++ b/public/sw.js @@ -36,7 +36,7 @@ try { // v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild. const CACHE_VERSION = (typeof self !== "undefined" && self.__APP_VERSION__) || - /* @sw-version-fallback */ "v1.28.33"; + /* @sw-version-fallback */ "v1.28.34"; const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`; const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`; // v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*` diff --git a/src/app/api/__tests__/module-route-gate-inventory.test.ts b/src/app/api/__tests__/module-route-gate-inventory.test.ts index d6989da1b..2487d7d87 100644 --- a/src/app/api/__tests__/module-route-gate-inventory.test.ts +++ b/src/app/api/__tests__/module-route-gate-inventory.test.ts @@ -111,6 +111,13 @@ const MODULE_ROUTE_TREES: ReadonlyArray = [ // 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", ]; /** diff --git a/src/app/api/nutrients/__tests__/route.test.ts b/src/app/api/nutrients/__tests__/route.test.ts new file mode 100644 index 000000000..a3ae3fac1 --- /dev/null +++ b/src/app/api/nutrients/__tests__/route.test.ts @@ -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}$/) }, + }), + }), + ); + }); +}); diff --git a/src/app/api/nutrients/batch/__tests__/route.test.ts b/src/app/api/nutrients/batch/__tests__/route.test.ts new file mode 100644 index 000000000..e1803183f --- /dev/null +++ b/src/app/api/nutrients/batch/__tests__/route.test.ts @@ -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: + (fn: (...args: Args) => Promise) => + (...args: Args) => + fn(...args), +})); +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { POST } from "../route"; +import { prisma } from "@/lib/db"; +import { apiError } from "@/lib/api-response"; +import { getSession } from "@/lib/auth/session"; +import { checkRateLimit } from "@/lib/rate-limit"; +import { requireModuleEnabled } from "@/lib/modules/gate"; +import { 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); + }); +}); diff --git a/src/app/api/nutrients/batch/route.ts b/src/app/api/nutrients/batch/route.ts new file mode 100644 index 000000000..c952fa911 --- /dev/null +++ b/src/app/api/nutrients/batch/route.ts @@ -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 { + 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, + }); +} diff --git a/src/app/api/nutrients/route.ts b/src/app/api/nutrients/route.ts new file mode 100644 index 000000000..0204ab006 --- /dev/null +++ b/src/app/api/nutrients/route.ts @@ -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 }); +}); diff --git a/src/components/settings/__tests__/nutrient-intake-card.test.tsx b/src/components/settings/__tests__/nutrient-intake-card.test.tsx new file mode 100644 index 000000000..120c9c141 --- /dev/null +++ b/src/components/settings/__tests__/nutrient-intake-card.test.tsx @@ -0,0 +1,108 @@ +/** + * v1.28 — `` 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( + "@/hooks/use-auth", + ); + return { ...actual, useAuth: () => authSpy() }; +}); + +import { NutrientIntakeCard } from "../nutrient-intake-card"; + +function user(modules: Record): AuthUser { + return { id: "user-1", modules } as unknown as AuthUser; +} + +function render(): string { + return renderToStaticMarkup( + + + , + ); +} + +beforeEach(() => { + queryState = { data: undefined, isLoading: false }; + authSpy.mockReturnValue({ user: user({ nutrients: true }) }); +}); + +describe("", () => { + 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(" { + queryState = { + isLoading: false, + data: { windowDays: 14, nutrients: [] }, + }; + const html = render(); + expect(html).toContain("No nutrient data has been synced yet."); + }); +}); diff --git a/src/components/settings/modules-section.tsx b/src/components/settings/modules-section.tsx index e5f735928..7d1f85f9d 100644 --- a/src/components/settings/modules-section.tsx +++ b/src/components/settings/modules-section.tsx @@ -47,6 +47,7 @@ import { FileScan, FileText, MessageCircleHeart, + Leaf, Moon, Plug, Pill, @@ -96,6 +97,8 @@ const MODULE_ICONS: Record = { 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() { diff --git a/src/components/settings/nutrient-intake-card.tsx b/src/components/settings/nutrient-intake-card.tsx new file mode 100644 index 000000000..621c12fee --- /dev/null +++ b/src/components/settings/nutrient-intake-card.tsx @@ -0,0 +1,130 @@ +"use client"; + +/** + * `` — 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 = { 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(`/api/nutrients?days=${WINDOW_DAYS}`), + }); + + if (!enabled) return null; + + const rows = overview.data?.nutrients ?? []; + + return ( + + + {overview.isLoading ? ( + + ) : rows.length === 0 ? ( +

+ {t("settings.sections.sources.nutrients.empty")} +

+ ) : ( +
    + {rows.map((row) => ( +
  • + + {t(`nutrients.names.${row.nutrient}`)} + + + {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, + })} + +
  • + ))} +
+ )} +

+ {t("settings.sections.sources.nutrients.moduleHint")}{" "} + + {t("settings.sections.sources.nutrients.moduleHintLink")} + +

+
+ ); +} diff --git a/src/components/settings/sources-section.tsx b/src/components/settings/sources-section.tsx index e2a1d78e3..31ac1d77e 100644 --- a/src/components/settings/sources-section.tsx +++ b/src/components/settings/sources-section.tsx @@ -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() {

+ + {/* v1.28 — read-only synced-nutrient list for the opt-in `nutrients` + module (renders nothing while the module is off). */} + ); } diff --git a/src/lib/dashboard/__tests__/snapshot.test.ts b/src/lib/dashboard/__tests__/snapshot.test.ts index 4be9e7e8c..15be5091f 100644 --- a/src/lib/dashboard/__tests__/snapshot.test.ts +++ b/src/lib/dashboard/__tests__/snapshot.test.ts @@ -93,6 +93,7 @@ function moduleMap( mcp: true, inboundDocuments: true, mentalHealth: true, + nutrients: true, }; return { ...base, ...overrides }; } diff --git a/src/lib/measurements/apple-health-mapping.ts b/src/lib/measurements/apple-health-mapping.ts index 686e3e254..704f46367 100644 --- a/src/lib/measurements/apple-health-mapping.ts +++ b/src/lib/measurements/apple-health-mapping.ts @@ -973,7 +973,15 @@ export const HK_QUANTITY_TYPE_DEFERRED = new Set([ // `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", diff --git a/src/lib/modules/__tests__/gate.test.ts b/src/lib/modules/__tests__/gate.test.ts index 7065fa500..54a800165 100644 --- a/src/lib/modules/__tests__/gate.test.ts +++ b/src/lib/modules/__tests__/gate.test.ts @@ -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)", () => { diff --git a/src/lib/modules/registry.ts b/src/lib/modules/registry.ts index 479b174d5..1d5b356b2 100644 --- a/src/lib/modules/registry.ts +++ b/src/lib/modules/registry.ts @@ -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> = // 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 = new Set(MODULE_KEYS); diff --git a/src/lib/nutrients/__tests__/catalog.test.ts b/src/lib/nutrients/__tests__/catalog.test.ts new file mode 100644 index 000000000..afbcf99e4 --- /dev/null +++ b/src/lib/nutrients/__tests__/catalog.test.ts @@ -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(); + const hkIds = new Set(); + 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. and resolves in the EN bundle", () => { + const en = JSON.parse( + readFileSync(join(__dirname, "../../../../messages/en.json"), "utf8"), + ) as { nutrients?: { names?: Record } }; + 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(); + } + }); +}); diff --git a/src/lib/nutrients/catalog.ts b/src/lib/nutrients/catalog.ts new file mode 100644 index 000000000..03b5e273e --- /dev/null +++ b/src/lib/nutrients/catalog.ts @@ -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 +> = 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 = + NUTRIENT_CODES.map((code) => NUTRIENT_CATALOG[code]); + +const NUTRIENT_CODE_SET: ReadonlySet = 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); +} diff --git a/src/lib/openapi/routes/index.ts b/src/lib/openapi/routes/index.ts index 7965d922e..91acb62b3 100644 --- a/src/lib/openapi/routes/index.ts +++ b/src/lib/openapi/routes/index.ts @@ -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 = { ...inboundDocumentPaths, ...customMetricPaths, ...onboardingPaths, + // v1.28 — nutrient-intake sync (appended; spread order is load-bearing). + ...nutrientPaths, }; export const openApiComponents: NonNullable = { diff --git a/src/lib/openapi/routes/nutrients.ts b/src/lib/openapi/routes/nutrients.ts new file mode 100644 index 000000000..1b96543ff --- /dev/null +++ b/src/lib/openapi/routes/nutrients.ts @@ -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 = { + "/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, + }, + }, + }, +}; diff --git a/src/lib/query-keys/index.ts b/src/lib/query-keys/index.ts index aff4cfbd7..1aa7b4199 100644 --- a/src/lib/query-keys/index.ts +++ b/src/lib/query-keys/index.ts @@ -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, }; /** diff --git a/src/lib/query-keys/nutrients.ts b/src/lib/query-keys/nutrients.ts new file mode 100644 index 000000000..62d512401 --- /dev/null +++ b/src/lib/query-keys/nutrients.ts @@ -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, +}; diff --git a/src/lib/validations/nutrients.ts b/src/lib/validations/nutrients.ts new file mode 100644 index 000000000..ce7c1dc4b --- /dev/null +++ b/src/lib/validations/nutrients.ts @@ -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.", + }); diff --git a/tests/integration/doctor-report-sections.test.ts b/tests/integration/doctor-report-sections.test.ts index c3999f6ae..629cfbafa 100644 --- a/tests/integration/doctor-report-sections.test.ts +++ b/tests/integration/doctor-report-sections.test.ts @@ -321,6 +321,7 @@ describe("doctor-report — module enable/disable gating", () => { mcp: true, inboundDocuments: true, mentalHealth: true, + nutrients: true, } as const; it("includes every module's data when all modules are enabled", async () => { diff --git a/tests/integration/nutrients-batch.test.ts b/tests/integration/nutrients-batch.test.ts new file mode 100644 index 000000000..49529bc8d --- /dev/null +++ b/tests/integration/nutrients-batch.test.ts @@ -0,0 +1,261 @@ +/** + * v1.28 — `POST /api/nutrients/batch` + `GET /api/nutrients` real-Postgres + * integration. + * + * Asserts the composite-PK contract the unit mocks can't pin: + * - a clean batch inserts one row per (user, day, nutrient) + * - a re-post replaces the day total in place (`updated`, no new row) + * - a unit-mismatched entry is skipped and never lands + * - the opt-in module gate refuses ingest AND read with 403 + * `module.disabled` for an account that never opted in + * - the read endpoint folds stored rows into the window summary + */ +import { NextRequest } from "next/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { cookieJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +const OPTED_IN_USER = "user-nutrients"; +const OPTED_OUT_USER = "user-nutrients-off"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => { + cookieJar.set(name, value); + }, + delete: (name: string) => { + cookieJar.delete(name); + }, + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +async function signIn(userId: string): Promise { + const session = await getPrismaClient().session.create({ + data: { + userId, + expiresAt: new Date(Date.now() + 60 * 60 * 1000), + }, + }); + cookieJar.set("healthlog_session", session.id); +} + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + await getPrismaClient().user.create({ + data: { + id: OPTED_IN_USER, + username: "nutrients-on", + email: "nutrients-on@example.test", + timezone: "Europe/Berlin", + // Opt-in module: only an explicit `true` enables it. + modulePreferencesJson: { nutrients: true }, + }, + }); + await getPrismaClient().user.create({ + data: { + id: OPTED_OUT_USER, + username: "nutrients-off", + email: "nutrients-off@example.test", + timezone: "Europe/Berlin", + }, + }); + await signIn(OPTED_IN_USER); +}); + +function postReq(body: unknown): NextRequest { + return new NextRequest("http://localhost/api/nutrients/batch", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +function recentDay(offsetDays: number): string { + return new Date(Date.now() - offsetDays * 24 * 60 * 60 * 1000) + .toISOString() + .slice(0, 10); +} + +interface BatchEnvelope { + data: { + processed: number; + inserted: number; + updated: number; + skipped: Array<{ index: number; reason: string }>; + entries: Array<{ index: number; status: string }>; + }; +} + +describe("POST /api/nutrients/batch (real Postgres)", () => { + it("inserts a clean batch — one row per (user, day, nutrient)", async () => { + const { POST } = await import("@/app/api/nutrients/batch/route"); + const res = await POST( + postReq({ + entries: [ + { + day: recentDay(1), + nutrient: "vitamin_d", + unit: "ug", + amount: 20, + externalSourceVersion: "writer-1.0", + }, + { day: recentDay(1), nutrient: "caffeine", unit: "mg", amount: 250 }, + { day: recentDay(2), nutrient: "caffeine", unit: "mg", amount: 400 }, + ], + }), + ); + + expect(res.status).toBe(200); + const json = (await res.json()) as BatchEnvelope; + expect(json.data.processed).toBe(3); + expect(json.data.inserted).toBe(3); + expect(json.data.updated).toBe(0); + + const stored = await getPrismaClient().nutrientIntakeDay.findMany({ + where: { userId: OPTED_IN_USER }, + orderBy: [{ nutrient: "asc" }, { day: "asc" }], + }); + expect(stored).toHaveLength(3); + expect(stored.map((r) => r.nutrient)).toEqual([ + "caffeine", + "caffeine", + "vitamin_d", + ]); + expect(stored[2].unit).toBe("ug"); + expect(stored[2].source).toBe("APPLE_HEALTH"); + expect(stored[2].externalSourceVersion).toBe("writer-1.0"); + }); + + it("re-post replaces the day total in place (composite-PK upsert, status updated)", async () => { + const { POST } = await import("@/app/api/nutrients/batch/route"); + const day = recentDay(1); + + await POST( + postReq({ + entries: [{ day, nutrient: "water", unit: "ml", amount: 900 }], + }), + ); + // The steps pattern: the total grows as the day progresses. + const res = await POST( + postReq({ + entries: [{ day, nutrient: "water", unit: "ml", amount: 1800 }], + }), + ); + + const json = (await res.json()) as BatchEnvelope; + expect(json.data.inserted).toBe(0); + expect(json.data.updated).toBe(1); + expect(json.data.entries[0].status).toBe("updated"); + + const stored = await getPrismaClient().nutrientIntakeDay.findMany({ + where: { userId: OPTED_IN_USER, nutrient: "water" }, + }); + expect(stored).toHaveLength(1); + expect(stored[0].amount).toBe(1800); + }); + + it("skips a unit-mismatched entry without landing a row", async () => { + const { POST } = await import("@/app/api/nutrients/batch/route"); + const res = await POST( + postReq({ + entries: [ + { day: recentDay(1), nutrient: "vitamin_d", unit: "mg", amount: 20 }, + ], + }), + ); + const json = (await res.json()) as BatchEnvelope; + expect(json.data.skipped).toEqual([{ index: 0, reason: "unit_mismatch" }]); + const count = await getPrismaClient().nutrientIntakeDay.count({ + where: { userId: OPTED_IN_USER }, + }); + expect(count).toBe(0); + }); + + it("refuses ingest 403 module.disabled for an account that never opted in", async () => { + const { POST } = await import("@/app/api/nutrients/batch/route"); + cookieJar.clear(); + await signIn(OPTED_OUT_USER); + + const res = await POST( + postReq({ + entries: [ + { day: recentDay(1), nutrient: "iron", unit: "mg", amount: 12 }, + ], + }), + ); + expect(res.status).toBe(403); + const body = (await res.json()) as { meta?: { errorCode?: string } }; + expect(body.meta?.errorCode).toBe("module.disabled"); + const count = await getPrismaClient().nutrientIntakeDay.count({ + where: { userId: OPTED_OUT_USER }, + }); + expect(count).toBe(0); + }); +}); + +describe("GET /api/nutrients (real Postgres)", () => { + it("folds stored rows into the catalog-ordered window summary", async () => { + const { POST } = await import("@/app/api/nutrients/batch/route"); + const { GET } = await import("@/app/api/nutrients/route"); + + await POST( + postReq({ + entries: [ + { day: recentDay(2), nutrient: "caffeine", unit: "mg", amount: 400 }, + { day: recentDay(1), nutrient: "caffeine", unit: "mg", amount: 250 }, + { day: recentDay(1), nutrient: "vitamin_d", unit: "ug", amount: 20 }, + ], + }), + ); + + const res = await GET( + new NextRequest("http://localhost/api/nutrients?days=14"), + ); + expect(res.status).toBe(200); + const json = (await res.json()) as { + data: { + windowDays: number; + nutrients: Array<{ + nutrient: string; + latestDay: string; + latestAmount: number; + daysWithData: number; + }>; + }; + }; + expect(json.data.windowDays).toBe(14); + expect(json.data.nutrients).toHaveLength(2); + // Catalog order: vitamin_d before the caffeine tail entry. + expect(json.data.nutrients[0].nutrient).toBe("vitamin_d"); + expect(json.data.nutrients[1]).toMatchObject({ + nutrient: "caffeine", + latestDay: recentDay(1), + latestAmount: 250, + daysWithData: 2, + }); + }); + + it("refuses the read 403 module.disabled when the module is off", async () => { + const { GET } = await import("@/app/api/nutrients/route"); + cookieJar.clear(); + await signIn(OPTED_OUT_USER); + const res = await GET(new NextRequest("http://localhost/api/nutrients")); + expect(res.status).toBe(403); + }); +});