From 6e72de1c35ebfc0cb2bda7e328d4d54fd91f10be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 15 Oct 2026 21:48:57 +0200 Subject: [PATCH] feat(db): add the v1.43 schema and drop the readable location columns Migrations 0402 to 0410: AI usage levels, biomarker aliases and merges, medication ingredients (with the supplement label run kind), life circumstance fact kinds, AI sections and AUDIT-C, Dawarich context, the pulse day-mean method, intraday hour buckets, the readable location drop and five relation link tables. Every new column carries its reader or a pending-consumer marker. 0409 refuses to run while any home, location period or stored day still holds a readable location, and names the counts. Locations are read from the sealed copy only; the encryption backfill no longer handles them, and the demo seed writes sealed locations only. The new tables are backed up and wiped, the new sealed columns are in the rotation registry with labels of their own, the AI usage level and the life-circumstance confirmations travel with the backup, and the wellbeing score reads the mental-health screeners only. --- .../0402_ai_usage_levels/migration.sql | 24 + .../migration.sql | 58 +++ .../0404_medication_ingredients/migration.sql | 43 ++ .../0405_life_circumstances/migration.sql | 27 ++ .../0406_dawarich_context/migration.sql | 38 ++ .../0407_pulse_day_mean_method/migration.sql | 14 + .../0408_intraday_hour_buckets/migration.sql | 16 + .../migration.sql | 50 ++ .../0410_relation_links/migration.sql | 151 +++++++ prisma/schema.prisma | 426 +++++++++++++++--- scripts/rotate-encryption-key.ts | 20 +- scripts/seed-demo.ts | 17 +- .../environment-backup-completeness.test.ts | 13 +- .../backfill/__tests__/route.test.ts | 1 - src/app/api/environment/backfill/route.ts | 6 +- src/app/api/environment/home/route.ts | 3 - src/app/api/environment/route.ts | 22 +- src/app/api/environment/travel/route.ts | 3 - .../tools/__tests__/environment-read.test.ts | 3 +- src/lib/analytics/score/reader.ts | 44 +- src/lib/crypto/encrypted-columns.ts | 37 +- src/lib/data-wipe/wipe-plan.ts | 26 +- src/lib/documents/ai-runs/store.ts | 5 +- src/lib/documents/ai-runs/types.ts | 13 + .../__tests__/location-cipher.test.ts | 26 +- .../__tests__/service-air-quality.test.ts | 21 +- src/lib/environment/air-quality-history.ts | 10 +- src/lib/environment/location-cipher.ts | 67 +-- src/lib/environment/service.ts | 42 +- .../__tests__/account-settings-backup.test.ts | 15 +- src/lib/export/account-settings-backup.ts | 50 +- src/lib/export/backup-plan.ts | 53 ++- src/lib/export/environment-backup.ts | 70 +-- src/lib/export/profile-backup.ts | 26 +- .../free-text-encryption-backfill.test.ts | 154 ------- .../jobs/environment-air-quality-history.ts | 1 - src/lib/jobs/environment-fetch.ts | 9 +- src/lib/jobs/free-text-encryption-backfill.ts | 179 +------- src/lib/validations/backup.ts | 9 + src/lib/validations/mental-health.ts | 15 + tests/integration/backup-round-trip.test.ts | 49 +- .../drop-plaintext-location-migration.test.ts | 245 ++++++++++ .../environment-coarse-location.test.ts | 83 +--- .../environment-location-encryption.test.ts | 110 +---- .../prisma-schema-name-drift.test.ts | 9 - 45 files changed, 1386 insertions(+), 917 deletions(-) create mode 100644 prisma/migrations/0402_ai_usage_levels/migration.sql create mode 100644 prisma/migrations/0403_biomarker_aliases_merges/migration.sql create mode 100644 prisma/migrations/0404_medication_ingredients/migration.sql create mode 100644 prisma/migrations/0405_life_circumstances/migration.sql create mode 100644 prisma/migrations/0406_dawarich_context/migration.sql create mode 100644 prisma/migrations/0407_pulse_day_mean_method/migration.sql create mode 100644 prisma/migrations/0408_intraday_hour_buckets/migration.sql create mode 100644 prisma/migrations/0409_drop_plaintext_location/migration.sql create mode 100644 prisma/migrations/0410_relation_links/migration.sql create mode 100644 tests/integration/drop-plaintext-location-migration.test.ts diff --git a/prisma/migrations/0402_ai_usage_levels/migration.sql b/prisma/migrations/0402_ai_usage_levels/migration.sql new file mode 100644 index 000000000..45db2f08e --- /dev/null +++ b/prisma/migrations/0402_ai_usage_levels/migration.sql @@ -0,0 +1,24 @@ +-- AI usage in three levels (#1217). +-- +-- An account picks frugal, standard or full; the operator sets the highest +-- level an account may pick and the level an account without its own choice +-- runs at. `ai_sync_regen_gap_hours` is the minimum gap between status-card +-- regenerations after a sync at `standard` (NULL = env or 6 h). Both +-- instance defaults are STANDARD; an account's stored NULL follows the +-- instance default. + +-- CreateEnum +CREATE TYPE "ai_usage_level" AS ENUM ('FRUGAL', 'STANDARD', 'FULL'); + +-- AlterTable +ALTER TABLE "users" ADD COLUMN "ai_usage_level" "ai_usage_level"; + +-- AlterTable +ALTER TABLE "app_settings" + ADD COLUMN "ai_usage_max_level" "ai_usage_level" NOT NULL DEFAULT 'STANDARD', + ADD COLUMN "ai_usage_default_level" "ai_usage_level" NOT NULL DEFAULT 'STANDARD', + ADD COLUMN "ai_sync_regen_gap_hours" INTEGER; + +ALTER TABLE "app_settings" + ADD CONSTRAINT "app_settings_ai_sync_regen_gap_hours_check" + CHECK ("ai_sync_regen_gap_hours" IS NULL OR "ai_sync_regen_gap_hours" BETWEEN 1 AND 48); diff --git a/prisma/migrations/0403_biomarker_aliases_merges/migration.sql b/prisma/migrations/0403_biomarker_aliases_merges/migration.sql new file mode 100644 index 000000000..7a753f7ab --- /dev/null +++ b/prisma/migrations/0403_biomarker_aliases_merges/migration.sql @@ -0,0 +1,58 @@ +-- Biomarker aliases and the merge ledger (D1223). +-- +-- An alias is another spelling of a marker on one account; the fold of the +-- name is unique per account, so one spelling resolves to one marker. A merge +-- moves the source markers' readings onto the target and deletes the sources; +-- `biomarker_merges` keeps one row per source so a stale id can answer with +-- the marker it went into. + +-- CreateTable +CREATE TABLE "biomarker_aliases" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "biomarker_id" TEXT NOT NULL, + "name" VARCHAR(120) NOT NULL, + "name_folded" VARCHAR(120) NOT NULL, + "source" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "biomarker_aliases_pkey" PRIMARY KEY ("id"), + CONSTRAINT "biomarker_aliases_source_check" CHECK ("source" IN ('MERGE', 'IMPORT_CHOICE', 'MANUAL')) +); + +-- CreateTable +CREATE TABLE "biomarker_merges" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "target_biomarker_id" TEXT NOT NULL, + "source_biomarker_id" TEXT NOT NULL, + "source_name" VARCHAR(120) NOT NULL, + "reading_count" INTEGER NOT NULL, + "merged_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "biomarker_merges_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "biomarker_aliases_biomarker_id_idx" ON "biomarker_aliases"("biomarker_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "biomarker_aliases_user_id_name_folded_key" ON "biomarker_aliases"("user_id", "name_folded"); + +-- CreateIndex +CREATE INDEX "biomarker_merges_user_id_source_biomarker_id_idx" ON "biomarker_merges"("user_id", "source_biomarker_id"); + +-- CreateIndex +CREATE INDEX "biomarker_merges_target_biomarker_id_idx" ON "biomarker_merges"("target_biomarker_id"); + +-- AddForeignKey +ALTER TABLE "biomarker_aliases" ADD CONSTRAINT "biomarker_aliases_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "biomarker_aliases" ADD CONSTRAINT "biomarker_aliases_biomarker_id_fkey" FOREIGN KEY ("biomarker_id") REFERENCES "biomarkers"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "biomarker_merges" ADD CONSTRAINT "biomarker_merges_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "biomarker_merges" ADD CONSTRAINT "biomarker_merges_target_biomarker_id_fkey" FOREIGN KEY ("target_biomarker_id") REFERENCES "biomarkers"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/0404_medication_ingredients/migration.sql b/prisma/migrations/0404_medication_ingredients/migration.sql new file mode 100644 index 000000000..34202b250 --- /dev/null +++ b/prisma/migrations/0404_medication_ingredients/migration.sql @@ -0,0 +1,43 @@ +-- Supplement composition. +-- +-- What one unit (tablet, capsule) of a medication contains, per nutrient, in +-- the nutrient catalogue's canonical unit. One row per (medication, nutrient). +-- The entered amount and unit are kept when the input unit differed (IU for +-- vitamin D). Taken doses add their share to `nutrient_intake_days` under the +-- text source `SUPPLEMENT`, which needs no schema change. Reading a label +-- photo is a background document AI run of its own kind; the value is added +-- only, nothing here uses it. + +-- AlterEnum +ALTER TYPE "document_ai_run_kind" ADD VALUE 'SUPPLEMENT_LABEL_EXTRACT'; + +-- CreateTable +CREATE TABLE "medication_ingredients" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "medication_id" TEXT NOT NULL, + "nutrient" VARCHAR(40) NOT NULL, + "amount_per_unit" DOUBLE PRECISION NOT NULL, + "unit" VARCHAR(8) NOT NULL, + "entered_amount" DOUBLE PRECISION, + "entered_unit" VARCHAR(8), + "source" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "medication_ingredients_pkey" PRIMARY KEY ("id"), + CONSTRAINT "medication_ingredients_amount_per_unit_check" CHECK ("amount_per_unit" > 0), + CONSTRAINT "medication_ingredients_source_check" CHECK ("source" IN ('MANUAL', 'LABEL_AI')) +); + +-- CreateIndex +CREATE INDEX "medication_ingredients_user_id_idx" ON "medication_ingredients"("user_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "medication_ingredients_medication_id_nutrient_key" ON "medication_ingredients"("medication_id", "nutrient"); + +-- AddForeignKey +ALTER TABLE "medication_ingredients" ADD CONSTRAINT "medication_ingredients_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "medication_ingredients" ADD CONSTRAINT "medication_ingredients_medication_id_fkey" FOREIGN KEY ("medication_id") REFERENCES "medications"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/0405_life_circumstances/migration.sql b/prisma/migrations/0405_life_circumstances/migration.sql new file mode 100644 index 000000000..a7441c152 --- /dev/null +++ b/prisma/migrations/0405_life_circumstances/migration.sql @@ -0,0 +1,27 @@ +-- Life circumstances (#614). +-- +-- Six new structured fact kinds (sealed JSON values, `schema: 1`), four new +-- AI sections that are never in the column default, AUDIT-C as an assessment +-- instrument, and the per-section "still true" confirmations. The new enum +-- values are added only; nothing in this migration uses them, so the +-- in-transaction ADD VALUE restriction does not apply. + +-- AlterEnum +ALTER TYPE "health_profile_fact_kind" ADD VALUE 'TOBACCO_DETAIL'; +ALTER TYPE "health_profile_fact_kind" ADD VALUE 'ACTIVITY_HABIT'; +ALTER TYPE "health_profile_fact_kind" ADD VALUE 'WORK_CONTEXT'; +ALTER TYPE "health_profile_fact_kind" ADD VALUE 'FREE_WEEKDAYS'; +ALTER TYPE "health_profile_fact_kind" ADD VALUE 'HABITS'; +ALTER TYPE "health_profile_fact_kind" ADD VALUE 'HEALTH_GOALS'; + +-- AlterEnum +ALTER TYPE "health_profile_ai_section" ADD VALUE 'ACTIVITY'; +ALTER TYPE "health_profile_ai_section" ADD VALUE 'WORK'; +ALTER TYPE "health_profile_ai_section" ADD VALUE 'HABITS'; +ALTER TYPE "health_profile_ai_section" ADD VALUE 'GOALS'; + +-- AlterEnum +ALTER TYPE "assessment_instrument" ADD VALUE 'AUDIT_C'; + +-- AlterTable +ALTER TABLE "user_health_profiles" ADD COLUMN "life_confirmed_json" JSONB; diff --git a/prisma/migrations/0406_dawarich_context/migration.sql b/prisma/migrations/0406_dawarich_context/migration.sql new file mode 100644 index 000000000..ad58dde23 --- /dev/null +++ b/prisma/migrations/0406_dawarich_context/migration.sql @@ -0,0 +1,38 @@ +-- Dawarich movement context. +-- +-- Per-account sealed connection (URL, API key, preferences) and one row per +-- local day with daily values only: distances in metres, places visited, +-- minutes away from home, and the sealed country code of the day. No +-- coordinate, place name or visit is stored anywhere. + +-- AlterTable +ALTER TABLE "users" + ADD COLUMN "dawarich_url_encrypted" TEXT, + ADD COLUMN "dawarich_api_key_encrypted" TEXT, + ADD COLUMN "dawarich_prefs_encrypted" BYTEA, + ADD COLUMN "dawarich_allow_private_host" BOOLEAN NOT NULL DEFAULT false; + +-- CreateTable +CREATE TABLE "dawarich_days" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "date" VARCHAR(10) NOT NULL, + "total_distance_m" INTEGER, + "driving_distance_m" INTEGER, + "train_distance_m" INTEGER, + "places_visited" INTEGER, + "away_from_home_minutes" INTEGER, + "country_code_encrypted" BYTEA, + "is_travel_day" BOOLEAN, + "fetched_at" TIMESTAMP(3) NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "dawarich_days_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "dawarich_days_user_id_date_key" ON "dawarich_days"("user_id", "date"); + +-- AddForeignKey +ALTER TABLE "dawarich_days" ADD CONSTRAINT "dawarich_days_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/0407_pulse_day_mean_method/migration.sql b/prisma/migrations/0407_pulse_day_mean_method/migration.sql new file mode 100644 index 000000000..fed53c3e6 --- /dev/null +++ b/prisma/migrations/0407_pulse_day_mean_method/migration.sql @@ -0,0 +1,14 @@ +-- Pulse day-mean method (#1107 follow-up). +-- +-- The method every pulse day-mean reader uses, switched only by the admin +-- apply step after a dry run. `hour_mean_v1` is the behaviour up to v1.42. + +-- AlterTable +ALTER TABLE "app_settings" + ADD COLUMN "pulse_day_mean_method" TEXT NOT NULL DEFAULT 'hour_mean_v1', + ADD COLUMN "pulse_day_mean_applied_at" TIMESTAMP(3), + ADD COLUMN "pulse_day_mean_dry_run_json" JSONB; + +ALTER TABLE "app_settings" + ADD CONSTRAINT "app_settings_pulse_day_mean_method_check" + CHECK ("pulse_day_mean_method" IN ('hour_mean_v1', 'weighted_hour_mean_v2')); diff --git a/prisma/migrations/0408_intraday_hour_buckets/migration.sql b/prisma/migrations/0408_intraday_hour_buckets/migration.sql new file mode 100644 index 000000000..dd38defbc --- /dev/null +++ b/prisma/migrations/0408_intraday_hour_buckets/migration.sql @@ -0,0 +1,16 @@ +-- Hour buckets for summed intraday types (ios #77). +-- +-- A day of steps, active energy, distance or flights can be built from the +-- hourly `stats::T` rows the app posts instead of the fold +-- of single samples. The hour increments live beside the running totals; +-- `basis` says which source built the profile. + +-- AlterTable +ALTER TABLE "intraday_cumulative_profiles" + ADD COLUMN "hourly_increments" DOUBLE PRECISION[], + ADD COLUMN "basis" TEXT NOT NULL DEFAULT 'PER_SAMPLE', + ADD COLUMN "bucket_count" INTEGER; + +ALTER TABLE "intraday_cumulative_profiles" + ADD CONSTRAINT "intraday_cumulative_profiles_basis_check" + CHECK ("basis" IN ('PER_SAMPLE', 'HOUR_BUCKETS')); diff --git a/prisma/migrations/0409_drop_plaintext_location/migration.sql b/prisma/migrations/0409_drop_plaintext_location/migration.sql new file mode 100644 index 000000000..af7f5f4fd --- /dev/null +++ b/prisma/migrations/0409_drop_plaintext_location/migration.sql @@ -0,0 +1,50 @@ +-- Drop the plaintext location columns (#615 follow-up). +-- +-- Since v1.42 every location is sealed (`users.home_location_encrypted`, +-- `environment_contexts.location_encrypted`, +-- `environment_travel_locations.location_encrypted`), and the free-text +-- encryption backfill moved the plaintext into the sealed copy and cleared +-- it. This migration removes the plaintext columns and the partial indexes +-- from 0382 that served the backfill. +-- +-- It refuses to run while any row still holds a plaintext location: such a +-- row has not been sealed yet, and dropping the column would lose it. Start +-- the previous release, let the backfill finish (the three counts below must +-- all be 0), then upgrade again. +DO $$ +DECLARE + home_rows BIGINT; + travel_rows BIGINT; + day_rows BIGINT; +BEGIN + SELECT count(*) INTO home_rows FROM "users" WHERE "home_lat" IS NOT NULL; + SELECT count(*) INTO travel_rows FROM "environment_travel_locations" WHERE "lat" IS NOT NULL; + SELECT count(*) INTO day_rows FROM "environment_contexts" WHERE "lat" IS NOT NULL; + IF home_rows > 0 OR travel_rows > 0 OR day_rows > 0 THEN + RAISE EXCEPTION 'HealthLog migration 0409 stopped: % home location(s), % location period(s) and % daily location(s) are still stored in plaintext. Run the previous release until its free-text encryption backfill reports zero remaining rows, then upgrade again. Nothing was changed.', home_rows, travel_rows, day_rows; + END IF; +END +$$; + +-- DropIndex +DROP INDEX IF EXISTS "users_home_location_plain_idx"; +DROP INDEX IF EXISTS "environment_travel_locations_plain_location_idx"; +DROP INDEX IF EXISTS "environment_contexts_plain_location_idx"; + +-- AlterTable +ALTER TABLE "users" + DROP COLUMN "home_lat", + DROP COLUMN "home_lon", + DROP COLUMN "home_label"; + +-- AlterTable +ALTER TABLE "environment_contexts" + DROP COLUMN "lat", + DROP COLUMN "lon", + DROP COLUMN "location_label"; + +-- AlterTable +ALTER TABLE "environment_travel_locations" + DROP COLUMN "lat", + DROP COLUMN "lon", + DROP COLUMN "label"; diff --git a/prisma/migrations/0410_relation_links/migration.sql b/prisma/migrations/0410_relation_links/migration.sql new file mode 100644 index 000000000..ba4e674cc --- /dev/null +++ b/prisma/migrations/0410_relation_links/migration.sql @@ -0,0 +1,151 @@ +-- Relation links that close the remaining pairs of the relation matrix. +-- +-- Lab result and document, visit and medication, visit and vaccination, +-- medication and document, medication and condition (indication). Same shape +-- as `encounter_lab_links`: both ends cascade, the pair is unique. + +-- CreateTable +CREATE TABLE "lab_result_document_links" ( + "id" TEXT NOT NULL, + "lab_result_id" TEXT NOT NULL, + "document_id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "lab_result_document_links_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "encounter_medication_links" ( + "id" TEXT NOT NULL, + "encounter_id" TEXT NOT NULL, + "medication_id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "encounter_medication_links_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "encounter_vaccination_links" ( + "id" TEXT NOT NULL, + "encounter_id" TEXT NOT NULL, + "vaccination_id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "encounter_vaccination_links_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "medication_document_links" ( + "id" TEXT NOT NULL, + "medication_id" TEXT NOT NULL, + "document_id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "medication_document_links_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "medication_condition_links" ( + "id" TEXT NOT NULL, + "medication_id" TEXT NOT NULL, + "episode_id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "medication_condition_links_pkey" PRIMARY KEY ("id") +); + + +-- CreateIndex +CREATE INDEX "lab_result_document_links_document_id_idx" ON "lab_result_document_links"("document_id"); + +-- CreateIndex +CREATE INDEX "lab_result_document_links_user_id_idx" ON "lab_result_document_links"("user_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "lab_result_document_links_lab_result_id_document_id_key" ON "lab_result_document_links"("lab_result_id", "document_id"); + +-- CreateIndex +CREATE INDEX "encounter_medication_links_medication_id_idx" ON "encounter_medication_links"("medication_id"); + +-- CreateIndex +CREATE INDEX "encounter_medication_links_user_id_idx" ON "encounter_medication_links"("user_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "encounter_medication_links_encounter_id_medication_id_key" ON "encounter_medication_links"("encounter_id", "medication_id"); + +-- CreateIndex +CREATE INDEX "encounter_vaccination_links_vaccination_id_idx" ON "encounter_vaccination_links"("vaccination_id"); + +-- CreateIndex +CREATE INDEX "encounter_vaccination_links_user_id_idx" ON "encounter_vaccination_links"("user_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "encounter_vaccination_links_encounter_id_vaccination_id_key" ON "encounter_vaccination_links"("encounter_id", "vaccination_id"); + +-- CreateIndex +CREATE INDEX "medication_document_links_document_id_idx" ON "medication_document_links"("document_id"); + +-- CreateIndex +CREATE INDEX "medication_document_links_user_id_idx" ON "medication_document_links"("user_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "medication_document_links_medication_id_document_id_key" ON "medication_document_links"("medication_id", "document_id"); + +-- CreateIndex +CREATE INDEX "medication_condition_links_episode_id_idx" ON "medication_condition_links"("episode_id"); + +-- CreateIndex +CREATE INDEX "medication_condition_links_user_id_idx" ON "medication_condition_links"("user_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "medication_condition_links_medication_id_episode_id_key" ON "medication_condition_links"("medication_id", "episode_id"); + +-- AddForeignKey +ALTER TABLE "lab_result_document_links" ADD CONSTRAINT "lab_result_document_links_lab_result_id_fkey" FOREIGN KEY ("lab_result_id") REFERENCES "lab_results"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "lab_result_document_links" ADD CONSTRAINT "lab_result_document_links_document_id_fkey" FOREIGN KEY ("document_id") REFERENCES "inbound_documents"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "lab_result_document_links" ADD CONSTRAINT "lab_result_document_links_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "encounter_medication_links" ADD CONSTRAINT "encounter_medication_links_encounter_id_fkey" FOREIGN KEY ("encounter_id") REFERENCES "encounters"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "encounter_medication_links" ADD CONSTRAINT "encounter_medication_links_medication_id_fkey" FOREIGN KEY ("medication_id") REFERENCES "medications"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "encounter_medication_links" ADD CONSTRAINT "encounter_medication_links_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "encounter_vaccination_links" ADD CONSTRAINT "encounter_vaccination_links_encounter_id_fkey" FOREIGN KEY ("encounter_id") REFERENCES "encounters"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "encounter_vaccination_links" ADD CONSTRAINT "encounter_vaccination_links_vaccination_id_fkey" FOREIGN KEY ("vaccination_id") REFERENCES "vaccination_records"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "encounter_vaccination_links" ADD CONSTRAINT "encounter_vaccination_links_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "medication_document_links" ADD CONSTRAINT "medication_document_links_medication_id_fkey" FOREIGN KEY ("medication_id") REFERENCES "medications"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "medication_document_links" ADD CONSTRAINT "medication_document_links_document_id_fkey" FOREIGN KEY ("document_id") REFERENCES "inbound_documents"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "medication_document_links" ADD CONSTRAINT "medication_document_links_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "medication_condition_links" ADD CONSTRAINT "medication_condition_links_medication_id_fkey" FOREIGN KEY ("medication_id") REFERENCES "medications"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "medication_condition_links" ADD CONSTRAINT "medication_condition_links_episode_id_fkey" FOREIGN KEY ("episode_id") REFERENCES "illness_episodes"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "medication_condition_links" ADD CONSTRAINT "medication_condition_links_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index f78946ab2..6328b220d 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -94,9 +94,6 @@ model User { // 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") // v1.25 — effective-from instant for the home location. Stamped the moment a // home is set/updated. Day resolution attributes a past day to home ONLY when @@ -105,9 +102,8 @@ model User { // dated location period instead. Backfill defaults its start to this instant. homeSince DateTime? @map("home_since") /// v1.42 — sealed home location (lat, lon, label) - /// under AAD `healthlog/environment-location/v1`. Replaces the plaintext - /// `homeLat` / `homeLon` / `homeLabel` once the free-text encryption backfill - /// reports zero remaining rows; the plaintext columns drop in v1.43. + /// under AAD `healthlog/environment-location/v1`. The only copy since + /// v1.43: migration 0409 dropped the plaintext columns it replaced. homeLocationEncrypted Bytes? @map("home_location_encrypted") /// v1.42 — per-user switch for the air-quality and /// pollen part of the environment module. On by default; off means the @@ -237,6 +233,20 @@ model User { nightscoutTokenEncrypted String? @map("nightscout_token_encrypted") nightscoutAllowPrivateHost Boolean @default(false) @map("nightscout_allow_private_host") + // v1.43 — Dawarich movement context (per-user, encrypted at app level, part + // of the `environment` module). Base URL and API key sealed like the + // Nightscout pair; `dawarichPrefsEncrypted` seals + // `{ homePlaceId, homeCountry, dismissedSuggestionIds }`. Same SSRF floor: + // a private host needs `DAWARICH_PRIVATE_ORIGINS` or this switch. + /// @pending-consumer(#v143-B7) Sealed Dawarich base URL. + dawarichUrlEncrypted String? @map("dawarich_url_encrypted") + /// @pending-consumer(#v143-B7) Sealed Dawarich API key. + dawarichApiKeyEncrypted String? @map("dawarich_api_key_encrypted") + /// @pending-consumer(#v143-B7) Sealed home place id, home country and dismissed travel suggestions. + dawarichPrefsEncrypted Bytes? @map("dawarich_prefs_encrypted") + /// @pending-consumer(#v143-B7) Opt this Dawarich instance past the public-host requirement. + dawarichAllowPrivateHost Boolean @default(false) @map("dawarich_allow_private_host") + // v1.17.0 — Polar OAuth credentials (F4, per-user, encrypted at app level). // Mirrors the WHOOP credential columns: the per-app authorized-user cap on // Polar AccessLink makes a single shared app unworkable for a multi-operator @@ -612,6 +622,14 @@ model User { // `useAuth()` hook. disableCoach Boolean @default(false) @map("disable_coach") + /// @pending-consumer(#v143-B1) The account's own AI usage level (#1217). + /// NULL follows the instance default (`AppSettings.aiUsageDefaultLevel`); + /// a stored level above `AppSettings.aiUsageMaxLevel` is kept but capped + /// at read time. Written by `PUT /api/auth/me/ai-usage`, resolved in + /// `src/lib/ai/usage-level.ts` and published as `ai.usage` on + /// `GET /api/auth/me`. + aiUsageLevel AiUsageLevel? @map("ai_usage_level") + // v1.18.0 — module enable/disable foundation. // // A DISABLED allowlist for the optional ("secondary") domains. The @@ -1017,6 +1035,18 @@ model User { // move, a birth, a new job. Shared under the `profile` domain; never sent to // a model. lifeEvents LifeEvent[] + // v1.43 — biomarker aliases and the merge ledger (D1223), supplement + // composition, Dawarich daily values, and the relation links that close + // the remaining pairs of the relation matrix. + biomarkerAliases BiomarkerAlias[] + biomarkerMerges BiomarkerMerge[] + medicationIngredients MedicationIngredient[] + dawarichDays DawarichDay[] + labResultDocumentLinks LabResultDocumentLink[] + encounterMedicationLinks EncounterMedicationLink[] + encounterVaccinationLinks EncounterVaccinationLink[] + medicationDocumentLinks MedicationDocumentLink[] + medicationConditionLinks MedicationConditionLink[] measurementFoldRepair MeasurementFoldRepair? // v1.7.0 profile — optional patient-identity fields surfaced on the @@ -1120,20 +1150,9 @@ model EnvironmentContext { /// YYYY-MM-DD anchored to the resolved location's timezone. date String - /// Coarse resolved location for the day (lat/lon rounded to 1 decimal, about - /// 11 km, + label + the source that won precedence). Nullable since v1.42: - /// the sealed copy in `locationEncrypted` replaces the plaintext once the - /// encryption backfill has run; the plaintext columns drop in v1.43. - lat Float? - lon Float? - /// @internal: human-readable label of the resolved location, stored as - /// operator/debug provenance beside the coordinates; surfaces select the - /// numeric and weather columns only, never this label. The backup selects it - /// (`src/lib/export/environment-backup.ts`) with the rest of the row: a - /// restored reading has to keep saying where it was read. - locationLabel String? @map("location_label") - /// v1.42 — sealed (lat, lon, label) for the day, - /// AAD `healthlog/environment-location/v1`. + /// v1.42 — sealed coarse resolved location for the day (lat/lon rounded to + /// 1 decimal, about 11 km, + label), AAD `healthlog/environment-location/v1`. + /// The only copy since v1.43: migration 0409 dropped the plaintext columns. locationEncrypted Bytes? @map("location_encrypted") source EnvironmentLocationSource @@ -1219,8 +1238,7 @@ model EnvironmentContext { @@unique([userId, date]) @@index([userId, date]) // `environment_contexts_aq_gap_idx` (user_id, date) WHERE aq_fetched_at IS - // NULL and `environment_contexts_plain_location_idx` (id) WHERE lat IS NOT - // NULL are partial indexes from migrations 0381 / 0382 (raw SQL only). + // NULL is a partial index from migration 0381 (raw SQL only). @@map("environment_contexts") } @@ -1241,22 +1259,15 @@ model EnvironmentTravelLocation { startDate String @map("start_date") endDate String @map("end_date") - /// Plaintext coarse location. Nullable since v1.42: the sealed copy in - /// `locationEncrypted` replaces it once the encryption backfill has run; - /// the plaintext columns drop in v1.43. - lat Float? - lon Float? - label String? - /// v1.42 — sealed (lat, lon, label) for the - /// period, AAD `healthlog/environment-location/v1`. - locationEncrypted Bytes? @map("location_encrypted") + /// v1.42 — sealed coarse location (lat, lon, label) for the period, AAD + /// `healthlog/environment-location/v1`. The only copy since v1.43: + /// migration 0409 dropped the plaintext columns. + locationEncrypted Bytes? @map("location_encrypted") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") @@index([userId, startDate, endDate]) - // `environment_travel_locations_plain_location_idx` (id) WHERE lat IS NOT - // NULL is a partial index from migration 0382 (raw SQL only). @@map("environment_travel_locations") } @@ -1701,6 +1712,13 @@ enum AssessmentInstrument { GAD7 WHO5 SCI + /// v1.43 (#614) — AUDIT-C, the alcohol section of the life circumstances. + /// Stored here for the scoring, encryption and backup it shares with the + /// screeners, but never served by `/api/mental-health/assessments` (a + /// closed decoder in older app builds): read through + /// `GET /api/anamnesis/life-circumstances`, written through + /// `POST /api/anamnesis/audit-c`. + AUDIT_C @@map("assessment_instrument") } @@ -2316,6 +2334,17 @@ model IntradayCumulativeProfile { /// two rows is not a shape; the baseline reader uses this to refuse such /// a day rather than let it drag the median. sampleCount Int @map("sample_count") + /// @pending-consumer(#v143-B9) v1.43 (#77) — per-hour increments posted as + /// `stats::T` rows, 24 slots in canonical units. Empty + /// for a profile folded from per-sample rows. + hourlyIncrements Float[] @map("hourly_increments") + /// @pending-consumer(#v143-B9) What `hourlyCumulative` was built from: + /// `PER_SAMPLE` (the fold of raw rows) or `HOUR_BUCKETS` (the posted hour + /// rows). CHECK in migration 0408. + basis String @default("PER_SAMPLE") + /// @pending-consumer(#v143-B9) How many hour buckets backed a + /// `HOUR_BUCKETS` profile; NULL for `PER_SAMPLE`. + bucketCount Int? @map("bucket_count") /// The IANA zone the day was bucketed in. A user who later moves zones /// leaves behind profiles cut on the old clock; carrying the zone lets /// the reader drop the mismatched days instead of silently comparing @@ -2898,6 +2927,12 @@ model Medication { schedules MedicationSchedule[] intakeEvents MedicationIntakeEvent[] + /// v1.43 — composition per unit (supplements), and the relation links to + /// visits, documents and conditions (indication). + ingredients MedicationIngredient[] + encounterLinks EncounterMedicationLink[] + documentLinks MedicationDocumentLink[] + conditionLinks MedicationConditionLink[] intakeImportJobs MedicationIntakeImportJob[] phaseConfig ReminderPhaseConfig? telegramMessages TelegramReminderMessage[] @@ -5227,6 +5262,19 @@ model AppSettings { assistantInsightStatusEnabled Boolean @default(true) @map("assistant_insight_status_enabled") assistantDocumentAiEnabled Boolean @default(true) @map("assistant_document_ai_enabled") + // v1.43 — the operator's AI usage bounds (#1217). `aiUsageMaxLevel` is the + // highest level an account may choose, `aiUsageDefaultLevel` the level an + // account without its own choice runs at (never above the ceiling), + // `aiSyncRegenGapHours` the minimum gap between status-card regenerations + // after a sync at `standard` (NULL = env `AI_SYNC_REGEN_GAP_HOURS`, else 6). + // Edited through `PUT /api/admin/settings/assistant-flags` (`aiUsage`). + /// @pending-consumer(#v143-B1) Instance ceiling for the per-account AI usage level. + aiUsageMaxLevel AiUsageLevel @default(STANDARD) @map("ai_usage_max_level") + /// @pending-consumer(#v143-B1) Level for accounts without their own choice. + aiUsageDefaultLevel AiUsageLevel @default(STANDARD) @map("ai_usage_default_level") + /// @pending-consumer(#v143-B1) Sync regeneration gap in hours (1..48); NULL = env or 6. + aiSyncRegenGapHours Int? @map("ai_sync_regen_gap_hours") + // v1.41 — the operator's reasoning controls. `aiReasoningEnabled` switches // provider-side reasoning off for every surface (the Coach and the three // background jobs that use it); `aiReasoningMaxEffort` caps the level a @@ -5283,6 +5331,17 @@ model AppSettings { /// finding of the earlier engine and so earns the one-time note. correlationSeasonalEngineSince DateTime? @map("correlation_seasonal_engine_since") + // v1.43 — pulse day-mean method (#1107 follow-up). `hour_mean_v1` weighs + // every hour alike; `weighted_hour_mean_v2` weighs an hour by + // `min(1, n / 3)`. Switched only by the admin apply step after a dry run; + // the dry-run result is the only thing a dry run writes. + /// @pending-consumer(#v143-B8) Active pulse day-mean method (CHECK in migration 0407). + pulseDayMeanMethod String @default("hour_mean_v1") @map("pulse_day_mean_method") + /// @pending-consumer(#v143-B8) When the admin applied the current method. + pulseDayMeanAppliedAt DateTime? @map("pulse_day_mean_applied_at") + /// @pending-consumer(#v143-B8) Last dry-run status and result (account labels only, no ids). + pulseDayMeanDryRunJson Json? @map("pulse_day_mean_dry_run_json") + @@map("app_settings") } @@ -6250,9 +6309,10 @@ model LabResult { /// Soft-delete tombstone. NULL = live; non-NULL = deleted at the instant. deletedAt DateTime? @map("deleted_at") - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - biomarker Biomarker? @relation(fields: [biomarkerId], references: [id], onDelete: SetNull) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + biomarker Biomarker? @relation(fields: [biomarkerId], references: [id], onDelete: SetNull) encounterLinks EncounterLabLink[] + documentLinks LabResultDocumentLink[] @@index([userId, analyte, takenAt]) @@index([userId, deletedAt]) @@ -6309,6 +6369,10 @@ model Biomarker { /// v1.28 — medications the user has pinned to this biomarker as their /// efficacy target. SetNull on delete keeps the override rows as orphans. efficacyTargets MedicationEfficacyTarget[] + /// v1.43 — other spellings that resolve to this marker, and the merges that + /// folded other markers into it. + aliases BiomarkerAlias[] + mergesInto BiomarkerMerge[] @@unique([userId, name]) @@index([userId]) @@ -6962,6 +7026,12 @@ model UserHealthProfile { emergencyContactsEncrypted Bytes? @map("emergency_contacts_encrypted") emergencyImplantsEncrypted Bytes? @map("emergency_implants_encrypted") emergencyNoteEncrypted Bytes? @map("emergency_note_encrypted") + /// @pending-consumer(#v143-B6) v1.43 (#614) — "still true" confirmations of the life + /// circumstance sections: `{ "
": "" }`. Written by + /// `POST /api/anamnesis/life-circumstances/confirm`, read as + /// `lastConfirmedAt` / `reviewDue` by the life-circumstances read route. + /// Plaintext: section keys and timestamps only, never an answer. + lifeConfirmedJson Json? @map("life_confirmed_json") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") @@ -7006,6 +7076,18 @@ enum AdvanceDirectiveStatus { @@map("advance_directive_status") } +/// v1.43 — how much background AI work an account asks for (#1217): status +/// card regeneration cadence, snapshot ceiling, background reasoning, briefing +/// length and automatic document reading. Wire form is lower case +/// (`frugal` / `standard` / `full`); the order is the ceiling order. +enum AiUsageLevel { + FRUGAL + STANDARD + FULL + + @@map("ai_usage_level") +} + /// Sections of the health profile that may enter AI prompt context. This is an /// inclusion list: a section absent from the array is neither decrypted nor /// assembled for Coach or comprehensive briefings. @@ -7018,6 +7100,14 @@ enum HealthProfileAiSection { SMOKING_STATUS ALCOHOL_PATTERN SHIFT_SCHEDULE + /// v1.43 (#614) — life-circumstance sections. Never in the column default + /// and never listed by `GET /api/coach/about-me` (a closed decoder in older + /// app builds); switched through + /// `PATCH /api/anamnesis/life-circumstances/ai-sections`. + ACTIVITY + WORK + HABITS + GOALS @@map("health_profile_ai_section") } @@ -7029,6 +7119,16 @@ enum HealthProfileFactKind { SMOKING_STATUS ALCOHOL_PATTERN SHIFT_SCHEDULE + /// v1.43 (#614) — life-circumstance facts. The value is a sealed JSON object + /// with `schema: 1`, one Zod schema per kind. `GET /api/anamnesis/facts` + /// never returns these (a closed decoder in older app builds); they are read + /// through `GET /api/anamnesis/life-circumstances`. + TOBACCO_DETAIL + ACTIVITY_HABIT + WORK_CONTEXT + FREE_WEEKDAYS + HABITS + HEALTH_GOALS @@map("health_profile_fact_kind") } @@ -8175,15 +8275,16 @@ model IllnessEpisode { /// Soft-delete tombstone. NULL = live; non-NULL = deleted at the instant. deletedAt DateTime? @map("deleted_at") - user User @relation(fields: [userId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) /// Parent condition for a flare/recurrence (self-relation). - parent IllnessEpisode? @relation("IllnessFlares", fields: [parentConditionId], references: [id], onDelete: SetNull) - flares IllnessEpisode[] @relation("IllnessFlares") - dayLogs IllnessDayLog[] - documentLinks DocumentConditionLink[] - encounterLinks EncounterConditionLink[] + parent IllnessEpisode? @relation("IllnessFlares", fields: [parentConditionId], references: [id], onDelete: SetNull) + flares IllnessEpisode[] @relation("IllnessFlares") + dayLogs IllnessDayLog[] + documentLinks DocumentConditionLink[] + encounterLinks EncounterConditionLink[] + medicationLinks MedicationConditionLink[] /// Symptom events the person filed against this episode while it was open. - symptomEvents SymptomEvent[] + symptomEvents SymptomEvent[] @@index([userId, deletedAt]) @@index([userId, onsetAt]) @@ -8762,6 +8863,9 @@ model InboundDocument { user User @relation(fields: [userId], references: [id], onDelete: Cascade) encounterLinks EncounterDocumentLink[] + /// v1.43 — lab results and medications filed against this document. + labResultLinks LabResultDocumentLink[] + medicationLinks MedicationDocumentLink[] /// Vaccination memberships (m:n via `VaccinationDocumentLink`): a scanned /// Impfpass page carries many doses, and a dose may be evidenced by more /// than one page. @@ -9215,15 +9319,17 @@ model Encounter { /// Soft-delete tombstone. NULL = live; non-NULL = deleted at the instant. deletedAt DateTime? @map("deleted_at") - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - practitioner Practitioner? @relation(fields: [practitionerId], references: [id], onDelete: SetNull) - reminder MeasurementReminder? @relation(fields: [reminderId], references: [id], onDelete: SetNull) - documentLinks EncounterDocumentLink[] - labLinks EncounterLabLink[] - conditionLinks EncounterConditionLink[] + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + practitioner Practitioner? @relation(fields: [practitionerId], references: [id], onDelete: SetNull) + reminder MeasurementReminder? @relation(fields: [reminderId], references: [id], onDelete: SetNull) + documentLinks EncounterDocumentLink[] + labLinks EncounterLabLink[] + conditionLinks EncounterConditionLink[] + medicationLinks EncounterMedicationLink[] + vaccinationLinks EncounterVaccinationLink[] /// Doses administered at this visit. SetNull on the far side: deleting the /// visit leaves the immunization history intact. - vaccinations VaccinationRecord[] + vaccinations VaccinationRecord[] @@index([userId, deletedAt, occurredAt]) @@index([userId, status, occurredAt]) @@ -9477,12 +9583,13 @@ model VaccinationRecord { /// Soft-delete tombstone. NULL = live; non-NULL = deleted at the instant. deletedAt DateTime? @map("deleted_at") - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - practitioner Practitioner? @relation(fields: [practitionerId], references: [id], onDelete: SetNull) - encounter Encounter? @relation(fields: [encounterId], references: [id], onDelete: SetNull) - reminder MeasurementReminder? @relation(fields: [reminderId], references: [id], onDelete: SetNull) - customVaccine CustomVaccine? @relation(fields: [customVaccineId], references: [id], onDelete: SetNull) - documentLinks VaccinationDocumentLink[] + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + practitioner Practitioner? @relation(fields: [practitionerId], references: [id], onDelete: SetNull) + encounter Encounter? @relation(fields: [encounterId], references: [id], onDelete: SetNull) + reminder MeasurementReminder? @relation(fields: [reminderId], references: [id], onDelete: SetNull) + customVaccine CustomVaccine? @relation(fields: [customVaccineId], references: [id], onDelete: SetNull) + documentLinks VaccinationDocumentLink[] + encounterLinks EncounterVaccinationLink[] @@index([userId, deletedAt, occurredAt]) @@index([userId, antigenSlug, occurredAt]) @@ -9635,6 +9742,9 @@ enum DocumentAiRunKind { /// Structured facts read out of a stored document, staged PENDING for the /// review step. The confirm route stays the only write into the record. DOCUMENT_EXTRACT + /// v1.43 — a supplement label read into a proposed composition. Nothing is + /// stored until the person saves it through the ingredients route. + SUPPLEMENT_LABEL_EXTRACT @@map("document_ai_run_kind") } @@ -9784,3 +9894,209 @@ model MeasurementFoldRepair { @@map("measurement_fold_repairs") } + +// ─── v1.43 contract tables ─────────────────────────────────── + +/// v1.43 (D1223) — another spelling of a biomarker on one account: a name a +/// merge folded in, a name chosen at import, or one typed on the marker. The +/// resolver (`src/lib/labs/biomarker-resolve.ts`) matches a printed name +/// against `nameFolded` after the marker name and before the catalogue. +model BiomarkerAlias { + id String @id @default(cuid()) + userId String @map("user_id") + biomarkerId String @map("biomarker_id") + /// @pending-consumer(#v143-B3) The alias as written, shown on the marker. + name String @db.VarChar(120) + /// @pending-consumer(#v143-B3) `foldForMatch(name)`, the match key; unique per account. + nameFolded String @map("name_folded") @db.VarChar(120) + /// @pending-consumer(#v143-B3) `MERGE`, `IMPORT_CHOICE` or `MANUAL` (CHECK in migration 0403). + source String + createdAt DateTime @default(now()) @map("created_at") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + biomarker Biomarker @relation(fields: [biomarkerId], references: [id], onDelete: Cascade) + + @@unique([userId, nameFolded]) + @@index([biomarkerId]) + @@map("biomarker_aliases") +} + +/// v1.43 (D1223) — one source marker folded into a target. The source row is +/// gone after the merge; this ledger is what answers `GET /api/biomarkers/{id}` +/// for a merged id with `meta.mergedInto`, and what a later split would need. +model BiomarkerMerge { + id String @id @default(cuid()) + userId String @map("user_id") + /// @pending-consumer(#v143-B3) The marker the source was folded into. + targetBiomarkerId String @map("target_biomarker_id") + /// @pending-consumer(#v143-B3) Id of the deleted source marker (no FK: the row is gone). + sourceBiomarkerId String @map("source_biomarker_id") + /// @pending-consumer(#v143-B3) The source marker's name at merge time. + sourceName String @map("source_name") @db.VarChar(120) + /// @pending-consumer(#v143-B3) How many readings moved from the source. + readingCount Int @map("reading_count") + /// @pending-consumer(#v143-B3) When the merge ran. + mergedAt DateTime @default(now()) @map("merged_at") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + target Biomarker @relation(fields: [targetBiomarkerId], references: [id], onDelete: Cascade) + + @@index([userId, sourceBiomarkerId]) + @@index([targetBiomarkerId]) + @@map("biomarker_merges") +} + +/// v1.43 — what one unit (tablet, capsule) of a medication contains, in the +/// nutrient catalogue's canonical unit. Every taken dose contributes +/// `amountPerUnit × unitsPerDose` to that day's `NutrientIntakeDay` row with +/// `source = "SUPPLEMENT"`. A changed formula is a new medication. +model MedicationIngredient { + id String @id @default(cuid()) + userId String @map("user_id") + medicationId String @map("medication_id") + /// @pending-consumer(#v143-B4) Nutrient catalogue code (`src/lib/nutrients/catalog.ts`). + nutrient String @db.VarChar(40) + /// @pending-consumer(#v143-B4) Amount per unit in the canonical unit (> 0, CHECK in migration 0404). + amountPerUnit Float @map("amount_per_unit") + /// @pending-consumer(#v143-B4) The catalogue's canonical unit for `nutrient`. + unit String @db.VarChar(8) + /// @pending-consumer(#v143-B4) The amount as entered, when it was entered in another input unit. + enteredAmount Float? @map("entered_amount") + /// @pending-consumer(#v143-B4) The input unit it was entered in (e.g. `IU`). + enteredUnit String? @map("entered_unit") @db.VarChar(8) + /// @pending-consumer(#v143-B4) `MANUAL` or `LABEL_AI` (CHECK in migration 0404). + source String + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + medication Medication @relation(fields: [medicationId], references: [id], onDelete: Cascade) + + @@unique([medicationId, nutrient]) + @@index([userId]) + @@map("medication_ingredients") +} + +/// v1.43 — one local day of Dawarich movement context. Daily values only: +/// no coordinate, no place name, no visit is ever stored. The country code is +/// sealed (AAD as the environment location). Part of the `environment` module. +model DawarichDay { + id String @id @default(cuid()) + userId String @map("user_id") + /// Local calendar day (`YYYY-MM-DD`) in the account's timezone. + date String @db.VarChar(10) + /// @pending-consumer(#v143-B7) Timeline `summary.total_distance`, metres. + totalDistanceM Int? @map("total_distance_m") + /// @pending-consumer(#v143-B7) Timeline `summary.mode_distances.driving`, metres. + drivingDistanceM Int? @map("driving_distance_m") + /// @pending-consumer(#v143-B7) Timeline `summary.mode_distances.train`, metres. + trainDistanceM Int? @map("train_distance_m") + /// @pending-consumer(#v143-B7) Timeline `summary.places_visited`. + placesVisited Int? @map("places_visited") + /// @pending-consumer(#v143-B7) Minutes away from the home place; NULL = no visit coverage that day. + awayFromHomeMinutes Int? @map("away_from_home_minutes") + /// @pending-consumer(#v143-B7) Sealed ISO-3166 alpha-2 country of the day (`/residency`). + countryCodeEncrypted Bytes? @map("country_code_encrypted") + /// @pending-consumer(#v143-B7) Country differs from the home country. + isTravelDay Boolean? @map("is_travel_day") + /// @pending-consumer(#v143-B7) When the day was last fetched from Dawarich. + fetchedAt DateTime @map("fetched_at") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([userId, date]) + @@map("dawarich_days") +} + +/// v1.43 — lab result ↔ document (the report it was read from). A scan commit +/// with a `documentId` files the link itself. +model LabResultDocumentLink { + id String @id @default(cuid()) + labResultId String @map("lab_result_id") + documentId String @map("document_id") + userId String @map("user_id") + createdAt DateTime @default(now()) @map("created_at") + + labResult LabResult @relation(fields: [labResultId], references: [id], onDelete: Cascade) + document InboundDocument @relation(fields: [documentId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([labResultId, documentId]) + @@index([documentId]) + @@index([userId]) + @@map("lab_result_document_links") +} + +/// v1.43 — visit ↔ medication (prescribed, changed or reviewed at the visit). +model EncounterMedicationLink { + id String @id @default(cuid()) + encounterId String @map("encounter_id") + medicationId String @map("medication_id") + userId String @map("user_id") + createdAt DateTime @default(now()) @map("created_at") + + encounter Encounter @relation(fields: [encounterId], references: [id], onDelete: Cascade) + medication Medication @relation(fields: [medicationId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([encounterId, medicationId]) + @@index([medicationId]) + @@index([userId]) + @@map("encounter_medication_links") +} + +/// v1.43 — visit ↔ vaccination dose (given at the visit). +model EncounterVaccinationLink { + id String @id @default(cuid()) + encounterId String @map("encounter_id") + vaccinationId String @map("vaccination_id") + userId String @map("user_id") + createdAt DateTime @default(now()) @map("created_at") + + encounter Encounter @relation(fields: [encounterId], references: [id], onDelete: Cascade) + vaccination VaccinationRecord @relation(fields: [vaccinationId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([encounterId, vaccinationId]) + @@index([vaccinationId]) + @@index([userId]) + @@map("encounter_vaccination_links") +} + +/// v1.43 — medication ↔ document (a prescription, a package insert, a plan). +model MedicationDocumentLink { + id String @id @default(cuid()) + medicationId String @map("medication_id") + documentId String @map("document_id") + userId String @map("user_id") + createdAt DateTime @default(now()) @map("created_at") + + medication Medication @relation(fields: [medicationId], references: [id], onDelete: Cascade) + document InboundDocument @relation(fields: [documentId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([medicationId, documentId]) + @@index([documentId]) + @@index([userId]) + @@map("medication_document_links") +} + +/// v1.43 — medication ↔ condition episode (the indication). +model MedicationConditionLink { + id String @id @default(cuid()) + medicationId String @map("medication_id") + episodeId String @map("episode_id") + userId String @map("user_id") + createdAt DateTime @default(now()) @map("created_at") + + medication Medication @relation(fields: [medicationId], references: [id], onDelete: Cascade) + episode IllnessEpisode @relation(fields: [episodeId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([medicationId, episodeId]) + @@index([episodeId]) + @@index([userId]) + @@map("medication_condition_links") +} diff --git a/scripts/rotate-encryption-key.ts b/scripts/rotate-encryption-key.ts index acbab8c25..99f5f9f3b 100644 --- a/scripts/rotate-encryption-key.ts +++ b/scripts/rotate-encryption-key.ts @@ -306,6 +306,8 @@ async function main() { "googleHealthClientSecretEncrypted", "nightscoutUrlEncrypted", "nightscoutTokenEncrypted", + "dawarichUrlEncrypted", + "dawarichApiKeyEncrypted", "polarAccessTokenEncrypted", "polarUserIdEncrypted", "polarClientIdEncrypted", @@ -825,8 +827,8 @@ async function main() { // ───── v1.42 environment locations (Bytes, binary2, batched) ───── // The sealed home, each dated location period and each day's resolved - // location, all under the environment-location label. NULL on a row the - // encryption backfill has not reached yet, which the walk skips. + // location, all under the environment-location label. NULL on a row + // without a location, which the walk skips. results.push( await rotateRegistryColumn("User", "homeLocationEncrypted", { user: prisma.user, @@ -847,6 +849,20 @@ async function main() { } as unknown as CorpusClient), ); + // ───── v1.43 Dawarich context (Bytes, binary2, batched) ───── + // The sealed connection preferences on the user row and each stored day's + // country code, each under its own label. NULL rows are skipped. + results.push( + await rotateRegistryColumn("User", "dawarichPrefsEncrypted", { + user: prisma.user, + } as unknown as CorpusClient), + ); + results.push( + await rotateRegistryColumn("DawarichDay", "countryCodeEncrypted", { + dawarichDay: prisma.dawarichDay, + } as unknown as CorpusClient), + ); + // ───── v1.39.3 custom-metric reading note (Bytes column) ───── // NULL on a reading without a note, and on a row the free-text backfill has // not reached yet; `rotateBytesColumn` skips both. diff --git a/scripts/seed-demo.ts b/scripts/seed-demo.ts index ae3061820..6e13de3b0 100644 --- a/scripts/seed-demo.ts +++ b/scripts/seed-demo.ts @@ -499,13 +499,7 @@ async function seed() { // start of a chart together. Every other tile stays one switch away in // the layout settings; its data is seeded either way. Writing the layout // verbatim keeps the demo's home page stable across default tweaks. - const SHOWCASE_TILE_IDS = new Set([ - "weight", - "bp", - "pulse", - "hrv", - "mood", - ]); + const SHOWCASE_TILE_IDS = new Set(["weight", "bp", "pulse", "hrv", "mood"]); const demoDashboardLayout = serializeDashboardLayout({ ...DEFAULT_DASHBOARD_LAYOUT, widgets: DEFAULT_DASHBOARD_LAYOUT.widgets.map((w) => ({ @@ -2798,12 +2792,11 @@ async function seed() { } // The timeline module is opt-in; the demo shows it. The home location - // is sealed the way the settings route writes it (readable columns null). + // is sealed the way the settings route writes it. const homeSince = daysAgoAt(HISTORY_DAYS, 12); await client.query( `UPDATE users SET module_preferences_json = $2, - home_lat = NULL, home_lon = NULL, home_label = NULL, home_location_encrypted = $3, home_timezone = 'Europe/Berlin', home_since = $4, @@ -2913,9 +2906,6 @@ async function seed() { cuid(), userId, key, - null, - null, - null, away ? sealedHoliday : sealedHome, away ? "TRAVEL" : "HOME", tempMin, @@ -2964,9 +2954,6 @@ async function seed() { "id", "user_id", "date", - "lat", - "lon", - "location_label", "location_encrypted", "source", "temp_min", diff --git a/src/__tests__/environment-backup-completeness.test.ts b/src/__tests__/environment-backup-completeness.test.ts index fbc6f5933..725b93225 100644 --- a/src/__tests__/environment-backup-completeness.test.ts +++ b/src/__tests__/environment-backup-completeness.test.ts @@ -43,13 +43,7 @@ const EXCLUDED: Record = { }; /** Columns written through the location helper rather than `row.`. */ -const LOCATION_COLUMNS = new Set([ - "lat", - "lon", - "locationLabel", - "label", - "locationEncrypted", -]); +const LOCATION_COLUMNS = new Set(["locationEncrypted"]); function block(text: string, start: string, end = "\n}"): string { const from = text.indexOf(start); @@ -95,9 +89,10 @@ describe.each([ const selected = selectKeys(selectName); it("parses both ends into a plausible shape", () => { - expect(columns.length).toBeGreaterThan(8); + // A period is seven columns since v1.43 (the readable location dropped). + expect(columns.length).toBeGreaterThan(6); expect(columns).toContain("locationEncrypted"); - expect(selected.length).toBeGreaterThan(5); + expect(selected.length).toBeGreaterThan(4); }); it("the backup select covers every column", () => { diff --git a/src/app/api/environment/backfill/__tests__/route.test.ts b/src/app/api/environment/backfill/__tests__/route.test.ts index 2d1cea45f..c50ee2e94 100644 --- a/src/app/api/environment/backfill/__tests__/route.test.ts +++ b/src/app/api/environment/backfill/__tests__/route.test.ts @@ -61,7 +61,6 @@ vi.mock("@/lib/db", () => ({ prisma: { user: { findUnique: vi.fn(async () => ({ - homeLat: null, homeLocationEncrypted: new Uint8Array([1]), homeSince: new Date("2025-01-01T00:00:00Z"), })), diff --git a/src/app/api/environment/backfill/route.ts b/src/app/api/environment/backfill/route.ts index 690a338ea..8e809b193 100644 --- a/src/app/api/environment/backfill/route.ts +++ b/src/app/api/environment/backfill/route.ts @@ -63,11 +63,9 @@ export const POST = apiHandler(async (request: NextRequest) => { const profile = await prisma.user.findUnique({ where: { id: user.id }, - select: { homeLat: true, homeLocationEncrypted: true, homeSince: true }, + select: { homeLocationEncrypted: true, homeSince: true }, }); - // The sealed home (v1.42), or the readable one the encryption backfill has - // not cleared yet. - if (profile?.homeLocationEncrypted == null && profile?.homeLat == null) { + if (profile?.homeLocationEncrypted == null) { return apiError("Set a home location before backfilling.", 409, { errorCode: "environment.no_home", }); diff --git a/src/app/api/environment/home/route.ts b/src/app/api/environment/home/route.ts index 3c6d554ce..82cad0d8f 100644 --- a/src/app/api/environment/home/route.ts +++ b/src/app/api/environment/home/route.ts @@ -57,9 +57,6 @@ export const PUT = apiHandler(async (request: NextRequest) => { where: { id: user.id }, data: { homeLocationEncrypted: sealLocation(home), - homeLat: null, - homeLon: null, - homeLabel: null, homeTimezone: timezone, homeSince, }, diff --git a/src/app/api/environment/route.ts b/src/app/api/environment/route.ts index cb75a7260..9aa424290 100644 --- a/src/app/api/environment/route.ts +++ b/src/app/api/environment/route.ts @@ -48,9 +48,6 @@ export const GET = apiHandler(async () => { prisma.user.findUnique({ where: { id: user.id }, select: { - homeLat: true, - homeLon: true, - homeLabel: true, homeLocationEncrypted: true, homeTimezone: true, homeSince: true, @@ -66,9 +63,6 @@ export const GET = apiHandler(async () => { id: true, startDate: true, endDate: true, - lat: true, - lon: true, - label: true, locationEncrypted: true, }, }), @@ -116,14 +110,7 @@ export const GET = apiHandler(async () => { ]); const homeLocation = profile - ? readLocation({ - sealed: profile.homeLocationEncrypted, - lat: profile.homeLat, - lon: profile.homeLon, - // The label may be absent on a pre-v1.42 home; the answer has - // always allowed a null label there. - label: profile.homeLabel ?? "", - }) + ? readLocation(profile.homeLocationEncrypted) : null; const home = profile && homeLocation @@ -139,12 +126,7 @@ export const GET = apiHandler(async () => { : null; const travel = travelRows.flatMap((row) => { - const location = readLocation({ - sealed: row.locationEncrypted, - lat: row.lat, - lon: row.lon, - label: row.label, - }); + const location = readLocation(row.locationEncrypted); return location ? [ { diff --git a/src/app/api/environment/travel/route.ts b/src/app/api/environment/travel/route.ts index 0f38eda59..9510a740b 100644 --- a/src/app/api/environment/travel/route.ts +++ b/src/app/api/environment/travel/route.ts @@ -57,9 +57,6 @@ export const POST = apiHandler(async (request: NextRequest) => { startDate: entry.startDate, endDate: entry.endDate, locationEncrypted: sealLocation(location), - lat: null, - lon: null, - label: null, }, select: { id: true, startDate: true, endDate: true }, }); diff --git a/src/lib/ai/coach/tools/__tests__/environment-read.test.ts b/src/lib/ai/coach/tools/__tests__/environment-read.test.ts index 3ce7e5f6c..f8c8c5e6e 100644 --- a/src/lib/ai/coach/tools/__tests__/environment-read.test.ts +++ b/src/lib/ai/coach/tools/__tests__/environment-read.test.ts @@ -53,7 +53,8 @@ function storedDay(date: string, extra: Record = {}) { return { date, source: "HOME", - // A row as the database might still hold it; none of this may leave. + // A row carrying location-shaped fields (a mocked row can, the table no + // longer does); none of this may leave. lat: 51.5, lon: 7.2, locationLabel: "Bochum, Germany", diff --git a/src/lib/analytics/score/reader.ts b/src/lib/analytics/score/reader.ts index eae8b85a8..3eb4aeb08 100644 --- a/src/lib/analytics/score/reader.ts +++ b/src/lib/analytics/score/reader.ts @@ -43,6 +43,10 @@ import { computeWeightGoal } from "./weight-goal"; import { DAY_MS, uniqueSources } from "./shared"; import type { DerivedProvenanceSource } from "@/lib/insights/derived/types"; import type { GlucoseUnit } from "@/lib/glucose"; +import { + SCREENER_INSTRUMENTS, + isScreenerInstrument, +} from "@/lib/validations/mental-health"; const HISTORY_OFFSET_DAYS = 14; const SCORE_READ_DAYS = 365 + HISTORY_OFFSET_DAYS; @@ -687,20 +691,32 @@ export async function computeUserHealthScore( ), input.modules.mentalHealth ? capture( - db.mentalHealthAssessment.findMany({ - where: { - userId: input.userId, - takenAt: { gte: since }, - deletedAt: null, - }, - orderBy: { takenAt: "asc" }, - select: { - instrument: true, - totalScore: true, - item9Flagged: true, - takenAt: true, - }, - }), + db.mentalHealthAssessment + .findMany({ + where: { + userId: input.userId, + takenAt: { gte: since }, + deletedAt: null, + // The wellbeing screeners only. AUDIT-C shares the table for + // its storage, but it belongs to the life circumstances, not + // to this pillar. + instrument: { in: [...SCREENER_INSTRUMENTS] }, + }, + orderBy: { takenAt: "asc" }, + select: { + instrument: true, + totalScore: true, + item9Flagged: true, + takenAt: true, + }, + }) + .then((rows) => + rows.flatMap((row) => + isScreenerInstrument(row.instrument) + ? [{ ...row, instrument: row.instrument }] + : [], + ), + ), [], "WELLBEING", ) diff --git a/src/lib/crypto/encrypted-columns.ts b/src/lib/crypto/encrypted-columns.ts index 93c5ae7a8..6869e47b6 100644 --- a/src/lib/crypto/encrypted-columns.ts +++ b/src/lib/crypto/encrypted-columns.ts @@ -68,6 +68,16 @@ export const DOCUMENT_AI_RUN_RESULT_AAD = "healthlog/document-ai-run-result/v1"; */ export const ENVIRONMENT_LOCATION_AAD = "healthlog/environment-location/v1"; +/** + * The associated-data labels for the Dawarich context (v1.43): the sealed + * connection preferences on the user row (home place id, home country, + * dismissed travel suggestions) and each stored day's country code. Labelled + * like the environment locations, each with its own label so a country code + * never opens as a location or as the preferences. + */ +export const DAWARICH_PREFS_AAD = "healthlog/dawarich-prefs/v1"; +export const DAWARICH_DAY_COUNTRY_AAD = "healthlog/dawarich-day-country/v1"; + export interface EncryptedColumn { /** Prisma model name (PascalCase, as declared in schema.prisma). */ readonly model: string; @@ -149,6 +159,9 @@ export const ENCRYPTED_COLUMNS: readonly EncryptedColumn[] = [ { model: "User", field: "googleHealthClientSecretEncrypted", kind: "string" }, { model: "User", field: "nightscoutUrlEncrypted", kind: "string" }, { model: "User", field: "nightscoutTokenEncrypted", kind: "string" }, + // v1.43 — Dawarich connection (URL and API key, like the Nightscout pair). + { model: "User", field: "dawarichUrlEncrypted", kind: "string" }, + { model: "User", field: "dawarichApiKeyEncrypted", kind: "string" }, { model: "User", field: "polarAccessTokenEncrypted", kind: "string" }, { model: "User", field: "polarUserIdEncrypted", kind: "string" }, { model: "User", field: "polarClientIdEncrypted", kind: "string" }, @@ -408,8 +421,8 @@ export const ENCRYPTED_COLUMNS: readonly EncryptedColumn[] = [ // ───── v1.42 environment locations (Bytes, binary2) ───── // The coarse home, each dated location period, and each day's resolved - // location, sealed as (lat, lon, label) under one label. The plaintext - // columns beside them empty as the encryption backfill runs. + // location, sealed as (lat, lon, label) under one label. The only copy since + // v1.43 (migration 0409 dropped the plaintext columns). { model: "User", field: "homeLocationEncrypted", @@ -432,6 +445,25 @@ export const ENCRYPTED_COLUMNS: readonly EncryptedColumn[] = [ aad: ENVIRONMENT_LOCATION_AAD, }, + // ───── v1.43 Dawarich context (Bytes, binary2) ───── + // The connection preferences (a place id on the Dawarich instance, the home + // country, dismissed travel suggestions) and each day's country code. Daily + // values only; no coordinate or place name is ever stored. + { + model: "User", + field: "dawarichPrefsEncrypted", + kind: "bytes", + codec: "binary2", + aad: DAWARICH_PREFS_AAD, + }, + { + model: "DawarichDay", + field: "countryCodeEncrypted", + kind: "bytes", + codec: "binary2", + aad: DAWARICH_DAY_COUNTRY_AAD, + }, + // ───── v1.38.0 dose free text (Bytes column) ───── // What the person wrote about the dose — a reaction, a sore arm, why the // schedule went the way it did. The Pass's own structured fields stay @@ -583,6 +615,7 @@ export const UPDATED_AT_MODELS: ReadonlySet = new Set([ "CoachPlan", "CoachReminder", "CycleDayLog", + "DawarichDay", "DocumentContentIndex", "DocumentSourceConnection", "DocumentThumbnail", diff --git a/src/lib/data-wipe/wipe-plan.ts b/src/lib/data-wipe/wipe-plan.ts index a247fd823..debdb9d14 100644 --- a/src/lib/data-wipe/wipe-plan.ts +++ b/src/lib/data-wipe/wipe-plan.ts @@ -81,6 +81,9 @@ export const WIPE_MODELS = [ "MedicationCategoryLabel", // The courses (v1.40, #1024), before the medications they cascade from. "MedicationCourse", + // v1.43 — what one unit of a supplement contains. Cascades from the + // medication; named before it so the count reads true. + "MedicationIngredient", // ── Mood ──────────────────────────────────────────────────────────────── // The day context, before the entry it hangs off: it cascades either way, @@ -104,9 +107,18 @@ export const WIPE_MODELS = [ // are deleted before any of those, or their per-model counts read zero on an // audit row that claims to say what was removed. The encounter follows its // own links; the address book it points at is a parent and goes last. + // v1.43 adds the remaining relation links (a lab result's document, a + // visit's medications and doses, a medication's documents and conditions); + // they cascade from both ends the same way, so they go first for the same + // reason. "EncounterDocumentLink", "EncounterLabLink", "EncounterConditionLink", + "EncounterMedicationLink", + "EncounterVaccinationLink", + "LabResultDocumentLink", + "MedicationDocumentLink", + "MedicationConditionLink", "Encounter", // ── Vaccinations ──────────────────────────────────────────────────────── @@ -124,6 +136,10 @@ export const WIPE_MODELS = [ // ── Clinical record ───────────────────────────────────────────────────── "MentalHealthAssessment", "LabResult", + // v1.43 — a marker's other spellings and the merges folded into it. Both + // cascade from the marker; named before it so their counts read true. + "BiomarkerAlias", + "BiomarkerMerge", "Biomarker", "CustomMetricEntry", "MenstrualCycle", @@ -150,6 +166,8 @@ export const WIPE_MODELS = [ // ── Context and engagement ────────────────────────────────────────────── "EnvironmentContext", "EnvironmentTravelLocation", + // v1.43 — Dawarich daily movement values. Part of the environment record. + "DawarichDay", "HealthProfileFactRevision", "CorrelationPattern", "UserHealthProfile", @@ -372,9 +390,6 @@ export const USER_RESET = { avatarUpdatedAt: null, // Where the person lives — an environment-context input, and a location - homeLat: null, - homeLon: null, - homeLabel: null, homeTimezone: null, homeSince: null, homeLocationEncrypted: null, @@ -406,6 +421,7 @@ export const USER_RESET = { aiProviderChain: Prisma.DbNull, aiResponseTimeoutSeconds: null, disableCoach: false, + aiUsageLevel: null, coachPrefsJson: Prisma.DbNull, coachLastSeenAt: null, documentsAutoAiRead: false, @@ -432,6 +448,10 @@ export const USER_RESET = { nightscoutUrlEncrypted: null, nightscoutTokenEncrypted: null, nightscoutAllowPrivateHost: false, + dawarichUrlEncrypted: null, + dawarichApiKeyEncrypted: null, + dawarichPrefsEncrypted: null, + dawarichAllowPrivateHost: false, polarAccessTokenEncrypted: null, polarUserIdEncrypted: null, polarClientIdEncrypted: null, diff --git a/src/lib/documents/ai-runs/store.ts b/src/lib/documents/ai-runs/store.ts index 184b5ac5a..09a1b488c 100644 --- a/src/lib/documents/ai-runs/store.ts +++ b/src/lib/documents/ai-runs/store.ts @@ -30,6 +30,7 @@ import { type AiRunParams, type AiRunResult, type DocumentAiRunKindValue, + servedRunKind, } from "./types"; /** The longest a client is told to wait between polls. */ @@ -113,7 +114,7 @@ export async function claimAiRun( return { id: row.id, userId: row.userId, - kind: row.kind, + kind: servedRunKind(row.kind), documentId: row.documentId, params: row.paramsJson as unknown as AiRunParams, input, @@ -252,7 +253,7 @@ export async function readAiRunForUser( : 0; return { id: row.id, - kind: row.kind, + kind: servedRunKind(row.kind), documentId: row.documentId, status: unreadable ? "FAILED" : row.status, result, diff --git a/src/lib/documents/ai-runs/types.ts b/src/lib/documents/ai-runs/types.ts index 2c63c7d92..74a8538b2 100644 --- a/src/lib/documents/ai-runs/types.ts +++ b/src/lib/documents/ai-runs/types.ts @@ -25,6 +25,19 @@ export const DOCUMENT_AI_RUN_KINDS = [ ] as const; export type DocumentAiRunKindValue = (typeof DOCUMENT_AI_RUN_KINDS)[number]; +/** + * A stored run's kind as one this build can serve. The database enum may + * carry a kind ahead of its worker (a value added with its migration before + * the run that uses it lands); such a row is refused here rather than handed + * to a dispatcher that has no entry for it. + */ +export function servedRunKind(kind: string): DocumentAiRunKindValue { + if ((DOCUMENT_AI_RUN_KINDS as readonly string[]).includes(kind)) { + return kind as DocumentAiRunKindValue; + } + throw new Error(`document AI run kind not served by this build: ${kind}`); +} + /** * The capability each kind of run answers to: the one its queuing route asks, * asked again by the worker for the record before anything leaves. diff --git a/src/lib/environment/__tests__/location-cipher.test.ts b/src/lib/environment/__tests__/location-cipher.test.ts index 0e8727c36..95276f688 100644 --- a/src/lib/environment/__tests__/location-cipher.test.ts +++ b/src/lib/environment/__tests__/location-cipher.test.ts @@ -56,27 +56,23 @@ describe("sealLocation / openLocation", () => { }); describe("readLocation", () => { - it("prefers the sealed value over readable columns", () => { + it("opens the sealed value", () => { const sealed = sealLocation({ lat: 48.1, lon: 11.6, label: "Munich" }); - expect( - readLocation({ sealed, lat: 52.5, lon: 13.4, label: "Berlin" }), - ).toEqual({ lat: 48.1, lon: 11.6, label: "Munich" }); + expect(readLocation(sealed)).toEqual({ + lat: 48.1, + lon: 11.6, + label: "Munich", + }); }); - it("falls back to the readable columns for a row the backfill has not reached", () => { - expect( - readLocation({ sealed: null, lat: 52.5, lon: 13.4, label: "Berlin" }), - ).toEqual({ lat: 52.5, lon: 13.4, label: "Berlin" }); - expect( - readLocation({ sealed: null, lat: 52.5, lon: null, label: "Berlin" }), - ).toBeNull(); + it("reads a row without a sealed value as no location", () => { + expect(readLocation(null)).toBeNull(); + expect(readLocation(new Uint8Array())).toBeNull(); }); - it("reads a value that does not open as no location, never as the readable columns", () => { + it("reads a value that does not open as no location", () => { const sealed = sealLocation({ lat: 48.1, lon: 11.6, label: "Munich" }); vi.stubEnv("ENCRYPTION_KEY", "c".repeat(64)); - expect( - readLocation({ sealed, lat: 52.5, lon: 13.4, label: "Berlin" }), - ).toBeNull(); + expect(readLocation(sealed)).toBeNull(); }); }); diff --git a/src/lib/environment/__tests__/service-air-quality.test.ts b/src/lib/environment/__tests__/service-air-quality.test.ts index 3d3915d8d..e53eecd0e 100644 --- a/src/lib/environment/__tests__/service-air-quality.test.ts +++ b/src/lib/environment/__tests__/service-air-quality.test.ts @@ -113,9 +113,6 @@ beforeEach(() => { vi.stubEnv("ENCRYPTION_ACTIVE_KEY_ID", ""); vi.stubEnv("ENCRYPTION_KEY", "d".repeat(64)); db.user = { - homeLat: null, - homeLon: null, - homeLabel: null, homeLocationEncrypted: sealLocation({ lat: 51.48, lon: 7.21, @@ -146,9 +143,6 @@ describe("fetchAndStoreEnvironment", () => { { startDate: "2026-05-02", endDate: "2026-05-03", - lat: null, - lon: null, - label: null, locationEncrypted: damaged, }, ]; @@ -189,10 +183,10 @@ describe("fetchAndStoreEnvironment", () => { expect([call.lat, call.lon]).toEqual([51.5, 7.2]); } const { create } = db.upserts[0]; + // The location rides sealed only; no readable column exists to write. + expect(create).not.toHaveProperty("lat"); + expect(create).not.toHaveProperty("locationLabel"); expect(create).toMatchObject({ - lat: null, - lon: null, - locationLabel: null, source: "HOME", apparentMax: 25, pm25Mean: 9.5, @@ -260,9 +254,6 @@ describe("fetchAndStoreEnvironment", () => { { startDate: "2026-05-01", endDate: "2026-05-02", - lat: null, - lon: null, - label: null, locationEncrypted: sealLocation({ lat: 38.72, lon: -9.14, @@ -290,16 +281,10 @@ describe("fillAirQualityGaps", () => { db.days = [ { date: "2026-05-03", - lat: null, - lon: null, - locationLabel: null, locationEncrypted: sealed, }, { date: "2026-05-02", - lat: null, - lon: null, - locationLabel: null, locationEncrypted: sealed, }, ]; diff --git a/src/lib/environment/air-quality-history.ts b/src/lib/environment/air-quality-history.ts index c192eff28..603292aaa 100644 --- a/src/lib/environment/air-quality-history.ts +++ b/src/lib/environment/air-quality-history.ts @@ -358,9 +358,6 @@ export async function collectAirQualityHistory( select: { date: true, aqFetchedAt: true, - lat: true, - lon: true, - locationLabel: true, locationEncrypted: true, source: true, }, @@ -375,12 +372,7 @@ export async function collectAirQualityHistory( const row = rowByDate.get(date); let location: ResolvedLocation | null; if (row) { - const stored = readLocation({ - sealed: row.locationEncrypted, - lat: row.lat, - lon: row.lon, - label: row.locationLabel, - }); + const stored = readLocation(row.locationEncrypted); location = stored ? { ...stored, source: row.source } : null; } else if (inAnyPeriod(date, travel.unreadable)) { location = null; diff --git a/src/lib/environment/location-cipher.ts b/src/lib/environment/location-cipher.ts index cda1a481f..78c7e48a7 100644 --- a/src/lib/environment/location-cipher.ts +++ b/src/lib/environment/location-cipher.ts @@ -1,22 +1,21 @@ /** * The environment module's coarse locations, sealed at rest (v1.42, #615). * - * Three places held a location in readable columns: the home on the user row - * (`homeLat` / `homeLon` / `homeLabel`), each dated location period - * (`EnvironmentTravelLocation.lat` / `lon` / `label`), and each stored day - * (`EnvironmentContext.lat` / `lon` / `locationLabel`). Coarse as they are - * (one decimal, about 11 km), a home city and a dated list of trips are still - * a movement profile. From v1.42 each row stores ONE sealed value instead, the - * JSON `{ lat, lon, label }` under AES-256-GCM with the associated-data label + * Three places hold a location: the home on the user row + * (`homeLocationEncrypted`), each dated location period + * (`EnvironmentTravelLocation.locationEncrypted`), and each stored day + * (`EnvironmentContext.locationEncrypted`). Coarse as they are (one decimal, + * about 11 km), a home city and a dated list of trips are still a movement + * profile, so each row stores ONE sealed value, the JSON `{ lat, lon, label }` + * under AES-256-GCM with the associated-data label * {@link ENVIRONMENT_LOCATION_AAD}: one decrypt per row, and the three parts * can never drift apart. * - * Writers seal through {@link sealLocation}; the readable columns are written - * as null. Readers go through {@link readLocation}: the sealed value when a - * row has one, the readable columns only for a row the boot-time encryption - * backfill (`free-text-encryption-backfill.ts`) has not reached yet. A sealed - * value that does not open reads as no location, with a warning on the - * request's event, and is never replaced by the readable columns. + * Writers seal through {@link sealLocation}; readers go through + * {@link readLocation}. A sealed value that does not open reads as no + * location, with a warning on the request's event. The readable columns the + * sealed value replaced were dropped in v1.43 (migration 0409), which refuses + * to run while any of them still held a value. * * The coordinates are rounded to the privacy floor BEFORE sealing, so the * sealed value never carries more precision than the readable column did, @@ -78,37 +77,21 @@ export function openLocation(sealed: Uint8Array): CoarseLocation { } /** - * The location a row holds: the sealed value first, the legacy readable - * columns only when there is no sealed value. Null when the row holds - * neither (or only part of the readable triple), and null with a warning when - * the sealed value does not open. - * - * `fallbackLabel` stands in for a missing readable label, which the home has - * always allowed (`homeLabel` was nullable from the start). + * The location a row holds, from its sealed value. Null when the row holds + * none, and null with a warning when the sealed value does not open. */ export function readLocation( - row: { - sealed: Uint8Array | null | undefined; - lat: number | null | undefined; - lon: number | null | undefined; - label: string | null | undefined; - }, - fallbackLabel?: string, + sealed: Uint8Array | null | undefined, ): CoarseLocation | null { - if (row.sealed && row.sealed.byteLength > 0) { - try { - return openLocation(row.sealed); - } catch (err) { - getEvent()?.addWarning( - `environment location decrypt failed: ${ - err instanceof Error ? err.message : String(err) - }`, - ); - return null; - } + if (!sealed || sealed.byteLength === 0) return null; + try { + return openLocation(sealed); + } catch (err) { + getEvent()?.addWarning( + `environment location decrypt failed: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + return null; } - if (row.lat == null || row.lon == null) return null; - const label = row.label ?? fallbackLabel; - if (label == null) return null; - return { lat: row.lat, lon: row.lon, label }; } diff --git a/src/lib/environment/service.ts b/src/lib/environment/service.ts index d9af4cea7..071e245e7 100644 --- a/src/lib/environment/service.ts +++ b/src/lib/environment/service.ts @@ -165,9 +165,6 @@ export interface FetchAndStoreResult { /** The user columns every environment read resolves the home from. */ export const HOME_SELECT = { - homeLat: true, - homeLon: true, - homeLabel: true, homeLocationEncrypted: true, homeTimezone: true, homeSince: true, @@ -176,25 +173,16 @@ export const HOME_SELECT = { } as const; /** - * The account's home as the resolver uses it: the sealed copy opened, or the - * readable columns for a row the encryption backfill has not reached. Null + * The account's home as the resolver uses it: the sealed copy opened. Null * when no home is set (or the sealed value does not open). */ export function resolveHome(user: { - homeLat: number | null; - homeLon: number | null; - homeLabel: string | null; homeLocationEncrypted: Uint8Array | null; homeTimezone: string | null; homeSince: Date | null; timezone: string; }): HomeLocation | null { - const location = readLocation({ - sealed: user.homeLocationEncrypted, - lat: user.homeLat, - lon: user.homeLon, - label: user.homeLabel ?? "", - }); + const location = readLocation(user.homeLocationEncrypted); if (!location) return null; return { ...location, @@ -229,21 +217,13 @@ export async function readTravelOverrides(userId: string): Promise<{ select: { startDate: true, endDate: true, - lat: true, - lon: true, - label: true, locationEncrypted: true, }, }); const overrides: TravelOverride[] = []; const unreadable: UnreadablePeriod[] = []; for (const row of records) { - const location = readLocation({ - sealed: row.locationEncrypted, - lat: row.lat, - lon: row.lon, - label: row.label, - }); + const location = readLocation(row.locationEncrypted); if (location) { overrides.push({ startDate: row.startDate, @@ -478,12 +458,8 @@ export function buildUpsert( ? airQualityColumns(air.day, air.now) : AIR_QUALITY_UNFETCHED; const data = { - // The coarse location rides sealed only; the readable columns are - // written empty (they drop in a later release). + // The coarse location rides sealed only. locationEncrypted: sealed, - lat: null, - lon: null, - locationLabel: null, source: loc.source, tempMin: obs.tempMin, tempMax: obs.tempMax, @@ -550,9 +526,6 @@ export async function fillAirQualityGaps( take: maxDays, select: { date: true, - lat: true, - lon: true, - locationLabel: true, locationEncrypted: true, }, }); @@ -564,12 +537,7 @@ export async function fillAirQualityGaps( { lat: number; lon: number; days: string[] } >(); for (const row of rows) { - const location = readLocation({ - sealed: row.locationEncrypted, - lat: row.lat, - lon: row.lon, - label: row.locationLabel, - }); + const location = readLocation(row.locationEncrypted); if (!location) continue; const key = `${location.lat},${location.lon}`; const group = byLocation.get(key) ?? { diff --git a/src/lib/export/__tests__/account-settings-backup.test.ts b/src/lib/export/__tests__/account-settings-backup.test.ts index abb0594a7..09b105b6f 100644 --- a/src/lib/export/__tests__/account-settings-backup.test.ts +++ b/src/lib/export/__tests__/account-settings-backup.test.ts @@ -394,17 +394,13 @@ describe("the environment home (v1.42, sealed at rest)", () => { ); }); - it("seals a readable home from a file and writes the readable columns empty", () => { + it("seals a readable home from a file", () => { const { data, refused } = admitAccountSettings( { homeLat: 48.137, homeLon: 11.575, homeLabel: "Munich" }, ctx, ); expect(refused).toEqual([]); - expect(data).toMatchObject({ - homeLat: null, - homeLon: null, - homeLabel: null, - }); + expect(data).not.toHaveProperty("homeLat"); expect(openLocation(data.homeLocationEncrypted as Uint8Array)).toEqual({ lat: 48.1, lon: 11.6, @@ -421,11 +417,6 @@ describe("the environment home (v1.42, sealed at rest)", () => { expect(Buffer.from(restored).equals(Buffer.from(sealed))).toBe(true); expect( admitAccountSettings({ homeLat: null, homeLon: null }, ctx).data, - ).toEqual({ - homeLocationEncrypted: null, - homeLat: null, - homeLon: null, - homeLabel: null, - }); + ).toEqual({ homeLocationEncrypted: null }); }); }); diff --git a/src/lib/export/account-settings-backup.ts b/src/lib/export/account-settings-backup.ts index f69131b8e..2f7a426e5 100644 --- a/src/lib/export/account-settings-backup.ts +++ b/src/lib/export/account-settings-backup.ts @@ -41,6 +41,7 @@ import { Buffer } from "node:buffer"; import { Prisma, type PrismaClient } from "@/generated/prisma/client"; import { + AiUsageLevel, DateFormatPreference, InjectionSite, TimeFormatPreference, @@ -384,21 +385,19 @@ const insuranceNumber: SettingCodec = { /** * v1.42 (#615) — the environment home, sealed at rest as one value - * (`homeLocationEncrypted`, see `src/lib/environment/location-cipher.ts`) - * beside the readable `homeLat` / `homeLon` / `homeLabel` that the - * encryption backfill empties. The four columns travel as one, the way the - * avatar's three do: + * (`homeLocationEncrypted`, see `src/lib/environment/location-cipher.ts`). + * The readable `homeLat` / `homeLon` / `homeLabel` columns were dropped in + * v1.43; the three keys live on in the file: * - * - a portable file carries the home readable, opened from the sealed copy - * when there is one, and no sealed value; - * - a disaster-recovery file carries the sealed value verbatim (base64) - * beside whatever the readable columns still hold. + * - a portable file carries the home readable (`homeLat` / `homeLon` / + * `homeLabel`), opened from the sealed copy, and no sealed value; + * - a disaster-recovery file carries the sealed value verbatim (base64). * * The restore writes the sealed value as it came, or seals a readable home - * under this host's key, and writes the readable columns empty either way. A - * file whose home is null clears the account's home. A home without a label - * (allowed since v1.25) is sealed with an empty label, which every reader - * shows as no label. + * (a portable file, or a disaster-recovery file written before v1.43) under + * this host's key. A file whose home is null clears the account's home. A + * home without a label (allowed since v1.25) is sealed with an empty label, + * which every reader shows as no label. */ const homeLocation: SettingCodec = { write: (row, disasterRecovery) => { @@ -408,21 +407,13 @@ const homeLocation: SettingCodec = { : null; if (disasterRecovery) { return { - homeLat: row.homeLat, - homeLon: row.homeLon, - homeLabel: row.homeLabel, homeLocationEncrypted: sealed && sealed.byteLength > 0 ? Buffer.from(sealed).toString("base64") : null, }; } - const home = readLocation({ - sealed, - lat: row.homeLat as number | null, - lon: row.homeLon as number | null, - label: (row.homeLabel as string | null) ?? "", - }); + const home = readLocation(sealed); return home ? { homeLat: home.lat, @@ -432,12 +423,7 @@ const homeLocation: SettingCodec = { : { homeLat: null, homeLon: null, homeLabel: null }; }, read: (entry) => { - const cleared = { - homeLocationEncrypted: null, - homeLat: null, - homeLon: null, - homeLabel: null, - }; + const cleared = { homeLocationEncrypted: null }; const sealedValue = entry.homeLocationEncrypted; if (has(entry, "homeLocationEncrypted") && sealedValue !== null) { if (typeof sealedValue !== "string") { @@ -471,12 +457,6 @@ const homeLocation: SettingCodec = { }, }; -/** Carried by the home codec above. */ -const ridesWithHome: SettingCodec = { - write: () => ({}), - read: () => undefined, -}; - const ACCOUNT_SETTING_CODECS: Readonly< Record > = { @@ -493,9 +473,6 @@ const ACCOUNT_SETTING_CODECS: Readonly< avatarBytes: avatar, avatarContentType: ridesWithAvatar, avatarUpdatedAt: ridesWithAvatar, - homeLat: ridesWithHome, - homeLon: ridesWithHome, - homeLabel: ridesWithHome, homeTimezone: timeZone("homeTimezone", true), homeSince: instant("homeSince"), homeLocationEncrypted: homeLocation, @@ -545,6 +522,7 @@ const ACCOUNT_SETTING_CODECS: Readonly< ), insightsExcludeMetrics: plain("insightsExcludeMetrics"), disableCoach: plain("disableCoach"), + aiUsageLevel: oneOf("aiUsageLevel", Object.values(AiUsageLevel), true), coachPrefsJson: json("coachPrefsJson"), documentsAutoAiRead: plain("documentsAutoAiRead"), labsLocalOcrEnabled: plain("labsLocalOcrEnabled"), diff --git a/src/lib/export/backup-plan.ts b/src/lib/export/backup-plan.ts index 94d737678..7409fc369 100644 --- a/src/lib/export/backup-plan.ts +++ b/src/lib/export/backup-plan.ts @@ -205,6 +205,21 @@ export const BACKED_UP_MODELS = [ // restore without the row opens a blank questionnaire for somebody who has // already answered it. "OnboardingRecord", + + // ── v1.43 ───────────────────────────────────────────────────────────────── + // A marker's other spellings and its merge ledger, a supplement's + // composition, the Dawarich daily values, and the five relation links that + // close the relation matrix. Each is owned data nothing recomputes; each is + // on COVERAGE_PENDING until its section carries it. + "BiomarkerAlias", + "BiomarkerMerge", + "MedicationIngredient", + "DawarichDay", + "LabResultDocumentLink", + "EncounterMedicationLink", + "EncounterVaccinationLink", + "MedicationDocumentLink", + "MedicationConditionLink", ] as const; /** @@ -597,6 +612,26 @@ export const STRUCTURALLY_UNATTRIBUTABLE: Readonly> = { * rather than by what is cheapest to write. */ export const COVERAGE_PENDING: Readonly> = { + // v1.43 — the new tables are declared here with the schema and move to + // TWO_ENDED_MODELS when their section writer and restore branch land. + BiomarkerAlias: + "Every other spelling a marker answers to: the names folded in by a merge, the choices made at a scan or a document import, the ones typed on the marker. Without them a restored account asks again at the next scan which marker each spelling belongs to.", + BiomarkerMerge: + "The ledger of which markers were merged into which. Without it a link or a cached screen that still names a merged marker reads as a plain 404 instead of being sent to the marker it went into.", + MedicationIngredient: + "What one tablet or capsule of a supplement contains. Without it a restored account's supplements stop counting toward the day's micronutrients, and the composition has to be typed in again from the label.", + DawarichDay: + "The daily movement values fetched from Dawarich: distances, places visited, time away from home, travel days. Days the Dawarich instance has since pruned cannot be fetched again, so they are lost for good.", + LabResultDocumentLink: + "Which stored report a lab result was read from. Without it the result and the report stop pointing at each other, and the result loses its source.", + EncounterMedicationLink: + "Which medications a visit prescribed, changed or reviewed. Without it the visit and the medication stop pointing at each other.", + EncounterVaccinationLink: + "Which doses were given at a visit. Without it the visit and the dose stop pointing at each other.", + MedicationDocumentLink: + "Which documents belong to a medication: the prescription, the plan, the package insert. Without it they stop pointing at each other.", + MedicationConditionLink: + "Which condition a medication treats (its indication). Without it the medication and the condition stop pointing at each other.", WorkoutRoute: "GPS traces. Deliberately absent from the payload today and DISCLOSED as absent in the file's own manifest, which is why this is a documented exclusion rather than a silent one — but it is still a loss for a self-hoster with no other copy.", WorkoutSamples: @@ -879,6 +914,13 @@ export const USER_COLUMN_BACKUP_CLASS = { nightscoutUrlEncrypted: "CREDENTIAL", nightscoutTokenEncrypted: "CREDENTIAL", nightscoutAllowPrivateHost: "CREDENTIAL", + // v1.43 — the Dawarich connection, credentials like the Nightscout pair. The + // sealed preferences name a place id on that instance, which means nothing + // without the key, so they stay behind with it. + dawarichUrlEncrypted: "CREDENTIAL", + dawarichApiKeyEncrypted: "CREDENTIAL", + dawarichPrefsEncrypted: "CREDENTIAL", + dawarichAllowPrivateHost: "CREDENTIAL", polarAccessTokenEncrypted: "CREDENTIAL", polarUserIdEncrypted: "CREDENTIAL", polarClientIdEncrypted: "CREDENTIAL", @@ -935,13 +977,10 @@ export const USER_COLUMN_BACKUP_CLASS = { avatarContentType: "SETTING", avatarUpdatedAt: "SETTING", // Home, which the environment history is resolved against. - homeLat: "SETTING", - homeLon: "SETTING", - homeLabel: "SETTING", homeTimezone: "SETTING", homeSince: "SETTING", - // v1.42 (#615) — the sealed copy of the same home location, and the - // account's switch for the air-quality part of the module. + // v1.42 (#615) — the sealed home location (the only copy since v1.43), + // and the account's switch for the air-quality part of the module. homeLocationEncrypted: "SETTING", environmentAirQualityEnabled: "SETTING", // How the interface reads. @@ -983,6 +1022,10 @@ export const USER_COLUMN_BACKUP_CLASS = { insightsPrivacyMode: "SETTING", insightsExcludeMetrics: "SETTING", disableCoach: "SETTING", + // v1.43 (#1217) — the account's own AI usage level; NULL follows the + // instance default, and a restored level above this host's ceiling is + // capped at read time like any stored choice. + aiUsageLevel: "SETTING", coachPrefsJson: "SETTING", documentsAutoAiRead: "SETTING", labsLocalOcrEnabled: "SETTING", diff --git a/src/lib/export/environment-backup.ts b/src/lib/export/environment-backup.ts index dc344e535..80c9f0a9f 100644 --- a/src/lib/export/environment-backup.ts +++ b/src/lib/export/environment-backup.ts @@ -14,8 +14,8 @@ * the environmental readings mean anything", and the second line is the one * that decides the design. * - * A reading carries the coarse location it was fetched for (`lat`, `lon`, - * `locationLabel`) and the precedence rule that chose it (`source`). A + * A reading carries the coarse location it was fetched for (sealed; `lat`, + * `lon`, `locationLabel` in a portable file) and the precedence rule that chose it (`source`). A * `TRAVEL` reading exists because an explicit dated location period covered * that day. `resolveLocationForDay` in `src/lib/environment/service.ts` reads * those periods live, every time; nothing else can produce a `TRAVEL` @@ -60,14 +60,13 @@ * - a portable export opens the sealed location and writes the readable * `lat` / `lon` / label, and no `locationEncrypted` at all, so the file * reads anywhere; - * - a disaster-recovery file carries `locationEncrypted` verbatim (base64) - * beside whatever readable columns the row still holds, for a host with - * the same keys. + * - a disaster-recovery file carries `locationEncrypted` verbatim (base64), + * for a host with the same keys; its readable fields are null. * * The restore takes either: a sealed value is written as it came, a readable - * one is sealed under this host's key, and the readable columns are written - * empty in both cases. A row that holds neither restores without a location; - * it is never invented. + * one (a portable file, or a disaster-recovery file written before v1.43) is + * sealed under this host's key. A row that holds neither restores without a + * location; it is never invented. * * The air-quality columns (v1.42) are plain values and ride both purposes * alike. Every one is optional on the way in: a file written before them @@ -129,8 +128,8 @@ export interface EnvironmentContextBackupEntry extends AirQualityBackupColumns { /** `YYYY-MM-DD`, anchored to the resolved location's timezone. */ date: string; /** - * Plaintext coarse location. Nullable since v1.42 (migration 0382): the - * sealed copy replaces it once the encryption backfill has run. + * Readable coarse location: set in a portable file, null in a + * disaster-recovery file (which carries `locationEncrypted`). */ lat: number | null; lon: number | null; @@ -169,7 +168,7 @@ export interface EnvironmentTravelLocationBackupEntry { /** Inclusive `YYYY-MM-DD` bounds. Strings end to end; see the file header. */ startDate: string; endDate: string; - /** Plaintext coarse location; nullable since v1.42 (migration 0382). */ + /** Readable coarse location; set in a portable file, null otherwise. */ lat: number | null; lon: number | null; label: string | null; @@ -198,9 +197,6 @@ export interface EnvironmentBackupCounts { */ const ENVIRONMENT_CONTEXT_BACKUP_SELECT = { date: true, - lat: true, - lon: true, - locationLabel: true, locationEncrypted: true, source: true, tempMin: true, @@ -245,9 +241,6 @@ const ENVIRONMENT_CONTEXT_BACKUP_SELECT = { const ENVIRONMENT_TRAVEL_LOCATION_BACKUP_SELECT = { startDate: true, endDate: true, - lat: true, - lon: true, - label: true, locationEncrypted: true, createdAt: true, updatedAt: true, @@ -261,16 +254,12 @@ function toBase64(bytes: Uint8Array | null): string | null { } /** - * The location columns of one entry: opened and readable for a portable - * file, as stored plus the sealed value for a disaster-recovery file. + * The location fields of one entry: opened and readable for a portable file; + * the sealed value for a disaster-recovery file, whose readable fields stay + * null (the database holds no readable copy since v1.43). */ function locationFields( - row: { - lat: number | null; - lon: number | null; - label: string | null; - locationEncrypted: Uint8Array | null; - }, + sealed: Uint8Array | null, disasterRecovery: boolean, ): { lat: number | null; @@ -280,19 +269,13 @@ function locationFields( } { if (disasterRecovery) { return { - lat: row.lat, - lon: row.lon, - label: row.label, - locationEncrypted: toBase64(row.locationEncrypted), + lat: null, + lon: null, + label: null, + locationEncrypted: toBase64(sealed), }; } - const location = readLocation({ - sealed: row.locationEncrypted, - lat: row.lat, - lon: row.lon, - label: row.label, - }); - return location ?? { lat: null, lon: null, label: null }; + return readLocation(sealed) ?? { lat: null, lon: null, label: null }; } /** @@ -326,12 +309,7 @@ export async function buildEnvironmentBackupSection( return { environmentContexts: contextRows.map((row) => { const { label, ...location } = locationFields( - { - lat: row.lat, - lon: row.lon, - label: row.locationLabel, - locationEncrypted: row.locationEncrypted, - }, + row.locationEncrypted, disasterRecovery, ); return { @@ -381,7 +359,7 @@ export async function buildEnvironmentBackupSection( environmentTravelLocations: travelRows.map((row) => ({ startDate: row.startDate, endDate: row.endDate, - ...locationFields(row, disasterRecovery), + ...locationFields(row.locationEncrypted, disasterRecovery), createdAt: row.createdAt.toISOString(), updatedAt: row.updatedAt.toISOString(), })), @@ -543,9 +521,6 @@ export async function restoreEnvironmentData( `environmentTravelLocations.${entry.startDate}..${entry.endDate}`, unopened, ), - lat: null, - lon: null, - label: null, ...(entry.createdAt ? { createdAt: new Date(entry.createdAt) } : {}), ...(entry.updatedAt ? { updatedAt: new Date(entry.updatedAt) } : {}), })), @@ -567,9 +542,6 @@ export async function restoreEnvironmentData( `environmentContexts.${entry.date}`, unopened, ), - lat: null, - lon: null, - locationLabel: null, source: entry.source, tempMin: entry.tempMin ?? null, tempMax: entry.tempMax ?? null, diff --git a/src/lib/export/profile-backup.ts b/src/lib/export/profile-backup.ts index 282d01a70..289e54d23 100644 --- a/src/lib/export/profile-backup.ts +++ b/src/lib/export/profile-backup.ts @@ -23,7 +23,7 @@ */ import { Buffer } from "node:buffer"; -import type { Prisma, PrismaClient } from "@/generated/prisma/client"; +import { Prisma, type PrismaClient } from "@/generated/prisma/client"; import { decryptFromBytes, encryptToBytes } from "@/lib/ai/coach/bytes-codec"; import { encryptNote } from "@/lib/crypto/note-cipher"; import { getEvent } from "@/lib/logging/context"; @@ -47,6 +47,19 @@ export interface ProfileBackupOptions { * The account's durable self-context. One row per user, so there is no * reference to resolve on the way back in — the owner is the key. */ +/** + * The stored section confirmations as the file carries them: an object of + * strings, anything else read as none. + */ +function lifeConfirmed(value: unknown): Record | null { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + const out: Record = {}; + for (const [key, stamp] of Object.entries(value)) { + if (typeof stamp === "string") out[key] = stamp; + } + return out; +} + export interface HealthProfileBackupEntry { /** Present in canonical DR payloads so the row keeps a stable identity. */ id?: string; @@ -91,6 +104,12 @@ export interface HealthProfileBackupEntry { * state it was in, pending prompts included. */ pendingQuestionsEncrypted?: string | null; + /** + * v1.43 (#614) — when each life-circumstance section was last confirmed as + * still true: section key to ISO instant. Plaintext keys and stamps only, + * carried by value in both purposes. Absent in files written before v1.43. + */ + lifeConfirmedJson?: Record | null; createdAt?: string; updatedAt?: string; } @@ -329,6 +348,7 @@ export async function buildProfileBackupSection( HealthProfileAiSection[] | undefined) ?? [ ...DEFAULT_HEALTH_PROFILE_AI_SECTIONS, ], + lifeConfirmedJson: lifeConfirmed(profileRow.lifeConfirmedJson), emergencyBloodType: profileRow.emergencyBloodType ?? null, organDonorStatus: profileRow.organDonorStatus ?? null, advanceDirectiveStatus: profileRow.advanceDirectiveStatus ?? null, @@ -377,6 +397,7 @@ export async function buildProfileBackupSection( HealthProfileAiSection[] | undefined) ?? [ ...DEFAULT_HEALTH_PROFILE_AI_SECTIONS, ], + lifeConfirmedJson: lifeConfirmed(profileRow.lifeConfirmedJson), emergencyBloodType: profileRow.emergencyBloodType ?? null, organDonorStatus: profileRow.organDonorStatus ?? null, advanceDirectiveStatus: profileRow.advanceDirectiveStatus ?? null, @@ -788,6 +809,9 @@ export async function restoreProfileData( // resolution as the self-context columns above. emergencyBloodType: p.emergencyBloodType, organDonorStatus: p.organDonorStatus, + // A file written before v1.43 says nothing about confirmations; the + // account then has none, which only re-offers the yearly check. + lifeConfirmedJson: p.lifeConfirmedJson ?? Prisma.DbNull, advanceDirectiveStatus: p.advanceDirectiveStatus, emergencyContactsEncrypted: resolveProfileColumn( p.emergencyContactsEncrypted, diff --git a/src/lib/jobs/__tests__/free-text-encryption-backfill.test.ts b/src/lib/jobs/__tests__/free-text-encryption-backfill.test.ts index 5e5583822..5bc6abd92 100644 --- a/src/lib/jobs/__tests__/free-text-encryption-backfill.test.ts +++ b/src/lib/jobs/__tests__/free-text-encryption-backfill.test.ts @@ -5,7 +5,6 @@ import { decryptRouteGeometry, encryptRouteGeometry, } from "@/lib/workouts/route-geometry-cipher"; -import { openLocation, sealLocation } from "@/lib/environment/location-cipher"; // ── in-memory store backing a minimal prisma mock ────────────────────────── interface ConversationRow { @@ -38,36 +37,11 @@ interface RouteRow { geometryEncrypted: Uint8Array | null; } -/** v1.42 — an account's environment home; `userId` is its own id. */ -interface HomeRow { - id: string; - userId: string; - homeLat: number | null; - homeLon: number | null; - homeLabel: string | null; - homeLocationEncrypted: Uint8Array | null; - updatedAt?: Date; -} -/** v1.42 — a dated location period or a stored day (label column named). */ -interface PlaceRow { - id: string; - userId: string; - lat: number | null; - lon: number | null; - label?: string | null; - locationLabel?: string | null; - locationEncrypted: Uint8Array | null; - updatedAt?: Date; -} - const store = vi.hoisted(() => ({ conversations: [] as ConversationRow[], entries: [] as EntryRow[], practitioners: [] as PractitionerRow[], routes: [] as RouteRow[], - homes: [] as HomeRow[], - travels: [] as PlaceRow[], - days: [] as PlaceRow[], })); vi.mock("@/lib/jobs/boss-instance", () => ({ getGlobalBoss: () => null })); @@ -144,9 +118,6 @@ vi.mock("@/lib/db", () => { customMetricEntry: delegate(() => store.entries, ["note"]), practitioner: delegate(() => store.practitioners, ["phone", "location"]), workoutRoute: delegate(() => store.routes, ["geometry"]), - user: delegate(() => store.homes, ["homeLat"]), - environmentTravelLocation: delegate(() => store.travels, ["lat"]), - environmentContext: delegate(() => store.days, ["lat"]), }; return { prisma: { @@ -254,72 +225,6 @@ beforeEach(() => { }, { id: "r3", userId: "u2", geometry: TRACK, geometryEncrypted: null }, ]; - const stamp = new Date("2026-02-01T08:00:00.000Z"); - store.homes = [ - { - id: "u1", - userId: "u1", - homeLat: 51.5, - homeLon: 7.2, - homeLabel: "Bochum, Germany", - homeLocationEncrypted: null, - updatedAt: stamp, - }, - { - id: "u2", - userId: "u2", - homeLat: 48.1, - homeLon: 11.6, - homeLabel: "Munich, Germany", - homeLocationEncrypted: null, - updatedAt: stamp, - }, - ]; - store.travels = [ - { - id: "t1", - userId: "u1", - lat: 38.7, - lon: -9.1, - label: "Lisbon, Portugal", - locationEncrypted: null, - updatedAt: stamp, - }, - // Already sealed by this release's writer. - { - id: "t2", - userId: "u1", - lat: null, - lon: null, - label: null, - locationEncrypted: sealLocation({ - lat: 41.4, - lon: 2.2, - label: "Barcelona, Spain", - }), - updatedAt: stamp, - }, - ]; - store.days = [ - { - id: "d1", - userId: "u1", - lat: 38.7, - lon: -9.1, - locationLabel: "Lisbon, Portugal", - locationEncrypted: null, - updatedAt: stamp, - }, - { - id: "d2", - userId: "u2", - lat: 48.1, - lon: 11.6, - locationLabel: "Munich, Germany", - locationEncrypted: null, - updatedAt: stamp, - }, - ]; }); afterEach(() => { @@ -337,9 +242,6 @@ describe("runFreeTextEncryptionBackfillForUser", () => { routeGeometriesMigrated: 1, appointmentAddressesCleared: 0, contactAuditRowsScrubbed: 0, - homeLocationsMigrated: 1, - travelLocationsMigrated: 1, - dayLocationsMigrated: 1, }); const c1 = store.conversations.find((r) => r.id === "c1")!; @@ -443,64 +345,12 @@ describe("runFreeTextEncryptionBackfillForUser", () => { routeGeometriesMigrated: 0, appointmentAddressesCleared: 0, contactAuditRowsScrubbed: 0, - homeLocationsMigrated: 0, - travelLocationsMigrated: 0, - dayLocationsMigrated: 0, }); expect(store.conversations.find((r) => r.id === "c1")!.titleEncrypted).toBe( sealed, ); }); - it("seals the environment home, periods and days, clears the readable columns, keeps the stamps", async () => { - const t2Before = store.travels.find( - (r) => r.id === "t2", - )!.locationEncrypted; - await runFreeTextEncryptionBackfillForUser("u1"); - - const home = store.homes.find((r) => r.id === "u1")!; - expect(home).toMatchObject({ - homeLat: null, - homeLon: null, - homeLabel: null, - }); - expect(openLocation(home.homeLocationEncrypted!)).toEqual({ - lat: 51.5, - lon: 7.2, - label: "Bochum, Germany", - }); - expect(home.updatedAt).toEqual(new Date("2026-02-01T08:00:00.000Z")); - - const t1 = store.travels.find((r) => r.id === "t1")!; - expect(t1).toMatchObject({ lat: null, lon: null, label: null }); - expect(openLocation(t1.locationEncrypted!)).toEqual({ - lat: 38.7, - lon: -9.1, - label: "Lisbon, Portugal", - }); - expect(store.travels.find((r) => r.id === "t2")!.locationEncrypted).toBe( - t2Before, - ); - - const d1 = store.days.find((r) => r.id === "d1")!; - expect(d1).toMatchObject({ lat: null, lon: null, locationLabel: null }); - expect(openLocation(d1.locationEncrypted!).label).toBe("Lisbon, Portugal"); - - // Another account's rows are untouched. - expect(store.homes.find((r) => r.id === "u2")!.homeLat).toBe(48.1); - expect(store.days.find((r) => r.id === "d2")!.lat).toBe(48.1); - }); - - it("seals a home stored without a label with an empty one", async () => { - store.homes[0].homeLabel = null; - await runFreeTextEncryptionBackfillForUser("u1"); - expect(openLocation(store.homes[0].homeLocationEncrypted!)).toEqual({ - lat: 51.5, - lon: 7.2, - label: "", - }); - }); - it("is fail-closed: without a key every readable row stays intact", async () => { vi.stubEnv("ENCRYPTION_KEY", ""); vi.stubEnv("ENCRYPTION_KEYS", ""); @@ -509,10 +359,6 @@ describe("runFreeTextEncryptionBackfillForUser", () => { title: "Why is my pressure up after the new tablets?", titleEncrypted: null, }); - expect(store.days.find((r) => r.id === "d1")).toMatchObject({ - lat: 38.7, - locationEncrypted: null, - }); }); }); diff --git a/src/lib/jobs/environment-air-quality-history.ts b/src/lib/jobs/environment-air-quality-history.ts index ad6acea4a..3c03090d5 100644 --- a/src/lib/jobs/environment-air-quality-history.ts +++ b/src/lib/jobs/environment-air-quality-history.ts @@ -132,7 +132,6 @@ export async function discoverAirQualityHistory( environmentAirQualityEnabled: true, OR: [ { homeLocationEncrypted: { not: null } }, - { homeLat: { not: null }, homeLon: { not: null } }, { environmentTravelLocations: { some: {} } }, ], }, diff --git a/src/lib/jobs/environment-fetch.ts b/src/lib/jobs/environment-fetch.ts index 23c838882..e15262132 100644 --- a/src/lib/jobs/environment-fetch.ts +++ b/src/lib/jobs/environment-fetch.ts @@ -146,14 +146,7 @@ export async function enqueueEnvironmentFetchDiscovery( // consults the per-user preference map + the operator availability. const [candidates, operatorAvailability] = await Promise.all([ prisma.user.findMany({ - // The sealed home (v1.42), or the readable one a row the encryption - // backfill has not reached yet still carries. - where: { - OR: [ - { homeLocationEncrypted: { not: null } }, - { homeLat: { not: null }, homeLon: { not: null } }, - ], - }, + where: { homeLocationEncrypted: { not: null } }, select: { id: true, modulePreferencesJson: true }, }), getOperatorModuleAvailability(), diff --git a/src/lib/jobs/free-text-encryption-backfill.ts b/src/lib/jobs/free-text-encryption-backfill.ts index d02277dbf..ce04c2189 100644 --- a/src/lib/jobs/free-text-encryption-backfill.ts +++ b/src/lib/jobs/free-text-encryption-backfill.ts @@ -22,15 +22,10 @@ * column holds a JSON null rather than SQL NULL has nothing to seal; it is set * to SQL NULL so discovery stops finding it. * - * v1.42 (#615) adds the environment module's coarse locations: the home on - * the user row (`homeLat` / `homeLon` / `homeLabel` -> `homeLocationEncrypted`), - * each dated location period and each stored day (`lat` / `lon` / label -> - * `locationEncrypted`), each sealed as one `{ lat, lon, label }` value under - * the environment-location label (`location-cipher.ts`). A row whose readable - * triple is incomplete (a home without a label is sealed with an empty one; - * a day or period cannot be incomplete, those - * columns were NOT NULL until migration 0382) has its readable columns - * cleared all the same, so it leaves discovery. + * v1.42 (#615) added the environment module's coarse locations (home, dated + * location periods, stored days). v1.43 dropped their readable columns + * (migration 0409, which refuses to run while one still holds a value), so + * that part of the pass is gone. * * Modelled on `med-notes-encryption-backfill.ts`: a discovery query enqueues * one job per user still holding an un-migrated row, the per-user handler walks @@ -54,9 +49,9 @@ * after the upgrade (a rename, an edited note). Its readable value is then * the newer one, and it is the one sealed. * - * The readable columns are NOT dropped in this release. That is a follow-up - * release, once this backfill reports zero remaining rows on every instance; - * every one of them carries the marker in `schema.prisma`. + * The remaining readable columns are dropped in a follow-up release, once + * this backfill reports zero remaining rows on every instance; every one of + * them carries the marker in `schema.prisma`. * * Boot discovery is staggered past the startup storm (`startAfter`), like the * other boot backfills; the daily discovery tick passes no offset. @@ -72,7 +67,6 @@ import { getGlobalBoss } from "@/lib/jobs/boss-instance"; import { encryptToBytes } from "@/lib/ai/coach/bytes-codec"; import { encryptNote } from "@/lib/crypto/note-cipher"; import { encryptRouteGeometry } from "@/lib/workouts/route-geometry-cipher"; -import { sealLocation } from "@/lib/environment/location-cipher"; export const FREE_TEXT_ENCRYPTION_BACKFILL_QUEUE = "free-text-encryption-backfill"; @@ -100,12 +94,6 @@ export interface FreeTextEncryptionBackfillSummary { appointmentAddressesCleared: number; /** v1.39.4 — edit audit rows whose old phone number / address was removed. */ contactAuditRowsScrubbed: number; - /** v1.42 — the account's environment home sealed (0 or 1). */ - homeLocationsMigrated: number; - /** v1.42 — dated environment location periods sealed. */ - travelLocationsMigrated: number; - /** v1.42 — stored environment days whose location was sealed. */ - dayLocationsMigrated: number; } /** The audit action whose older rows carried contact values. */ @@ -303,107 +291,6 @@ async function migrateRouteGeometry(id: string): Promise { }); } -/** - * Seal one account's readable environment home into `homeLocationEncrypted` - * and clear the readable columns, atomically and idempotently. The user row - * keeps its own `updatedAt`: moving a value into ciphertext is not an edit. - */ -async function migrateHomeLocation(id: string): Promise { - return prisma.$transaction(async (tx) => { - const fresh = await tx.user.findUnique({ - where: { id }, - select: { - homeLat: true, - homeLon: true, - homeLabel: true, - updatedAt: true, - }, - }); - if (!fresh || fresh.homeLat === null) return false; - const { count } = await tx.user.updateMany({ - where: { id, updatedAt: fresh.updatedAt }, - data: { - updatedAt: fresh.updatedAt, - ...(fresh.homeLon !== null - ? { - homeLocationEncrypted: sealLocation({ - lat: fresh.homeLat, - lon: fresh.homeLon, - // A home stored without a label seals an empty one, which - // every reader shows as no label. - label: fresh.homeLabel ?? "", - }), - } - : {}), - homeLat: null, - homeLon: null, - homeLabel: null, - }, - }); - return count === 1; - }); -} - -/** Seal one dated location period's readable location, as above. */ -async function migrateTravelLocation(id: string): Promise { - return prisma.$transaction(async (tx) => { - const fresh = await tx.environmentTravelLocation.findUnique({ - where: { id }, - select: { lat: true, lon: true, label: true, updatedAt: true }, - }); - if (!fresh || fresh.lat === null) return false; - const { count } = await tx.environmentTravelLocation.updateMany({ - where: { id, updatedAt: fresh.updatedAt }, - data: { - updatedAt: fresh.updatedAt, - ...(fresh.lon !== null && fresh.label !== null - ? { - locationEncrypted: sealLocation({ - lat: fresh.lat, - lon: fresh.lon, - label: fresh.label, - }), - } - : {}), - lat: null, - lon: null, - label: null, - }, - }); - return count === 1; - }); -} - -/** Seal one stored day's readable location, as above. */ -async function migrateDayLocation(id: string): Promise { - return prisma.$transaction(async (tx) => { - const fresh = await tx.environmentContext.findUnique({ - where: { id }, - select: { lat: true, lon: true, locationLabel: true, updatedAt: true }, - }); - if (!fresh || fresh.lat === null) return false; - const { count } = await tx.environmentContext.updateMany({ - where: { id, updatedAt: fresh.updatedAt }, - data: { - updatedAt: fresh.updatedAt, - ...(fresh.lon !== null && fresh.locationLabel !== null - ? { - locationEncrypted: sealLocation({ - lat: fresh.lat, - lon: fresh.lon, - label: fresh.locationLabel, - }), - } - : {}), - lat: null, - lon: null, - locationLabel: null, - }, - }); - return count === 1; - }); -} - /** * Walk one kind of row page by page, migrating each. `page` returns the ids * still holding a readable value; each migration clears it, so a migrated row @@ -511,39 +398,6 @@ export async function runFreeTextEncryptionBackfillForUser( scrubContactAuditRow, once, ); - // v1.42 — the environment locations. The 0382 partial indexes match these - // predicates. - const homeLocationsMigrated = await drain( - () => - prisma.user.findMany({ - where: { id: userId, homeLat: { not: null } }, - select: { id: true }, - take: PAGE_SIZE, - }), - migrateHomeLocation, - once, - ); - const travelLocationsMigrated = await drain( - () => - prisma.environmentTravelLocation.findMany({ - where: { userId, lat: { not: null } }, - select: { id: true }, - take: PAGE_SIZE, - }), - migrateTravelLocation, - once, - ); - const dayLocationsMigrated = await drain( - () => - prisma.environmentContext.findMany({ - where: { userId, lat: { not: null } }, - select: { id: true }, - take: PAGE_SIZE, - }), - migrateDayLocation, - once, - ); - annotate({ action: { name: "free_text.encryption.backfill", @@ -554,9 +408,6 @@ export async function runFreeTextEncryptionBackfillForUser( route_geometries_migrated: routeGeometriesMigrated, appointment_addresses_cleared: appointmentAddressesCleared, contact_audit_rows_scrubbed: contactAuditRowsScrubbed, - home_locations_migrated: homeLocationsMigrated, - travel_locations_migrated: travelLocationsMigrated, - day_locations_migrated: dayLocationsMigrated, }, }, }); @@ -568,16 +419,13 @@ export async function runFreeTextEncryptionBackfillForUser( routeGeometriesMigrated, appointmentAddressesCleared, contactAuditRowsScrubbed, - homeLocationsMigrated, - travelLocationsMigrated, - dayLocationsMigrated, }; } /** * Discovery. Finds every user still holding a readable value in any of the - * columns above and enqueues one backfill job per account. The 0357, 0361 and - * 0382 partial indexes match the predicates, so the scan is index-only and converges to empty. + * columns above and enqueues one backfill job per account. The 0357 and 0361 + * partial indexes match the predicates, so the scan is index-only and converges to empty. * The appointment-address and audit predicates have no partial index: the * first scans the reminder table, the second reads one action through the * `(action, created_at)` index; both converge to empty the same way. pg-boss @@ -625,15 +473,6 @@ export async function enqueueBootTimeFreeTextEncryptionBackfill( WHERE action = ${CONTACT_AUDIT_ACTION} AND user_id IS NOT NULL AND (details LIKE '%"location":%' OR details LIKE '%"phone":%')`, - prisma.$queryRaw<{ user_id: string }[]>` - SELECT id AS user_id FROM users - WHERE home_lat IS NOT NULL`, - prisma.$queryRaw<{ user_id: string }[]>` - SELECT DISTINCT user_id FROM environment_travel_locations - WHERE lat IS NOT NULL`, - prisma.$queryRaw<{ user_id: string }[]>` - SELECT DISTINCT user_id FROM environment_contexts - WHERE lat IS NOT NULL`, ]); const userIds = new Set(); diff --git a/src/lib/validations/backup.ts b/src/lib/validations/backup.ts index f9d99ba4a..70ca04c42 100644 --- a/src/lib/validations/backup.ts +++ b/src/lib/validations/backup.ts @@ -811,6 +811,13 @@ const healthProfileBackupSchema = z aiIncludedSections: z .array(healthProfileAiSectionSchema) .default([...DEFAULT_HEALTH_PROFILE_AI_SECTIONS]), + // v1.43 (#614) — section confirmations, section key to ISO instant. + // Carried as stored; the read route judges each stamp, so an unreadable + // one costs a "still true?" prompt rather than the whole restore. + lifeConfirmedJson: z + .record(z.string().max(64), z.string().max(64)) + .nullable() + .optional(), // Emergency profile: three plaintext enums (carried by value in both // purposes), three free-text columns following the ciphertext-or-plaintext // split of the self-context fields above. @@ -2031,6 +2038,8 @@ const accountSettingsBackupSchema = z insightsPrivacyMode: z.string().optional(), insightsExcludeMetrics: z.array(z.string()).optional(), disableCoach: z.boolean().optional(), + // v1.43 (#1217) — the account's AI usage level; the codec checks the value. + aiUsageLevel: z.string().nullable().optional(), coachPrefsJson: z.unknown().optional(), documentsAutoAiRead: z.boolean().optional(), labsLocalOcrEnabled: z.boolean().optional(), diff --git a/src/lib/validations/mental-health.ts b/src/lib/validations/mental-health.ts index ca6b70784..003f5f619 100644 --- a/src/lib/validations/mental-health.ts +++ b/src/lib/validations/mental-health.ts @@ -16,6 +16,21 @@ import { isValidTimezone } from "@/lib/tz/format"; export const assessmentInstrumentEnum = z.enum(["PHQ9", "GAD7", "WHO5", "SCI"]); +/** + * The mental-health screeners, as opposed to every `AssessmentInstrument`. + * Since v1.43 the table also stores AUDIT-C (the alcohol section of the life + * circumstances), which no mental-health surface may list or score: older app + * builds decode `instrument` against exactly these four values. + */ +export const SCREENER_INSTRUMENTS = assessmentInstrumentEnum.options; +export type ScreenerInstrument = (typeof SCREENER_INSTRUMENTS)[number]; + +export function isScreenerInstrument( + instrument: string, +): instrument is ScreenerInstrument { + return (SCREENER_INSTRUMENTS as readonly string[]).includes(instrument); +} + /** * Client provenance for an administration. Closed enum so the column can never * carry a free-form label. `WEB` is the in-app questionnaire (the DB default); diff --git a/tests/integration/backup-round-trip.test.ts b/tests/integration/backup-round-trip.test.ts index 7cde2c4cb..88971c88a 100644 --- a/tests/integration/backup-round-trip.test.ts +++ b/tests/integration/backup-round-trip.test.ts @@ -1426,7 +1426,7 @@ async function seedEveryTwoEndedModel(prisma: PrismaClient): Promise { // coordinates are at 1 decimal, the precision the app stores since v1.39.4; // a restore rounds anything finer, so finer values would not round-trip. // Since v1.42 the location is stored sealed (`locationEncrypted`), the way - // the writers store it; the readable columns stay empty. + // the writers store it; it is the only copy since v1.43. await prisma.environmentTravelLocation.create({ data: { userId: OWNER_ID, @@ -3602,22 +3602,6 @@ const COLUMN_EXCLUSIONS: Readonly> = { "insert stamp of a symptom link; no reader, the day log's own timestamps travel", "IllnessSymptomLink.createdAt": "insert stamp of a symptom link; no reader, the illness day log's own timestamps travel", - // v1.42 (#615) — the readable copies of the sealed environment locations. - // The restore seals whatever location a file carries into - // `locationEncrypted` and writes these empty; the location itself is - // compared through the sealed column. They drop in v1.43. - "EnvironmentContext.lat": - "readable copy of the sealed day location; the restore seals it and writes this empty", - "EnvironmentContext.lon": - "readable copy of the sealed day location; the restore seals it and writes this empty", - "EnvironmentContext.locationLabel": - "readable copy of the sealed day location; the restore seals it and writes this empty", - "EnvironmentTravelLocation.lat": - "readable copy of the sealed period location; the restore seals it and writes this empty", - "EnvironmentTravelLocation.lon": - "readable copy of the sealed period location; the restore seals it and writes this empty", - "EnvironmentTravelLocation.label": - "readable copy of the sealed period location; the restore seals it and writes this empty", }; /** @@ -3638,7 +3622,14 @@ const COLUMN_PRESENCE_ONLY: Readonly> = { * starts coming back, so the entry has to go in the change that carries it. * The list is empty again before the release is tagged. */ -const RELEASE_PENDING_COLUMNS: Readonly> = {}; +const RELEASE_PENDING_COLUMNS: Readonly> = { + "IntradayCumulativeProfile.hourlyIncrements": + "hour buckets for summed types (ios #77): the intraday section carries it with the hour-bucket ingest", + "IntradayCumulativeProfile.basis": + "hour buckets for summed types (ios #77): the intraday section carries it with the hour-bucket ingest", + "IntradayCumulativeProfile.bucketCount": + "hour buckets for summed types (ios #77): the intraday section carries it with the hour-bucket ingest", +}; /** * Columns every reader parses through one function before using, compared @@ -3665,6 +3656,8 @@ const FILL_OVERRIDES: Readonly> = { "MoodEntry.tags": JSON.stringify(["round-trip-tag"]), "CorrelationPattern.qValue": 0.04, "CorrelationPattern.dismissedEvidenceHash": "ab".repeat(32), + // CHECK in migration 0408: the profile is built from samples or from hour buckets. + "IntradayCumulativeProfile.basis": "HOUR_BUCKETS", }; /** @@ -4155,17 +4148,6 @@ const USER_FILL_OVERRIDES: Readonly> = { sealLocation({ lat: 2.5, lon: 2.5, label: "round-trip home" }), }; -/** - * v1.42 (#615) — readable setting columns the restore seals into another - * column and writes empty. The value they held is compared through the sealed - * column (`homeLocationEncrypted`). - */ -const SEALED_ON_RESTORE: Readonly> = { - homeLat: "sealed into homeLocationEncrypted", - homeLon: "sealed into homeLocationEncrypted", - homeLabel: "sealed into homeLocationEncrypted", -}; - /** * Columns of the account row the restore writes for reasons of its own, so * "unchanged on the target" does not apply to them. @@ -4302,15 +4284,8 @@ describe("the account's own settings survive a real restore", () => { })) as unknown as Record; const lost = [...settings].filter( - (name) => - !Object.hasOwn(SEALED_ON_RESTORE, name) && - opened(after[name]) !== opened(source[name]), + (name) => opened(after[name]) !== opened(source[name]), ); - // The readable home columns come back empty by design; the home itself - // came back sealed, compared above through `homeLocationEncrypted`. - for (const name of Object.keys(SEALED_ON_RESTORE)) { - expect(after[name], name).toBeNull(); - } expect( lost, "these settings left with a value and came back without it", diff --git a/tests/integration/drop-plaintext-location-migration.test.ts b/tests/integration/drop-plaintext-location-migration.test.ts new file mode 100644 index 000000000..e20d48ea4 --- /dev/null +++ b/tests/integration/drop-plaintext-location-migration.test.ts @@ -0,0 +1,245 @@ +/** + * Migration 0409 drops the readable location columns, and refuses to run + * while any row still holds one. + * + * The suite's database is already migrated, so each case first puts the + * v1.42 shape back (the three readable column sets and the 0382 partial + * indexes) inside a transaction, then runs the migration's own statements + * against it. Every case rolls back, so the database leaves as it came. + * + * - A readable home, period or day aborts the migration with a message that + * counts each kind, and nothing is lost: the columns and the value are + * still there afterwards. + * - Without readable values it drops the nine columns and the three + * indexes, and the sealed copies are untouched. + */ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +import type { Prisma } from "@/generated/prisma/client"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +const prisma = getPrismaClient(); +const USER = "drop-plaintext-location"; +const ROLLBACK = new Error("roll back the case"); + +const MIGRATION = readFileSync( + resolve( + process.cwd(), + "prisma", + "migrations", + "0409_drop_plaintext_location", + "migration.sql", + ), + "utf8", +); + +/** + * The migration as separate statements. A `DO $$ … $$` block carries + * semicolons of its own, so the split honours dollar quoting; comment lines + * are dropped first. + */ +function statements(sql: string): string[] { + const text = sql + .split("\n") + .filter((line) => !line.trimStart().startsWith("--")) + .join("\n"); + const out: string[] = []; + let current = ""; + let inDollar = false; + for (let i = 0; i < text.length; i += 1) { + if (text.startsWith("$$", i)) { + inDollar = !inDollar; + current += "$$"; + i += 1; + continue; + } + const ch = text[i]; + if (ch === ";" && !inDollar) { + if (current.trim()) out.push(current.trim()); + current = ""; + continue; + } + current += ch; + } + if (current.trim()) out.push(current.trim()); + return out; +} + +/** The readable columns and partial indexes as v1.42 left them. */ +async function restoreV142Shape(tx: Prisma.TransactionClient): Promise { + await tx.$executeRawUnsafe(`ALTER TABLE "users" + ADD COLUMN "home_lat" DOUBLE PRECISION, + ADD COLUMN "home_lon" DOUBLE PRECISION, + ADD COLUMN "home_label" TEXT`); + await tx.$executeRawUnsafe(`ALTER TABLE "environment_travel_locations" + ADD COLUMN "lat" DOUBLE PRECISION, + ADD COLUMN "lon" DOUBLE PRECISION, + ADD COLUMN "label" TEXT`); + await tx.$executeRawUnsafe(`ALTER TABLE "environment_contexts" + ADD COLUMN "lat" DOUBLE PRECISION, + ADD COLUMN "lon" DOUBLE PRECISION, + ADD COLUMN "location_label" TEXT`); + await tx.$executeRawUnsafe( + `CREATE INDEX "users_home_location_plain_idx" ON "users"("id") WHERE "home_lat" IS NOT NULL`, + ); + await tx.$executeRawUnsafe( + `CREATE INDEX "environment_travel_locations_plain_location_idx" ON "environment_travel_locations"("id") WHERE "lat" IS NOT NULL`, + ); + await tx.$executeRawUnsafe( + `CREATE INDEX "environment_contexts_plain_location_idx" ON "environment_contexts"("id") WHERE "lat" IS NOT NULL`, + ); +} + +async function runMigration(tx: Prisma.TransactionClient): Promise { + for (const statement of statements(MIGRATION)) { + await tx.$executeRawUnsafe(statement); + } +} + +async function columnCount(tx: Prisma.TransactionClient): Promise { + const rows = await tx.$queryRaw<{ n: bigint }[]>` + SELECT count(*) AS n FROM information_schema.columns + WHERE (table_name = 'users' AND column_name IN ('home_lat', 'home_lon', 'home_label')) + OR (table_name = 'environment_travel_locations' AND column_name IN ('lat', 'lon', 'label')) + OR (table_name = 'environment_contexts' AND column_name IN ('lat', 'lon', 'location_label'))`; + return Number(rows[0].n); +} + +async function plainIndexCount(tx: Prisma.TransactionClient): Promise { + const rows = await tx.$queryRaw<{ n: bigint }[]>` + SELECT count(*) AS n FROM pg_indexes + WHERE indexname IN ( + 'users_home_location_plain_idx', + 'environment_travel_locations_plain_location_idx', + 'environment_contexts_plain_location_idx' + )`; + return Number(rows[0].n); +} + +/** Run `body` in a transaction that always rolls back. */ +async function inRolledBackCase( + body: (tx: Prisma.TransactionClient) => Promise, +): Promise { + await expect( + prisma.$transaction(async (tx) => { + await body(tx); + throw ROLLBACK; + }), + ).rejects.toBe(ROLLBACK); +} + +/** The migration's outcome inside a savepoint, so the case can look after it. */ +async function attempt(tx: Prisma.TransactionClient): Promise { + await tx.$executeRawUnsafe("SAVEPOINT migration_0409"); + try { + await runMigration(tx); + await tx.$executeRawUnsafe("RELEASE SAVEPOINT migration_0409"); + return null; + } catch (err) { + await tx.$executeRawUnsafe("ROLLBACK TO SAVEPOINT migration_0409"); + return err instanceof Error ? err : new Error(String(err)); + } +} + +beforeAll(async () => { + await truncateAllTables(prisma); + await prisma.user.create({ + data: { id: USER, username: USER, email: `${USER}@example.test` }, + }); +}); + +afterAll(async () => { + await truncateAllTables(prisma); +}); + +describe("migration 0409 (drop the readable location columns)", () => { + it("is split into its guard and its drops", () => { + const parts = statements(MIGRATION); + expect(parts[0]).toMatch(/^DO \$\$/); + expect(parts[0]).toContain("RAISE EXCEPTION"); + // The guard runs before anything is dropped. + expect(parts.slice(1).every((s) => /^(DROP|ALTER)/.test(s))).toBe(true); + expect(parts.length).toBeGreaterThan(3); + }); + + it("stops on a readable home, names the counts, and loses nothing", async () => { + await inRolledBackCase(async (tx) => { + await restoreV142Shape(tx); + await tx.$executeRawUnsafe( + `UPDATE "users" SET "home_lat" = 51.5, "home_lon" = 7.2, "home_label" = 'Bochum' WHERE "id" = $1`, + USER, + ); + + const error = await attempt(tx); + expect(error?.message).toMatch(/HealthLog migration 0409 stopped/); + expect(error?.message).toMatch( + /1 home location\(s\), 0 location period\(s\) and 0 daily location\(s\)/, + ); + + // Nothing was dropped and the value is where it was. + expect(await columnCount(tx)).toBe(9); + expect(await plainIndexCount(tx)).toBe(3); + const rows = await tx.$queryRawUnsafe<{ home_lat: number }[]>( + `SELECT "home_lat" FROM "users" WHERE "id" = $1`, + USER, + ); + expect(rows[0].home_lat).toBe(51.5); + }); + }); + + it("counts readable periods and days as well", async () => { + await inRolledBackCase(async (tx) => { + await restoreV142Shape(tx); + await tx.$executeRawUnsafe( + `INSERT INTO "environment_travel_locations" ("id", "user_id", "start_date", "end_date", "lat", "lon", "label", "updated_at") + VALUES ('t1', $1, '2026-05-01', '2026-05-03', 38.7, -9.1, 'Lisbon', now()), + ('t2', $1, '2026-06-01', '2026-06-03', 41.4, 2.2, 'Barcelona', now())`, + USER, + ); + await tx.$executeRawUnsafe( + `INSERT INTO "environment_contexts" ("id", "user_id", "date", "source", "lat", "lon", "location_label", "updated_at") + VALUES ('d1', $1, '2026-05-01', 'TRAVEL', 38.7, -9.1, 'Lisbon', now())`, + USER, + ); + + const error = await attempt(tx); + expect(error?.message).toMatch( + /0 home location\(s\), 2 location period\(s\) and 1 daily location\(s\)/, + ); + expect(await columnCount(tx)).toBe(9); + }); + }); + + it("drops the columns and indexes when no readable value is left, sealed rows untouched", async () => { + await inRolledBackCase(async (tx) => { + await restoreV142Shape(tx); + const sealed = new Uint8Array([2, 1, 2, 3]); + await tx.environmentTravelLocation.create({ + data: { + id: "sealed-period", + userId: USER, + startDate: "2026-07-01", + endDate: "2026-07-02", + locationEncrypted: sealed, + }, + }); + + expect(await attempt(tx)).toBeNull(); + expect(await columnCount(tx)).toBe(0); + expect(await plainIndexCount(tx)).toBe(0); + const kept = await tx.environmentTravelLocation.findUnique({ + where: { id: "sealed-period" }, + }); + expect(Buffer.from(kept!.locationEncrypted!)).toEqual( + Buffer.from(sealed), + ); + }); + }); + + it("leaves the migrated database as it found it", async () => { + expect(await columnCount(prisma as Prisma.TransactionClient)).toBe(0); + expect(await plainIndexCount(prisma as Prisma.TransactionClient)).toBe(0); + }); +}); diff --git a/tests/integration/environment-coarse-location.test.ts b/tests/integration/environment-coarse-location.test.ts index 8bab8a772..07e753ae9 100644 --- a/tests/integration/environment-coarse-location.test.ts +++ b/tests/integration/environment-coarse-location.test.ts @@ -5,16 +5,14 @@ * - setting a home and adding a travel period store the coarse value, and * the home answer carries it in the same shape as before (numbers); * - a restore of a file written with 2-decimal coordinates stores them - * coarse; - * - the rounding block of migration 0361 coarsens rows written before it, - * and running it twice changes nothing more. + * coarse. * * v1.42 (#615): the locations are stored sealed, so the coarse value is - * asserted on the opened sealed copy, and the readable columns are empty. The - * rounding still happens before sealing; nothing finer is ever stored. + * asserted on the opened sealed copy. The rounding happens before sealing; + * nothing finer is ever stored. Since v1.43 (migration 0409) the readable + * columns no longer exist, so the rounding block of migration 0361, which + * worked on them, has nothing left to test against. */ -import { readFileSync } from "node:fs"; -import { join } from "node:path"; import { beforeEach, describe, expect, it, vi } from "vitest"; process.env.ENCRYPTION_KEY ??= @@ -100,11 +98,6 @@ describe("environment coordinates at one decimal", () => { const user = await getPrismaClient().user.findUniqueOrThrow({ where: { id: OWNER_ID }, }); - expect([user.homeLat, user.homeLon, user.homeLabel]).toEqual([ - null, - null, - null, - ]); expect(openLocation(user.homeLocationEncrypted!)).toEqual({ lat: 52.5, lon: 13.4, @@ -125,9 +118,6 @@ describe("environment coordinates at one decimal", () => { const stored = await getPrismaClient().environmentTravelLocation.findMany({ where: { userId: OWNER_ID }, }); - expect(stored.map((t) => [t.lat, t.lon, t.label])).toEqual([ - [null, null, null], - ]); expect(stored.map((t) => openLocation(t.locationEncrypted!))).toEqual([ { lat: 48.1, lon: 11.6, label: "Munich, Germany" }, ]); @@ -169,68 +159,5 @@ describe("environment coordinates at one decimal", () => { expect(days.map((d) => openLocation(d.locationEncrypted!))).toEqual([ { lat: 48.1, lon: 11.6, label: "Munich, Germany" }, ]); - expect(days.map((d) => [d.lat, d.lon, d.locationLabel])).toEqual([ - [null, null, null], - ]); - }); - - it("coarsens rows written before migration 0361, idempotently", async () => { - const prisma = getPrismaClient(); - await prisma.user.update({ - where: { id: OWNER_ID }, - data: { homeLat: 52.52, homeLon: 13.38 }, - }); - await prisma.environmentTravelLocation.create({ - data: { - userId: OWNER_ID, - startDate: "2026-08-01", - endDate: "2026-08-10", - lat: 48.14, - lon: 11.58, - label: "Munich, Germany", - }, - }); - await prisma.environmentContext.create({ - data: { - userId: OWNER_ID, - date: "2026-08-02", - lat: -33.87, - lon: 151.21, - locationLabel: "Sydney, Australia", - source: "TRAVEL", - }, - }); - - const sql = readFileSync( - join( - __dirname, - "../../prisma/migrations/0361_encrypt_route_geometry_and_practitioner_contact/migration.sql", - ), - "utf8", - ); - const statements = sql - .slice(sql.indexOf('UPDATE "users"')) - .split(";") - .map((s) => s.trim()) - .filter((s) => s.length > 0); - expect(statements).toHaveLength(3); - - for (let pass = 0; pass < 2; pass++) { - for (const statement of statements) { - await prisma.$executeRawUnsafe(statement); - } - const user = await prisma.user.findUniqueOrThrow({ - where: { id: OWNER_ID }, - }); - expect([user.homeLat, user.homeLon]).toEqual([52.5, 13.4]); - const travel = await prisma.environmentTravelLocation.findFirstOrThrow({ - where: { userId: OWNER_ID }, - }); - expect([travel.lat, travel.lon]).toEqual([48.1, 11.6]); - const day = await prisma.environmentContext.findFirstOrThrow({ - where: { userId: OWNER_ID }, - }); - expect([day.lat, day.lon]).toEqual([-33.9, 151.2]); - } }); }); diff --git a/tests/integration/environment-location-encryption.test.ts b/tests/integration/environment-location-encryption.test.ts index ff5e25456..79251745e 100644 --- a/tests/integration/environment-location-encryption.test.ts +++ b/tests/integration/environment-location-encryption.test.ts @@ -1,13 +1,11 @@ /** * v1.42 (#615) — the environment locations sealed at rest, against real - * Postgres with the migrated schema (0381, 0382). + * Postgres with the migrated schema (0381, 0382, and 0409, which dropped the + * readable location columns). * * - the writers (home, period) store only the sealed copy, the overview * answers readable, and the air-quality switch round-trips through its * route; - * - the boot-time backfill finds the accounts still holding a readable - * home, period or day, seals each, clears the readable columns, and - * converges (a second pass and a second discovery find nothing); * - the backup carries the location readable in a portable file and sealed * in a disaster-recovery file, and both restore to a location that opens * to the same coarse place, with the air-quality values of the day; @@ -22,10 +20,6 @@ import { POST as postTravel } from "@/app/api/environment/travel/route"; import { PATCH as patchPreferences } from "@/app/api/environment/preferences/route"; import { GET as getOverview } from "@/app/api/environment/route"; import { setGlobalBoss } from "@/lib/jobs/boss-instance"; -import { - enqueueBootTimeFreeTextEncryptionBackfill, - runFreeTextEncryptionBackfillForUser, -} from "@/lib/jobs/free-text-encryption-backfill"; import { openLocation, sealLocation } from "@/lib/environment/location-cipher"; import { buildEnvironmentBackupSection, @@ -134,19 +128,18 @@ describe("environment writers and the overview", () => { ).toBe(201); const user = await prisma.user.findUniqueOrThrow({ where: { id: OWNER } }); - expect([user.homeLat, user.homeLon, user.homeLabel]).toEqual([ - null, - null, - null, - ]); + expect(user).not.toHaveProperty("homeLat"); const travel = await prisma.environmentTravelLocation.findFirstOrThrow({ where: { userId: OWNER }, }); - expect([travel.lat, travel.lon, travel.label]).toEqual([null, null, null]); + expect(travel).not.toHaveProperty("lat"); // No readable trace of either place anywhere in the two rows. expect( JSON.stringify({ ...user, homeLocationEncrypted: null }), ).not.toMatch(/Bochum/); + expect(JSON.stringify({ ...travel, locationEncrypted: null })).not.toMatch( + /Lisbon/, + ); const overview = (await (await getOverview()).json()).data; expect(overview.home).toMatchObject({ @@ -231,93 +224,6 @@ describe("environment writers and the overview", () => { }); }); -describe("the encryption backfill (real Postgres)", () => { - it("discovers, seals, clears and converges", async () => { - const prisma = getPrismaClient(); - await seedUser("env-other"); - const stamp = new Date("2026-03-01T10:00:00.000Z"); - await prisma.user.update({ - where: { id: OWNER }, - data: { homeLat: 51.5, homeLon: 7.2, homeLabel: "Bochum, Germany" }, - }); - await prisma.environmentTravelLocation.create({ - data: { - userId: OWNER, - startDate: "2026-08-01", - endDate: "2026-08-10", - lat: 38.7, - lon: -9.1, - label: "Lisbon, Portugal", - updatedAt: stamp, - }, - }); - await prisma.environmentContext.create({ - data: { - userId: OWNER, - date: "2026-08-02", - lat: 38.7, - lon: -9.1, - locationLabel: "Lisbon, Portugal", - source: "TRAVEL", - }, - }); - - const sent: string[] = []; - setGlobalBoss({ - send: async (_q: string, payload: { userId?: string }) => { - if (payload.userId) sent.push(payload.userId); - return "job"; - }, - } as never); - const discovery = await enqueueBootTimeFreeTextEncryptionBackfill(); - expect(discovery.error).toBeNull(); - expect(sent).toEqual([OWNER]); - - const summary = await runFreeTextEncryptionBackfillForUser(OWNER); - expect(summary).toMatchObject({ - homeLocationsMigrated: 1, - travelLocationsMigrated: 1, - dayLocationsMigrated: 1, - }); - - const user = await prisma.user.findUniqueOrThrow({ where: { id: OWNER } }); - expect(user.homeLat).toBeNull(); - expect(openLocation(user.homeLocationEncrypted!)).toEqual({ - lat: 51.5, - lon: 7.2, - label: "Bochum, Germany", - }); - const travel = await prisma.environmentTravelLocation.findFirstOrThrow({ - where: { userId: OWNER }, - }); - expect(travel.lat).toBeNull(); - expect(travel.updatedAt).toEqual(stamp); - expect(openLocation(travel.locationEncrypted!).label).toBe( - "Lisbon, Portugal", - ); - const day = await prisma.environmentContext.findFirstOrThrow({ - where: { userId: OWNER }, - }); - expect([day.lat, day.lon, day.locationLabel]).toEqual([null, null, null]); - expect(openLocation(day.locationEncrypted!).lat).toBe(38.7); - - // Converged: nothing readable is left to find. - expect(await runFreeTextEncryptionBackfillForUser(OWNER)).toMatchObject({ - homeLocationsMigrated: 0, - travelLocationsMigrated: 0, - dayLocationsMigrated: 0, - }); - sent.length = 0; - await enqueueBootTimeFreeTextEncryptionBackfill(); - expect(sent).toEqual([]); - const remaining = await prisma.$queryRaw<{ n: bigint }[]>` - SELECT (SELECT count(*) FROM users WHERE home_lat IS NOT NULL) - + (SELECT count(*) FROM environment_travel_locations WHERE lat IS NOT NULL) - + (SELECT count(*) FROM environment_contexts WHERE lat IS NOT NULL) AS n`; - expect(Number(remaining[0].n)).toBe(0); - }); -}); - describe("the environment backup with sealed locations", () => { async function seedDay() { const prisma = getPrismaClient(); @@ -383,7 +289,7 @@ describe("the environment backup with sealed locations", () => { const restored = await prisma.environmentContext.findFirstOrThrow({ where: { userId: OWNER }, }); - expect([restored.lat, restored.locationLabel]).toEqual([null, null]); + expect(restored).not.toHaveProperty("lat"); expect(openLocation(restored.locationEncrypted!)).toEqual({ lat: 38.7, lon: -9.1, diff --git a/tests/integration/prisma-schema-name-drift.test.ts b/tests/integration/prisma-schema-name-drift.test.ts index 9865cb93a..cff5e57c1 100644 --- a/tests/integration/prisma-schema-name-drift.test.ts +++ b/tests/integration/prisma-schema-name-drift.test.ts @@ -146,9 +146,6 @@ describe("environment context — the write the enum drift blocked", () => { create: { userId: user.id, date, - lat: 52.5, - lon: 13.4, - locationLabel: "Somewhere", source, tempMean: 12.5, fetchedAt: new Date(), @@ -174,9 +171,6 @@ describe("environment context — the write the enum drift blocked", () => { create: { userId: user.id, date: "2026-03-01", - lat: 52.5, - lon: 13.4, - locationLabel: "Somewhere", source: "HOME", fetchedAt: new Date(), }, @@ -200,9 +194,6 @@ describe("environment context — the write the enum drift blocked", () => { data: { userId: user.id, date: "2026-04-01", - lat: 52.5, - lon: 13.4, - locationLabel: "Somewhere", source: "TRAVEL", fetchedAt: new Date(), },