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