From 0151f86f3f39c554d4174143f80b1b00d8a2fdcd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 28 Jun 2026 16:58:13 +0200 Subject: [PATCH] feat(environment): add environmental-context data model (migration 0217) Add the opt-in environmental-context store: per-day EnvironmentContext observations (temperature, daylight, sunshine, precipitation, pressure, humidity, cloud, weather code) keyed by user + day and location, plus the manual EnvironmentTravelLocation overrides and the coarse home-location columns on the user. Location is city-level by design; the weather model needs no finer than ~10-25 km and no GPS track is ever stored. --- .../migration.sql | 101 +++++++++ prisma/schema.prisma | 212 +++++++++++++----- 2 files changed, 263 insertions(+), 50 deletions(-) create mode 100644 prisma/migrations/0217_v125_environmental_context/migration.sql diff --git a/prisma/migrations/0217_v125_environmental_context/migration.sql b/prisma/migrations/0217_v125_environmental_context/migration.sql new file mode 100644 index 000000000..4cc4e661f --- /dev/null +++ b/prisma/migrations/0217_v125_environmental_context/migration.sql @@ -0,0 +1,101 @@ +-- v1.25 (W-ENV) — environmental-context module (opt-in, server-side v1). +-- +-- Adds the per-day weather/daylight observation store and the manual travel +-- overrides, plus the coarse home-location columns on `users`. The module is +-- opt-in (default OFF via the module registry); with it off no row is written +-- and no outbound weather fetch runs. +-- +-- Location granularity is coarse (city level / rounded lat-lon) by design — the +-- weather model only needs ~10–25 km and HealthLog never stores a GPS track. +-- +-- Additive; no existing row touched. Idempotent guards (`IF NOT EXISTS` / +-- `DO $$`) so reruns are safe on prod. +-- +-- Reversibility (down): +-- DROP TABLE IF EXISTS "environment_travel_locations"; +-- DROP TABLE IF EXISTS "environment_contexts"; +-- DROP TYPE IF EXISTS "environment_location_source"; +-- ALTER TABLE "users" +-- DROP COLUMN IF EXISTS "home_lat", +-- DROP COLUMN IF EXISTS "home_lon", +-- DROP COLUMN IF EXISTS "home_label", +-- DROP COLUMN IF EXISTS "home_timezone"; +-- A roll-back drops the environment store wholesale — no other domain depends +-- on it. + +-- AlterTable: coarse home location on the user (all nullable). +ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "home_lat" DOUBLE PRECISION; +ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "home_lon" DOUBLE PRECISION; +ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "home_label" TEXT; +ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "home_timezone" TEXT; + +-- CreateEnum +DO $$ BEGIN + CREATE TYPE "environment_location_source" AS ENUM ('HOME', 'TRAVEL'); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; + +-- CreateTable +CREATE TABLE IF NOT EXISTS "environment_contexts" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "date" TEXT NOT NULL, + "lat" DOUBLE PRECISION NOT NULL, + "lon" DOUBLE PRECISION NOT NULL, + "location_label" TEXT NOT NULL, + "source" "environment_location_source" NOT NULL, + "temp_min" DOUBLE PRECISION, + "temp_max" DOUBLE PRECISION, + "temp_mean" DOUBLE PRECISION, + "apparent_mean" DOUBLE PRECISION, + "sunshine_sec" INTEGER, + "daylight_sec" INTEGER, + "precip_sum" DOUBLE PRECISION, + "pressure_mean" DOUBLE PRECISION, + "pressure_delta" DOUBLE PRECISION, + "humidity_mean" DOUBLE PRECISION, + "cloud_mean" DOUBLE PRECISION, + "weather_code" INTEGER, + "fetched_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "environment_contexts_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX IF NOT EXISTS "environment_contexts_user_id_date_key" ON "environment_contexts"("user_id", "date"); +CREATE INDEX IF NOT EXISTS "environment_contexts_user_id_date_idx" ON "environment_contexts"("user_id", "date"); + +-- CreateTable +CREATE TABLE IF NOT EXISTS "environment_travel_locations" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "start_date" TEXT NOT NULL, + "end_date" TEXT NOT NULL, + "lat" DOUBLE PRECISION NOT NULL, + "lon" DOUBLE PRECISION NOT NULL, + "label" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "environment_travel_locations_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX IF NOT EXISTS "environment_travel_locations_user_id_start_date_end_date_idx" ON "environment_travel_locations"("user_id", "start_date", "end_date"); + +-- AddForeignKey +DO $$ BEGIN + ALTER TABLE "environment_contexts" ADD CONSTRAINT "environment_contexts_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; + +-- AddForeignKey +DO $$ BEGIN + ALTER TABLE "environment_travel_locations" ADD CONSTRAINT "environment_travel_locations_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e862c91f0..6687ea8f5 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -51,6 +51,19 @@ model User { gender String? @map("gender") // "MALE" | "FEMALE" | "OTHER" | null = keine Angabe timezone String @default("Europe/Berlin") + // v1.25 (W-ENV) — opt-in environmental-context module home location. A coarse, + // user-set city (rounded lat/lon, ~city granularity — never a GPS track) the + // nightly weather fetch resolves against when no travel override covers the + // day. City-level granularity is the privacy floor: the weather model only + // needs ~10–25 km. All nullable — an account without the module enabled (or + // without a home set) simply has no environment rows. `homeTimezone` anchors + // the day-key an observation is stored under (it can differ from the display + // `timezone` above for an account that tracks weather for another locale). + homeLat Float? @map("home_lat") + homeLon Float? @map("home_lon") + homeLabel String? @map("home_label") + homeTimezone String? @map("home_timezone") + // Codex OAuth (ChatGPT subscription) codexAccessTokenEncrypted String? @map("codex_access_token_encrypted") codexRefreshTokenEncrypted String? @map("codex_refresh_token_encrypted") @@ -727,6 +740,12 @@ model User { // sensitive free-text fields are encrypted at rest. allergies Allergy[] familyHistory FamilyHistoryEntry[] + // v1.25 (W-ENV) — opt-in environmental-context module. `EnvironmentContext` + // is the per-day weather/daylight observation keyed to the resolved location; + // `EnvironmentTravelLocation` holds the manual date-range travel overrides + // that win over the home location for the days they cover. + environmentContexts EnvironmentContext[] + environmentTravelLocations EnvironmentTravelLocation[] // v1.19.0 — full Withings ECG waveform recordings (encrypted at rest). // The paired AFib verdict still lands as an IRREGULAR_RHYTHM_NOTIFICATION // Measurement EVENT row (v1.18.11); this holds the raw signal samples. @@ -782,6 +801,99 @@ model User { @@map("users") } +// ─── v1.25 (W-ENV) Environmental-context module ────────────────────────────── + +/// How the location for an environment observation was resolved. HOME = the +/// user's configured home location; TRAVEL = a manual date-range override. +/// (iOS coarse device location is a v2 follow-up and not represented here.) +enum EnvironmentLocationSource { + HOME + TRAVEL +} + +/// A per-day environmental observation (daily weather / daylight) for the +/// location resolved for that day. Day-keyed exactly like the rollup tier: +/// `date` is a YYYY-MM-DD string anchored to the location's IANA timezone, so a +/// late-evening reading does not mis-bucket under UTC. One row per (user, day); +/// a re-fetch upserts (the archive feed settles over a few days). All weather +/// fields are nullable so a partial feed still stores what it has. The location +/// stored here is coarse (city granularity) — never a GPS track. +model EnvironmentContext { + id String @id @default(cuid()) + userId String @map("user_id") + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + /// YYYY-MM-DD anchored to the resolved location's timezone. + date String + + /// Coarse resolved location for the day (rounded lat/lon + label + the source + /// that won precedence). + lat Float + lon Float + locationLabel String @map("location_label") + source EnvironmentLocationSource + + /// Daily temperature (°C). Min doubles as the overnight-low proxy. + tempMin Float? @map("temp_min") + tempMax Float? @map("temp_max") + tempMean Float? @map("temp_mean") + /// Daily apparent ("feels-like") mean (°C). + apparentMean Float? @map("apparent_mean") + + /// Sunshine + daylight, stored in seconds (the feed's native unit). + sunshineSec Int? @map("sunshine_sec") + daylightSec Int? @map("daylight_sec") + + /// Precipitation total (mm) over the local day. + precipSum Float? @map("precip_sum") + + /// Pressure aggregated from the hourly feed: daily mean (hPa) + the intraday + /// delta (max−min, hPa) — the derived feature for the headache/symptom overlay. + pressureMean Float? @map("pressure_mean") + pressureDelta Float? @map("pressure_delta") + + /// Relative humidity mean (%) and cloud-cover mean (%) from the hourly feed. + humidityMean Float? @map("humidity_mean") + cloudMean Float? @map("cloud_mean") + + /// WMO weather code for the day (descriptive condition lookup). + weatherCode Int? @map("weather_code") + + /// When the row was last fetched/refreshed from the upstream feed. + fetchedAt DateTime @default(now()) @map("fetched_at") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@unique([userId, date]) + @@index([userId, date]) + @@map("environment_contexts") +} + +/// A manual travel override: for any day in `[startDate, endDate]` the weather +/// fetch resolves against this location instead of the home location. Declared, +/// not inferred — a stated trip dominates the home fallback so a winter week in +/// a sunny locale is not mis-attributed to the home city. Coarse location only. +model EnvironmentTravelLocation { + id String @id @default(cuid()) + userId String @map("user_id") + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + /// Inclusive YYYY-MM-DD range (anchored to the user's home timezone for the + /// day-key the override matches against). + startDate String @map("start_date") + endDate String @map("end_date") + + lat Float + lon Float + label String + + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@index([userId, startDate, endDate]) + @@map("environment_travel_locations") +} + model Passkey { id String @id @default(cuid()) userId String @map("user_id") @@ -1158,37 +1270,37 @@ enum AssessmentInstrument { /// administration (linked by `externalId = assessment:`) so the score rides /// the existing chart / rollup infra — the trend, never the item content. model MentalHealthAssessment { - id String @id @default(cuid()) - userId String @map("user_id") - instrument AssessmentInstrument + id String @id @default(cuid()) + userId String @map("user_id") + instrument AssessmentInstrument /// The validated translation actually presented (officially-validated wording /// per locale, English fallback otherwise — recorded for the audit trail). - locale String + locale String /// Instrument variant identifier (e.g. "standard"); future-proofs revisions. - version String @default("standard") + version String @default("standard") /// Encrypted JSON blob: { items: number[], functionalDifficulty?: 0..3, schema: 1 }. /// AES-256-GCM via the shared Bytes codec; registered in ENCRYPTED_COLUMNS + /// the rotation script. The item-9 raw value lives ONLY here. - responsesEncrypted Bytes @map("responses_encrypted") + responsesEncrypted Bytes @map("responses_encrypted") /// Derived total (PHQ-9 0–27, GAD-7 0–21). Denormalised for fast reads. - totalScore Int @map("total_score") + totalScore Int @map("total_score") /// Derived severity band label (server-authoritative; descriptive, never a diagnosis). - severityBand String @map("severity_band") + severityBand String @map("severity_band") /// PHQ-9 only: item 9 (self-harm ideation) answered > 0. A coarse boolean so /// the history view can re-surface crisis resources without decrypting. - item9Flagged Boolean @default(false) @map("item9_flagged") + item9Flagged Boolean @default(false) @map("item9_flagged") /// Audit stamp: the supportive crisis-resource signpost was shown to the user. /// Never triggers any third-party alert — private, on-device-only signposting. - crisisShownAt DateTime? @map("crisis_shown_at") + crisisShownAt DateTime? @map("crisis_shown_at") /// Recall-window anchor (when the screener was taken). - takenAt DateTime @map("taken_at") - tz String? - source String @default("WEB") - externalId String? @map("external_id") - syncVersion Int @default(0) @map("sync_version") - deletedAt DateTime? @map("deleted_at") - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") + takenAt DateTime @map("taken_at") + tz String? + source String @default("WEB") + externalId String? @map("external_id") + syncVersion Int @default(0) @map("sync_version") + deletedAt DateTime? @map("deleted_at") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@ -2537,26 +2649,26 @@ model MedicationInventoryItem { /// - (userId, occurredAt DESC) — Coach snapshot aggregation read /// path; correlation across medications is per-user, not per-drug. model MedicationSideEffect { - id String @id @default(cuid()) - userId String @map("user_id") - medicationId String @map("medication_id") - occurredAt DateTime @default(now()) @map("occurred_at") - category MedicationSideEffectCategory - entry MedicationSideEffectEntry + id String @id @default(cuid()) + userId String @map("user_id") + medicationId String @map("medication_id") + occurredAt DateTime @default(now()) @map("occurred_at") + category MedicationSideEffectCategory + entry MedicationSideEffectEntry /// 1 = mild, 2 = moderate, 3 = significant, 4 = severe, 5 = very-severe. /// Constrained to [1, 5] at the application layer (Zod) and the DB /// (CHECK constraint via migration). Not an enum to keep cardinality /// trivially numeric for chart aggregation. - severity Int + severity Int /// Optional free-text note, hard-capped to 280 chars at the /// application layer. The DB column is unbounded TEXT for /// consistency with sibling "notes" columns on this schema. - notes String? + notes String? /// v1.25 — AES-256-GCM ciphertext for `notes`. The plaintext column /// stays for read-fallback until the backfill reports zero remaining /// rows; the plaintext drop is DEFERRED to a later release. - notesEncrypted Bytes? @map("notes_encrypted") - createdAt DateTime @default(now()) @map("created_at") + notesEncrypted Bytes? @map("notes_encrypted") + createdAt DateTime @default(now()) @map("created_at") user User @relation(fields: [userId], references: [id], onDelete: Cascade) medication Medication @relation(fields: [medicationId], references: [id], onDelete: Cascade) @@ -5254,28 +5366,28 @@ enum AllergyStatus { /// and notes are encrypted at rest. Patient-reported; the export carries a /// "not a clinical diagnosis" guard rail. model Allergy { - id String @id @default(cuid()) - userId String @map("user_id") + id String @id @default(cuid()) + userId String @map("user_id") /// User-facing substance/allergen name, e.g. "Penicillin", "Peanuts". /// Queryable plaintext label (the medication.name / illness.label /// precedent); rides FHIR `code.text`. - substance String - category AllergyCategory @default(OTHER) - type AllergyType @default(ALLERGY) + substance String + category AllergyCategory @default(OTHER) + type AllergyType @default(ALLERGY) /// Worst observed reaction severity. NULL = not assessed. - severity AllergySeverity? - status AllergyStatus @default(ACTIVE) + severity AllergySeverity? + status AllergyStatus @default(ACTIVE) /// When the allergy was first noticed (UTC instant). NULL = unknown. - onsetAt DateTime? @map("onset_at") + onsetAt DateTime? @map("onset_at") /// AES-256-GCM ciphertext of the free-text reaction/manifestation /// description (e.g. "anaphylaxis", "hives"). Bytes via the shared codec. - reactionEncrypted Bytes? @map("reaction_encrypted") + reactionEncrypted Bytes? @map("reaction_encrypted") /// AES-256-GCM ciphertext of an optional free-text note. Bytes column. - notesEncrypted Bytes? @map("notes_encrypted") - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") + notesEncrypted Bytes? @map("notes_encrypted") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") /// Soft-delete tombstone. NULL = live; non-NULL = deleted at the instant. - deletedAt DateTime? @map("deleted_at") + deletedAt DateTime? @map("deleted_at") user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@ -5312,20 +5424,20 @@ enum FamilyRelationship { /// code); `ageAtOnset` is the relative's age when the condition began. The /// optional free-text note is encrypted at rest. Patient-reported. model FamilyHistoryEntry { - id String @id @default(cuid()) - userId String @map("user_id") - relationship FamilyRelationship + id String @id @default(cuid()) + userId String @map("user_id") + relationship FamilyRelationship /// User-facing condition name, e.g. "Type 2 diabetes", "Breast cancer". /// Queryable plaintext label; rides FHIR `condition.code.text`. - condition String + condition String /// The relative's age (years) when the condition began. NULL = unknown. - ageAtOnset Int? @map("age_at_onset") + ageAtOnset Int? @map("age_at_onset") /// AES-256-GCM ciphertext of an optional free-text note. Bytes column. - notesEncrypted Bytes? @map("notes_encrypted") - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") + notesEncrypted Bytes? @map("notes_encrypted") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") /// Soft-delete tombstone. NULL = live; non-NULL = deleted at the instant. - deletedAt DateTime? @map("deleted_at") + deletedAt DateTime? @map("deleted_at") user User @relation(fields: [userId], references: [id], onDelete: Cascade)