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(), },