mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
Merge branch 'v143/w0' into release/v1.43.0
This commit is contained in:
commit
f75a2fe343
131 files changed
+8346
-1223
No files matched your search
+3338
-267
File diff suppressed because it is too large.
Load diff
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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'));
|
||||
@@ -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:<HK>:<YYYY-MM-DD>T<HH>` 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'));
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
+371
-55
@@ -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:<HK>:<YYYY-MM-DD>T<HH>` 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: `{ "<section key>": "<ISO instant>" }`. 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")
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
+2
-15
@@ -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",
|
||||
|
||||
@@ -47,8 +47,6 @@ const SKIP_DIRS = new Set([
|
||||
* runs on the server where the property always exists.
|
||||
*/
|
||||
const SERVER_SIDE_EXEMPT = new Set([
|
||||
// Next middleware — runs on the edge runtime, never in the page.
|
||||
"src/proxy.ts",
|
||||
// The one module allowed to reach for the platform call.
|
||||
"src/lib/random-id.ts",
|
||||
]);
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* The routes that publish a contract and do not yet carry its effect.
|
||||
*
|
||||
* v1.43 fixed its API contract before the features behind it were built: the
|
||||
* routes exist, authenticate, gate and validate as documented, and answer
|
||||
* `501 not_implemented` (`src/lib/http/not-implemented.ts`) until their
|
||||
* feature lands. That is safe only while nothing reaches them, and nothing
|
||||
* should: every client path sits behind a `features` flag that stays `false`.
|
||||
*
|
||||
* This list is the debt register. A feature that lands removes its route here
|
||||
* in the same change; a new stub has to be named here to exist. The release is
|
||||
* not ready to tag while the list is not empty, which the last case below
|
||||
* makes impossible to miss: it states the count it was written against, so
|
||||
* the change that empties the list also has to touch this file.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { walkSourceFiles } from "./helpers/source-files";
|
||||
|
||||
const API_ROOT = join(process.cwd(), "src", "app", "api");
|
||||
|
||||
/** Route file (under `src/app/api`) to the operations it still stubs. */
|
||||
const CONTRACT_STUBS: Readonly<Record<string, readonly string[]>> = {
|
||||
"admin/maintenance/pulse-day-mean/route.ts": [
|
||||
"admin.pulse_day_mean.get",
|
||||
"admin.pulse_day_mean.post",
|
||||
],
|
||||
"admin/support-report/route.ts": ["admin.support_report.get"],
|
||||
"anamnesis/audit-c/[id]/route.ts": ["anamnesis.audit_c.delete"],
|
||||
"anamnesis/audit-c/route.ts": ["anamnesis.audit_c.create"],
|
||||
"anamnesis/life-circumstances/ai-sections/route.ts": [
|
||||
"anamnesis.life_circumstances.ai_sections.patch",
|
||||
],
|
||||
"anamnesis/life-circumstances/confirm/route.ts": [
|
||||
"anamnesis.life_circumstances.confirm",
|
||||
],
|
||||
"anamnesis/life-circumstances/route.ts": ["anamnesis.life_circumstances.get"],
|
||||
"auth/claim/sso/route.ts": ["profile_claim.sso.start"],
|
||||
"auth/me/ai-usage/route.ts": ["auth.me.ai_usage.put"],
|
||||
"biomarkers/[id]/merge/route.ts": ["labs.biomarker.merge"],
|
||||
"dawarich/connect/route.ts": ["dawarich.connect"],
|
||||
"dawarich/disconnect/route.ts": ["dawarich.disconnect"],
|
||||
"dawarich/places/route.ts": ["dawarich.places.get"],
|
||||
"dawarich/settings/route.ts": ["dawarich.settings.patch"],
|
||||
"dawarich/status/route.ts": ["dawarich.status.get"],
|
||||
"dawarich/sync/route.ts": ["dawarich.sync"],
|
||||
"dawarich/test/route.ts": ["dawarich.test"],
|
||||
"environment/travel/suggestions/[id]/dismiss/route.ts": [
|
||||
"environment.travel_suggestion.dismiss",
|
||||
],
|
||||
"environment/travel/suggestions/route.ts": [
|
||||
"environment.travel_suggestions.get",
|
||||
],
|
||||
"labs/[id]/links/route.ts": ["labs.link."],
|
||||
"medications/[id]/ingredients/extract/route.ts": [
|
||||
"medications.ingredients.extract",
|
||||
],
|
||||
"medications/[id]/ingredients/route.ts": ["medications.ingredients.put"],
|
||||
"medications/[id]/links/route.ts": ["medication.link."],
|
||||
"nutrients/catalog/route.ts": ["nutrients.catalog.get"],
|
||||
};
|
||||
|
||||
/** The size of the register when it was written; see the header. */
|
||||
const REGISTER_SIZE = 24;
|
||||
|
||||
function routeFiles(): string[] {
|
||||
return walkSourceFiles(API_ROOT, { floor: 400, extensions: [".ts"] })
|
||||
.filter((rel) => rel.endsWith("route.ts"))
|
||||
.filter((rel) => !rel.includes("__tests__"));
|
||||
}
|
||||
|
||||
describe("contract stubs answering 501", () => {
|
||||
const routes = routeFiles();
|
||||
const found = routes
|
||||
.filter((rel) =>
|
||||
/\bnotImplemented\s*\(/.test(readFileSync(join(API_ROOT, rel), "utf8")),
|
||||
)
|
||||
.sort();
|
||||
|
||||
it("sweeps the whole route tree", () => {
|
||||
// The register can and should reach zero; the tree it is checked against
|
||||
// cannot. A matcher that read nothing would make the register look clean.
|
||||
expect(routes.length).toBeGreaterThan(400);
|
||||
});
|
||||
|
||||
it("are exactly the registered ones", () => {
|
||||
expect(found).toEqual(Object.keys(CONTRACT_STUBS).sort());
|
||||
});
|
||||
|
||||
it("each still names the operations it was registered with", () => {
|
||||
for (const [rel, operations] of Object.entries(CONTRACT_STUBS)) {
|
||||
const source = readFileSync(join(API_ROOT, rel), "utf8");
|
||||
for (const operation of operations) {
|
||||
expect(source, `${rel} no longer stubs ${operation}`).toContain(
|
||||
`notImplemented(${operation.endsWith(".") ? "`" : '"'}${operation}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("authenticate before they answer", () => {
|
||||
for (const rel of found) {
|
||||
const source = readFileSync(join(API_ROOT, rel), "utf8");
|
||||
expect(
|
||||
/\b(requireAuth|requireRecordAuth|requireAdmin|claimGate)\s*\(/.test(
|
||||
source,
|
||||
),
|
||||
`${rel} answers 501 without authenticating first`,
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("are still the register the release was planned with", () => {
|
||||
// Empty before the release is tagged: set REGISTER_SIZE to 0 with the
|
||||
// change that fills the last route.
|
||||
expect(Object.keys(CONTRACT_STUBS)).toHaveLength(REGISTER_SIZE);
|
||||
});
|
||||
});
|
||||
@@ -43,13 +43,7 @@ const EXCLUDED: Record<string, string> = {
|
||||
};
|
||||
|
||||
/** Columns written through the location helper rather than `row.<column>`. */
|
||||
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", () => {
|
||||
|
||||
@@ -258,6 +258,10 @@ const UNPUBLISHED: Readonly<Record<string, Exemption>> = {
|
||||
kind: "adminConsole",
|
||||
methods: ["POST"],
|
||||
},
|
||||
"/api/admin/maintenance/pulse-day-mean": {
|
||||
kind: "adminConsole",
|
||||
methods: ["GET", "POST"],
|
||||
},
|
||||
"/api/admin/monitoring/glitchtip-test": {
|
||||
kind: "adminConsole",
|
||||
methods: ["POST"],
|
||||
@@ -284,6 +288,7 @@ const UNPUBLISHED: Readonly<Record<string, Exemption>> = {
|
||||
methods: ["POST"],
|
||||
},
|
||||
"/api/admin/status": { kind: "adminConsole", methods: ["GET"] },
|
||||
"/api/admin/support-report": { kind: "adminConsole", methods: ["GET"] },
|
||||
"/api/admin/tokens": { kind: "adminConsole", methods: ["GET"] },
|
||||
"/api/admin/users": { kind: "adminConsole", methods: ["GET"] },
|
||||
"/api/admin/users/{id}": { kind: "adminConsole", methods: ["PUT"] },
|
||||
|
||||
@@ -83,6 +83,8 @@ const UNAUTHENTICATED: Readonly<Record<string, string>> = {
|
||||
"Previews a managed-profile handover link for a person who has no credential yet; the one-time token in the body is the credential.",
|
||||
"/api/auth/claim":
|
||||
"Turns a managed profile into the claimant's own account and creates their first credential; the one-time token in the body authorises it.",
|
||||
"/api/auth/claim/sso":
|
||||
"Starts claiming a managed profile through the identity provider; the claimant has no credential yet, and the one-time token in the body authorises it.",
|
||||
// The tombstones, sourced from the registry rather than restated one by one.
|
||||
// A retired path answers 410 from the proxy before any handler runs, and that
|
||||
// answer never reads the request's cookies or Authorization header — so it is
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* An incoming `x-request-id` is client-controlled. It is echoed on the
|
||||
* response and written into every log line of the request, so it is adopted
|
||||
* only when it is a plain correlation token — `^[A-Za-z0-9-]{8,64}$` — and
|
||||
* replaced with a fresh id otherwise. Both places that read it are covered:
|
||||
* the proxy (every proxied path) and `apiHandler` (which also serves the
|
||||
* routes the proxy matcher leaves out).
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
vi.mock("@/lib/process-type", () => ({
|
||||
shouldRunWeb: () => true,
|
||||
shouldRunWorker: () => true,
|
||||
getProcessType: () => "all",
|
||||
}));
|
||||
|
||||
import { proxy } from "../proxy";
|
||||
import { apiHandler } from "@/lib/api-handler";
|
||||
import { getEvent } from "@/lib/logging/context";
|
||||
|
||||
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
||||
|
||||
const VALID = ["3f9a2c1b", "abcDEF12-3456-7890", "a".repeat(64)];
|
||||
const INVALID = [
|
||||
"short",
|
||||
"a".repeat(65),
|
||||
"id with spaces",
|
||||
"id_underscore_x",
|
||||
"<script>alert(1)</script>",
|
||||
'x" level="error',
|
||||
"ümläut-id-1234",
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubEnv("NODE_ENV", "production");
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
function proxied(requestId: string): NextRequest {
|
||||
return new NextRequest("http://localhost/api/version", {
|
||||
headers: { "x-request-id": requestId },
|
||||
});
|
||||
}
|
||||
|
||||
describe("proxy: incoming x-request-id", () => {
|
||||
it.each(VALID)("adopts the well-formed id %s", (id) => {
|
||||
const res = proxy(proxied(id));
|
||||
expect(res.headers.get("x-request-id")).toBe(id);
|
||||
});
|
||||
|
||||
it.each(INVALID)("replaces the malformed id %j with a fresh one", (id) => {
|
||||
const res = proxy(proxied(id));
|
||||
const echoed = res.headers.get("x-request-id");
|
||||
expect(echoed).not.toBe(id);
|
||||
expect(echoed).toMatch(UUID);
|
||||
// The rewritten request header the route sees carries the same fresh id.
|
||||
expect(res.headers.get("x-middleware-request-x-request-id")).toBe(echoed);
|
||||
});
|
||||
});
|
||||
|
||||
describe("apiHandler: incoming x-request-id", () => {
|
||||
function call(id: string) {
|
||||
let logged: string | undefined;
|
||||
const wrapped = apiHandler(async (_request: NextRequest) => {
|
||||
logged = getEvent()?.getRequestId();
|
||||
return NextResponse.json({ data: "ok" });
|
||||
});
|
||||
return wrapped(
|
||||
new NextRequest("http://localhost/api/measurements/batch", {
|
||||
method: "POST",
|
||||
headers: { "x-request-id": id },
|
||||
}),
|
||||
).then((res) => ({ res, logged }));
|
||||
}
|
||||
|
||||
it.each(VALID)("logs and echoes the well-formed id %s", async (id) => {
|
||||
const { res, logged } = await call(id);
|
||||
expect(logged).toBe(id);
|
||||
expect(res.headers.get("x-request-id")).toBe(id);
|
||||
});
|
||||
|
||||
it.each(INVALID)("never logs or echoes the malformed id %j", async (id) => {
|
||||
const { res, logged } = await call(id);
|
||||
expect(logged).not.toBe(id);
|
||||
expect(logged).toMatch(UUID);
|
||||
expect(res.headers.get("x-request-id")).toBe(logged);
|
||||
});
|
||||
});
|
||||
@@ -96,6 +96,9 @@ describe("S1 — the direct session-resolution set is frozen", () => {
|
||||
"refuses to claim a profile over a live session",
|
||||
"app/api/auth/claim/preview/route.ts":
|
||||
"refuses to preview a handover link over a live session",
|
||||
// v1.43 (#959) — the single-sign-on claim start, same argument.
|
||||
"app/api/auth/claim/sso/route.ts":
|
||||
"refuses to start a single-sign-on claim over a live session",
|
||||
// Anonymous IdP callback. Probes for an already-valid session so a
|
||||
// replayed callback redirects instead of minting a second identity.
|
||||
"app/api/auth/oidc/callback/route.ts":
|
||||
|
||||
@@ -853,7 +853,7 @@ const DELEGABLE_ROUTES: Record<string, DelegableEntry> = {
|
||||
},
|
||||
"app/api/biomarkers/route.ts": {
|
||||
domain: "labs",
|
||||
why: "The record's biomarker catalogue: the analytes this account tracks and the ranges it tracks them against. Adding a marker is a delegable write; the PUT and DELETE on the sibling route are refused.",
|
||||
why: "The record's biomarker catalogue: the analytes this account tracks and the ranges it tracks them against. Adding a marker is a delegable write; the PUT and DELETE on the sibling route are manage-level.",
|
||||
},
|
||||
"app/api/biomarkers/[id]/route.ts": {
|
||||
domain: "labs",
|
||||
@@ -887,6 +887,10 @@ const DELEGABLE_ROUTES: Record<string, DelegableEntry> = {
|
||||
domain: "profile",
|
||||
why: "The record's health-profile facts, read through `readHealthProfileFacts(user.id)`. The module holds write helpers, and the GET calls none of them.",
|
||||
},
|
||||
"app/api/anamnesis/life-circumstances/route.ts": {
|
||||
domain: "profile",
|
||||
why: "The record's life circumstances (v1.43), the structured sibling of the facts above and under the same `profile` grant. Read-only: the section switches, confirmations and AUDIT-C are written through their own routes, none of them delegable.",
|
||||
},
|
||||
"app/api/profile/summary/route.ts": {
|
||||
domain: "profile",
|
||||
why: "The bounded shared profile read combines only the record's allergies, family history, and current lifestyle facts. It carries no settings, AI configuration, mutations, or encrypted free text.",
|
||||
@@ -931,6 +935,10 @@ const DELEGABLE_ROUTES: Record<string, DelegableEntry> = {
|
||||
domain: "measurements",
|
||||
why: "The record's sleep rhythm, built from `buildSleepRhythm(user.id)` and gated on the record's sleep module.",
|
||||
},
|
||||
"app/api/nutrients/catalog/route.ts": {
|
||||
domain: "measurements",
|
||||
why: "The nutrient catalogue as the composition form reads it (v1.43): codes, units and upper limits, labels in the record's language. Reference data the record's module gate opens; nothing of the record itself.",
|
||||
},
|
||||
"app/api/nutrients/route.ts": {
|
||||
domain: "measurements",
|
||||
why: "The record's nutrient overview. It reads the last ingest audit row to date the data — integration-adjacent metadata, and no credential, endpoint or token crosses the wire.",
|
||||
@@ -1601,8 +1609,8 @@ const DELEGABLE_MANAGE_ROUTES: Record<string, ManageEntry> = {
|
||||
},
|
||||
"app/api/biomarkers/[id]/route.ts": {
|
||||
domain: "labs",
|
||||
conditions: ["C4"],
|
||||
why: "Editing an analyte's reference range, the one admitted edit whose error is silent, which is why C4 puts it in the feed. The DELETE beside it stays refused.",
|
||||
conditions: ["C3", "C4"],
|
||||
why: "Editing an analyte's reference range, the one admitted edit whose error is silent, which is why C4 puts it in the feed. The DELETE beside it resolves through the same labs authority since v1.43 instead of a bare requireAuth; it hard-deletes the marker with its readings, so C3 files the name, unit and reading count it took.",
|
||||
},
|
||||
"app/api/allergies/route.ts": {
|
||||
domain: "profile",
|
||||
@@ -2104,8 +2112,13 @@ const ACTOR_ROUTES: Record<string, string> = {
|
||||
*
|
||||
* v1.42.4 -- the Labs list's per-marker overview joins the record list
|
||||
* beside `GET /api/labs`, the rows it groups. 255 -> 256.
|
||||
*
|
||||
* v1.43 -- two reads join the record list: the life circumstances beside the
|
||||
* facts (`profile`) and the nutrient catalogue beside the nutrient reads
|
||||
* (`measurements`). The biomarker DELETE joins the manage literal on a module
|
||||
* that was already there. 256 -> 258.
|
||||
*/
|
||||
const FROZEN_ENTRY_COUNT = 256;
|
||||
const FROZEN_ENTRY_COUNT = 258;
|
||||
|
||||
/**
|
||||
* The two surfaces that authenticate a Bearer token outside `requireAuth` —
|
||||
@@ -2866,7 +2879,7 @@ describe("(g) the MANAGE route set is frozen", () => {
|
||||
|
||||
it("keeps the admitted mutation inventory complete and discoverable", () => {
|
||||
expect(ADMITTED_MUTATING_HANDLERS.length).toBeGreaterThan(0);
|
||||
expect(ADMITTED_MUTATING_HANDLERS.length).toBe(87);
|
||||
expect(ADMITTED_MUTATING_HANDLERS.length).toBe(88);
|
||||
|
||||
const expected = ADMITTED_MUTATING_HANDLERS.map(
|
||||
({ handlerModule, action, level }) =>
|
||||
|
||||
@@ -26,7 +26,7 @@ describe("sharing and handler inventories", () => {
|
||||
});
|
||||
|
||||
it("keeps every admitted mutation uniquely addressable and fully controlled", () => {
|
||||
expect(ADMITTED_MUTATING_HANDLERS).toHaveLength(87);
|
||||
expect(ADMITTED_MUTATING_HANDLERS).toHaveLength(88);
|
||||
expect(
|
||||
new Set(
|
||||
ADMITTED_MUTATING_HANDLERS.map(
|
||||
|
||||
@@ -754,9 +754,20 @@ const OCR_COLLECTION_CONSUMERS: Readonly<Record<string, readonly string[]>> = {
|
||||
skipped: ["result.skipped.length"],
|
||||
};
|
||||
|
||||
/**
|
||||
* `/api/<provider>/sync` routes that only queue a background job and answer
|
||||
* 202 `{ enqueued }`: nothing is written while the request runs, so there is
|
||||
* no written outcome to resolve. Each entry says why.
|
||||
*/
|
||||
const QUEUE_ONLY_SYNC_ROUTES: Readonly<Record<string, string>> = {
|
||||
dawarich:
|
||||
"Dawarich daily values are fetched by the dawarich-sync job; the route answers 202 { enqueued } and the outcome reaches the card through the connection health, not this response.",
|
||||
};
|
||||
|
||||
function discoveredSyncRoutes(): Array<{ id: string; path: string }> {
|
||||
return readdirSync(API_ROOT, { withFileTypes: true }).flatMap((entry) => {
|
||||
if (!entry.isDirectory()) return [];
|
||||
if (entry.name in QUEUE_ONLY_SYNC_ROUTES) return [];
|
||||
const path = join(API_ROOT, entry.name, "sync", "route.ts");
|
||||
return existsSync(path) ? [{ id: entry.name, path }] : [];
|
||||
});
|
||||
@@ -1078,6 +1089,13 @@ describe("written-outcome response consumer guard", () => {
|
||||
).toBe(true);
|
||||
}
|
||||
}
|
||||
for (const [id, reason] of Object.entries(QUEUE_ONLY_SYNC_ROUTES)) {
|
||||
expect(
|
||||
existsSync(join(API_ROOT, id, "sync", "route.ts")),
|
||||
`${id} is listed as a queue-only sync route but has none`,
|
||||
).toBe(true);
|
||||
expect(reason.length).toBeGreaterThan(40);
|
||||
}
|
||||
const discovered = discoveredSyncRoutes().sort((a, b) =>
|
||||
a.id.localeCompare(b.id),
|
||||
);
|
||||
|
||||
@@ -252,6 +252,11 @@ const EXEMPT_ROUTES: ReadonlyArray<string> = [
|
||||
"src/app/api/labs/restore/route.ts",
|
||||
"src/app/api/biomarkers/route.ts",
|
||||
"src/app/api/biomarkers/[id]/route.ts",
|
||||
// v1.43 — merging markers and filing a reading against its report or visit
|
||||
// are the same row-store operations: the history and its links must stay
|
||||
// consistent while the Labs surface is hidden.
|
||||
"src/app/api/biomarkers/[id]/merge/route.ts",
|
||||
"src/app/api/labs/[id]/links/route.ts",
|
||||
// v1.18.9 — the Lab-OCR ingestion routes share the LabResult data-layer
|
||||
// reasoning: the labs module gates the SURFACE (the Labs page + scan
|
||||
// affordance), not the row store. The extract route is read-only vision
|
||||
@@ -315,6 +320,9 @@ const EXEMPT_ROUTES: ReadonlyArray<string> = [
|
||||
// SURFACE (the detail-page tab + the insights summary), not the row store.
|
||||
"src/app/api/medications/[id]/efficacy/route.ts",
|
||||
"src/app/api/medications/[id]/efficacy/target/route.ts",
|
||||
// v1.43 — a medication's links to its visits, documents and indication are
|
||||
// raw link CRUD over the person's own rows, like the vaccination links.
|
||||
"src/app/api/medications/[id]/links/route.ts",
|
||||
// ── INFRA / UI-ONLY ───────────────────────────────────────────────
|
||||
// Static FHIR CapabilityStatement — server metadata, no user data.
|
||||
"src/app/api/fhir/metadata/route.ts",
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* `GET/POST /api/admin/maintenance/pulse-day-mean` — the pulse day-mean
|
||||
* method (#1107 follow-up).
|
||||
*
|
||||
* GET answers the active method, when it was applied and the last dry run
|
||||
* (status and result: per-account labels only, never an id). POST
|
||||
* `{ action: "dry_run" | "apply" }` answers 202 `{ enqueued }`: a dry run
|
||||
* computes both methods for every account and day and writes nothing but its
|
||||
* result; `apply` needs a successful dry run from the last seven days (409
|
||||
* `maintenance.pulseDayMean.dryRunRequired`), switches the method and
|
||||
* rebuilds the pulse rollups. 409 `maintenance.pulseDayMean.busy` while a run
|
||||
* is active. Admin only, cookie session only.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, requireAdmin } from "@/lib/api-handler";
|
||||
import { returnAllZodIssues, safeJson } from "@/lib/api-response";
|
||||
import { notImplemented } from "@/lib/http/not-implemented";
|
||||
import { pulseDayMeanActionSchema } from "@/lib/validations/pulse-day-mean";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
await requireAdmin();
|
||||
return notImplemented("admin.pulse_day_mean.get");
|
||||
});
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
await requireAdmin();
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
const parsed = pulseDayMeanActionSchema.safeParse(body);
|
||||
if (!parsed.success) return returnAllZodIssues(parsed.error, 422);
|
||||
|
||||
return notImplemented("admin.pulse_day_mean.post");
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* `GET /api/admin/support-report` — a redacted plain-text report an operator
|
||||
* can paste into an issue: build, database, configuration as yes/no, jobs,
|
||||
* connections by health state, AI ledger and usage levels, notifications,
|
||||
* backups and the most frequent recent error actions. Never an email address,
|
||||
* a user id, a value, a token or a foreign host name; accounts appear as
|
||||
* "Account 1..n". Admin only, cookie session only.
|
||||
*/
|
||||
import { apiHandler, requireAdmin } from "@/lib/api-handler";
|
||||
import { notImplemented } from "@/lib/http/not-implemented";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
await requireAdmin();
|
||||
return notImplemented("admin.support_report.get");
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* `DELETE /api/anamnesis/audit-c/{id}` — remove an AUDIT-C entry (#614).
|
||||
* Soft delete; answers `{ id, removedAt }`, 404 `anamnesis.auditC.notFound`.
|
||||
*
|
||||
* Contract authority: `requireRecordAuth("manage", "profile")`. Until AUDIT-C
|
||||
* lands the stub resolves the caller's own record only.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { notImplemented } from "@/lib/http/not-implemented";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
export const DELETE = apiHandler(
|
||||
async (_request: NextRequest, { params }: RouteParams) => {
|
||||
await requireAuth();
|
||||
await params;
|
||||
return notImplemented("anamnesis.audit_c.delete");
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* `POST /api/anamnesis/audit-c` — record an AUDIT-C (#614).
|
||||
*
|
||||
* `{ items: [0..4, 0..4, 0..4], takenAt?, tz?, locale?, source, externalId? }`.
|
||||
* 201 `{ result: AuditCResult }` with the sum and the screening cut-off for
|
||||
* the entry's language and the profile's sex (null without one); a replay
|
||||
* with the same `externalId` answers 200 with the same result. Stored beside
|
||||
* the mental-health screeners but never listed by
|
||||
* `/api/mental-health/assessments`. At most 30 an hour.
|
||||
*
|
||||
* Contract authority: `requireRecordAuth("manage", "profile")`. Until AUDIT-C
|
||||
* lands the stub resolves the caller's own record only.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiError, returnAllZodIssues, safeJson } from "@/lib/api-response";
|
||||
import { notImplemented } from "@/lib/http/not-implemented";
|
||||
import { withIdempotency } from "@/lib/idempotency";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { auditCRequestSchema } from "@/lib/validations/life-circumstances";
|
||||
|
||||
export const POST = apiHandler(withIdempotency<[NextRequest]>(postAuditC));
|
||||
|
||||
async function postAuditC(request: NextRequest): Promise<Response> {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const rl = await checkRateLimit(`audit-c:${user.id}`, 30, 60 * 60 * 1000);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Too many entries, try again later", 429, {
|
||||
errorCode: "anamnesis.auditC.rateLimited",
|
||||
});
|
||||
}
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 4 * 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
const parsed = auditCRequestSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return returnAllZodIssues(parsed.error, 422, {
|
||||
errorCode: "anamnesis.auditC.invalid",
|
||||
});
|
||||
}
|
||||
|
||||
return notImplemented("anamnesis.audit_c.create");
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* `PATCH /api/anamnesis/life-circumstances/ai-sections` — switch which
|
||||
* life-circumstance sections may reach the AI (#614).
|
||||
*
|
||||
* `{ sections: { <SECTION>: boolean }, baseUpdatedAt? }` with at least one
|
||||
* key, over `SMOKING_STATUS`, `ALCOHOL_PATTERN`, `SHIFT_SCHEDULE`, `ACTIVITY`,
|
||||
* `WORK`, `HABITS`, `GOALS`. Sections not named keep their state. A stale
|
||||
* `baseUpdatedAt` is 409 `about_me_conflict`, the token `about-me` uses.
|
||||
* Own account only.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { returnAllZodIssues, safeJson } from "@/lib/api-response";
|
||||
import { notImplemented } from "@/lib/http/not-implemented";
|
||||
import { lifeAiSectionsPatchSchema } from "@/lib/validations/life-circumstances";
|
||||
|
||||
export const PATCH = apiHandler(async (request: NextRequest) => {
|
||||
await requireAuth();
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 4 * 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
const parsed = lifeAiSectionsPatchSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return returnAllZodIssues(parsed.error, 422, {
|
||||
errorCode: "anamnesis.lifeSections.invalid",
|
||||
});
|
||||
}
|
||||
|
||||
return notImplemented("anamnesis.life_circumstances.ai_sections.patch");
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* `POST /api/anamnesis/life-circumstances/confirm` — "still true" for one
|
||||
* section (#614). `{ section }`; stores the confirmation instant only, never a
|
||||
* new revision, and answers `{ section, lastConfirmedAt }`. Own account only.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { returnAllZodIssues, safeJson } from "@/lib/api-response";
|
||||
import { notImplemented } from "@/lib/http/not-implemented";
|
||||
import { lifeConfirmSchema } from "@/lib/validations/life-circumstances";
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
await requireAuth();
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
const parsed = lifeConfirmSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return returnAllZodIssues(parsed.error, 422, {
|
||||
errorCode: "anamnesis.lifeConfirm.invalid",
|
||||
});
|
||||
}
|
||||
|
||||
return notImplemented("anamnesis.life_circumstances.confirm");
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* `GET /api/anamnesis/life-circumstances` — the six life-circumstance
|
||||
* sections (#614) with their current answers, AI switches, review state,
|
||||
* the derived values (pack-years, activity minutes, free weekdays, screening
|
||||
* hints) and AUDIT-C with its instrument text in the account's language.
|
||||
*
|
||||
* The new fact kinds are served here and only here: `GET /api/anamnesis/facts`
|
||||
* keeps its three kinds for clients that decode that list closed. Delegates
|
||||
* read it under the `profile` grant, like the facts.
|
||||
*/
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { notImplemented } from "@/lib/http/not-implemented";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
await requireRecordAuth("read", "profile");
|
||||
return notImplemented("anamnesis.life_circumstances.get");
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* `POST /api/auth/claim/sso` — start claiming a managed profile through
|
||||
* single sign-on (#959).
|
||||
*
|
||||
* Anonymous, like the password claim beside it. `{ token, username }`: the
|
||||
* link is checked and the username reserved for the round trip, a sealed
|
||||
* `hl_claim` cookie (10 minutes, `httpOnly`, `Secure` through
|
||||
* `shouldEmitSecureCookie()`) carries `{ handoverId, username, exp }`, and the
|
||||
* answer names where to go: `{ redirectTo: "/api/auth/oidc/login?intent=claim" }`.
|
||||
* The OIDC callback binds the identity provider's subject to the profile. The
|
||||
* token never rides a URL. Works on a single-sign-on-only server; 404
|
||||
* `profile_claim.sso_unavailable` when no identity provider is configured.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler } from "@/lib/api-handler";
|
||||
import { returnAllZodIssues, safeJson } from "@/lib/api-response";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { notImplemented } from "@/lib/http/not-implemented";
|
||||
import { claimGate } from "@/lib/managed-profiles/claim-gate";
|
||||
import { claimSsoSchema } from "@/lib/validations/claim-sso";
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
// Refuses a signed-in caller and rate-limits the address, as the password
|
||||
// claim does.
|
||||
const gate = await claimGate(request, "claim", (await getSession()) !== null);
|
||||
if (gate.refusal) return gate.refusal;
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 4 * 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
const parsed = claimSsoSchema.safeParse(body);
|
||||
if (!parsed.success) return returnAllZodIssues(parsed.error, 422);
|
||||
|
||||
return notImplemented("profile_claim.sso.start");
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* `PUT /api/auth/me/ai-usage` — the account's own AI usage level (#1217).
|
||||
*
|
||||
* `{ level: "frugal" | "standard" | "full" | null }`; `null` follows the
|
||||
* instance default. A level above the operator's ceiling is refused with 422
|
||||
* `ai.usage.aboveCeiling`, never capped silently. Answers the resolved
|
||||
* `AiUsageState`, the same block `GET /api/auth/me` publishes as `ai.usage`.
|
||||
* Own account only (cookie or a full-scope token), at most 30 writes an hour.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiError, returnAllZodIssues, safeJson } from "@/lib/api-response";
|
||||
import { notImplemented } from "@/lib/http/not-implemented";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { aiUsagePutSchema } from "@/lib/validations/ai-usage";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const rl = await checkRateLimit(`ai-usage:${user.id}`, 30, 60 * 60 * 1000);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Too many changes, try again later", 429, {
|
||||
errorCode: "ai.usage.rateLimited",
|
||||
});
|
||||
}
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
const parsed = aiUsagePutSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return returnAllZodIssues(parsed.error, 422, {
|
||||
errorCode: "ai.usage.invalid",
|
||||
});
|
||||
}
|
||||
|
||||
return notImplemented("auth.me.ai_usage.put");
|
||||
});
|
||||
@@ -83,7 +83,15 @@ export const dynamic = "force-dynamic";
|
||||
* by design: a feature the server supports is supported for every account,
|
||||
* and a per-account answer belongs in `modules` or `ai`, not here.
|
||||
*/
|
||||
const SERVER_FEATURES = { trackIntake: true } as const;
|
||||
const SERVER_FEATURES = {
|
||||
trackIntake: true,
|
||||
// v1.43 — each turns true in the change that makes its route or request
|
||||
// form work; until then a client must not send it (contract §0.6).
|
||||
cumulativeHourBuckets: false,
|
||||
syncTriggerBackfill: false,
|
||||
biomarkerMerge: false,
|
||||
lifeCircumstances: false,
|
||||
} as const;
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const auth = await requireActorAuth();
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
/**
|
||||
* `DELETE /api/biomarkers/{id}` takes a marker together with every reading
|
||||
* filed under it. It must resolve its record through the same record-scoped
|
||||
* labs authority as the PUT beside it — `requireRecordAuth("manage", "labs")`
|
||||
* — rather than the bare `requireAuth()` it carried before, so the delete is
|
||||
* held to the labs domain, the session fence, and the grant level like every
|
||||
* other labs write.
|
||||
*/
|
||||
|
||||
const requireAuth = vi.fn();
|
||||
const requireRecordAuth = vi.fn();
|
||||
|
||||
vi.mock("@/lib/api-handler", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/lib/api-handler")>();
|
||||
return {
|
||||
...actual,
|
||||
requireAuth: (...args: unknown[]) => requireAuth(...args),
|
||||
requireRecordAuth: (...args: unknown[]) => requireRecordAuth(...args),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
biomarker: { findFirst: vi.fn(), delete: vi.fn() },
|
||||
labResult: { deleteMany: vi.fn() },
|
||||
$transaction: vi.fn(async (ops: Promise<unknown>[]) => Promise.all(ops)),
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/cache/invalidate", () => ({
|
||||
invalidateUserHealthScore: vi.fn(),
|
||||
}));
|
||||
|
||||
import { DELETE } from "../route";
|
||||
import { HttpError } from "@/lib/api-handler";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
const RECORD_USER = { id: "record-1", username: "r", role: "USER" };
|
||||
|
||||
function delReq(): NextRequest {
|
||||
return new NextRequest("http://localhost/api/biomarkers/bm-1", {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
const params = { params: Promise.resolve({ id: "bm-1" }) };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
requireAuth.mockRejectedValue(
|
||||
new HttpError(403, "Not available for this record"),
|
||||
);
|
||||
requireRecordAuth.mockResolvedValue({
|
||||
user: RECORD_USER,
|
||||
actor: { id: "actor-1" },
|
||||
grantId: "grant-1",
|
||||
authMethod: "cookie",
|
||||
});
|
||||
vi.mocked(prisma.biomarker.findFirst).mockResolvedValue({
|
||||
id: "bm-1",
|
||||
userId: "record-1",
|
||||
} as never);
|
||||
vi.mocked(prisma.labResult.deleteMany).mockResolvedValue({
|
||||
count: 2,
|
||||
} as never);
|
||||
vi.mocked(prisma.biomarker.delete).mockResolvedValue({} as never);
|
||||
});
|
||||
|
||||
describe("DELETE /api/biomarkers/[id] record authority", () => {
|
||||
it("resolves the record through requireRecordAuth('manage', 'labs')", async () => {
|
||||
const res = await DELETE(delReq(), params);
|
||||
expect(res.status).toBe(200);
|
||||
expect(requireRecordAuth).toHaveBeenCalledWith("manage", "labs");
|
||||
expect(requireAuth).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("narrows both delete legs to the resolved record", async () => {
|
||||
await DELETE(delReq(), params);
|
||||
expect(prisma.labResult.deleteMany).toHaveBeenCalledWith({
|
||||
where: { userId: "record-1", biomarkerId: "bm-1" },
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses when the record authority refuses, deleting nothing", async () => {
|
||||
requireRecordAuth.mockRejectedValue(
|
||||
new HttpError(403, "Not available for this record"),
|
||||
);
|
||||
const res = await DELETE(delReq(), params);
|
||||
expect(res.status).toBe(403);
|
||||
expect(prisma.labResult.deleteMany).not.toHaveBeenCalled();
|
||||
expect(prisma.biomarker.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* `POST /api/biomarkers/{id}/merge` — fold other markers into this one (D1223).
|
||||
*
|
||||
* `{id}` is the marker that stays. `{ sourceIds, dryRun }`: the readings of
|
||||
* every source move onto the target in one transaction, converted where the
|
||||
* analyte's conversion table knows the unit, the source names become aliases
|
||||
* (`MERGE`), one `biomarker_merges` row is written per source and the sources
|
||||
* are deleted. A pair of units without a known conversion refuses the whole
|
||||
* merge (422 `labs.biomarker.merge.unitMismatch`); nothing moves. `dryRun`
|
||||
* answers the same result and writes nothing. There is no undo.
|
||||
*
|
||||
* Contract authority: `requireRecordAuth("manage", "labs")` with the shared
|
||||
* record-write ceiling. Until the merge lands the stub resolves the caller's
|
||||
* own record only.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiError, returnAllZodIssues, safeJson } from "@/lib/api-response";
|
||||
import { notImplemented } from "@/lib/http/not-implemented";
|
||||
import { withIdempotency } from "@/lib/idempotency";
|
||||
import { checkRecordWriteRateLimit } from "@/lib/rate-limit";
|
||||
import { biomarkerMergeSchema } from "@/lib/validations/biomarker-merge";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
export const POST = apiHandler(
|
||||
withIdempotency<[NextRequest, RouteParams]>(mergeBiomarkers),
|
||||
);
|
||||
|
||||
async function mergeBiomarkers(
|
||||
request: NextRequest,
|
||||
{ params }: RouteParams,
|
||||
): Promise<Response> {
|
||||
const { user } = await requireAuth();
|
||||
const { id } = await params;
|
||||
|
||||
const writeRl = await checkRecordWriteRateLimit(user.id);
|
||||
if (!writeRl.allowed) {
|
||||
return apiError("Too many writes, try again later", 429, {
|
||||
errorCode: "record_write.rate_limited",
|
||||
});
|
||||
}
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 4 * 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
const parsed = biomarkerMergeSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return returnAllZodIssues(parsed.error, 422, {
|
||||
errorCode: "labs.biomarker.merge.invalid",
|
||||
});
|
||||
}
|
||||
if (parsed.data.sourceIds.includes(id)) {
|
||||
return apiError("The target cannot be one of the sources", 422, {
|
||||
errorCode: "labs.biomarker.merge.invalid",
|
||||
});
|
||||
}
|
||||
|
||||
return notImplemented("labs.biomarker.merge");
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
@@ -10,7 +10,10 @@ import {
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { overwriteDetails } from "@/lib/sharing/audit-details";
|
||||
import {
|
||||
destroyedDetails,
|
||||
overwriteDetails,
|
||||
} from "@/lib/sharing/audit-details";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { invalidateUserHealthScore } from "@/lib/cache/invalidate";
|
||||
import {
|
||||
@@ -90,8 +93,8 @@ export const PUT = apiHandler(
|
||||
// v1.37.0 — MANAGE, and the closest call in this domain: a reference range
|
||||
// recolours every reading under it, so this is the one admitted edit whose
|
||||
// error is silent rather than visible. C4 on the audit row is what puts it
|
||||
// in the owner's feed. The DELETE below stays refused — it takes the
|
||||
// readings with it and files an id.
|
||||
// in the owner's feed. The DELETE below resolves through the same labs
|
||||
// authority; it takes the readings with it and files the id.
|
||||
const { user } = await requireRecordAuth("manage", "labs");
|
||||
const { id } = await params;
|
||||
|
||||
@@ -254,7 +257,10 @@ export const PUT = apiHandler(
|
||||
|
||||
export const DELETE = apiHandler(
|
||||
async (request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
// The same record-scoped labs authority as the PUT above. A bare
|
||||
// `requireAuth()` here held the one destructive biomarker write to a
|
||||
// weaker gate than the edit beside it: no labs domain, no grant level.
|
||||
const { user } = await requireRecordAuth("manage", "labs");
|
||||
const { id } = await params;
|
||||
|
||||
const existing = await prisma.biomarker.findFirst({ where: { id } });
|
||||
@@ -267,7 +273,7 @@ export const DELETE = apiHandler(
|
||||
// readings go with it rather than lingering as orphaned, unlinked rows.
|
||||
// Both legs are userId-narrowed so a cross-user id can never reach another
|
||||
// account's data.
|
||||
await prisma.$transaction([
|
||||
const [removedReadings] = await prisma.$transaction([
|
||||
prisma.labResult.deleteMany({
|
||||
where: { userId: user.id, biomarkerId: id },
|
||||
}),
|
||||
@@ -278,7 +284,22 @@ export const DELETE = apiHandler(
|
||||
await auditLog("biomarker.delete", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { biomarkerId: id },
|
||||
// C3: a hard delete files what it destroyed — the marker's name, unit,
|
||||
// and how many readings went with it — so the owner's feed can say what
|
||||
// a manager removed, not just an id that now points at nothing.
|
||||
details: {
|
||||
biomarkerId: id,
|
||||
...destroyedDetails({
|
||||
model: "Biomarker",
|
||||
id,
|
||||
label: existing.name,
|
||||
effectiveAt: existing.createdAt,
|
||||
extra: {
|
||||
unit: existing.unit,
|
||||
readingCount: removedReadings?.count ?? 0,
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
annotate({
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* `POST /api/dawarich/connect` — connect a Dawarich instance (movement
|
||||
* context, daily values only).
|
||||
*
|
||||
* `{ url, apiKey }`; both sealed at rest. The URL must resolve to a public
|
||||
* host unless its origin is listed in `DAWARICH_PRIVATE_ORIGINS` (422
|
||||
* `private_origin_not_approved`). The key is checked against the instance
|
||||
* before anything is stored, then a 30-day backfill starts. Part of the
|
||||
* `environment` module; own account only, ten attempts a minute.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiError, returnAllZodIssues, safeJson } from "@/lib/api-response";
|
||||
import { notImplemented } from "@/lib/http/not-implemented";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { dawarichConnectSchema } from "@/lib/validations/dawarich";
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
const gate = await requireModuleEnabled(user.id, "environment");
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
const rl = await checkRateLimit(`dawarich-connect:${user.id}`, 10, 60_000);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Too many attempts, try again later", 429, {
|
||||
errorCode: "dawarich.rate_limited",
|
||||
});
|
||||
}
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 4 * 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
const parsed = dawarichConnectSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return returnAllZodIssues(parsed.error, 422, {
|
||||
errorCode: "dawarich.invalid",
|
||||
});
|
||||
}
|
||||
|
||||
return notImplemented("dawarich.connect");
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* `POST /api/dawarich/disconnect` — forget the connection; with
|
||||
* `deleteData: true` the stored days go too. Answers
|
||||
* `{ disconnected: true, deletedDays }`. Part of the `environment` module;
|
||||
* own account only.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { returnAllZodIssues, safeJson } from "@/lib/api-response";
|
||||
import { notImplemented } from "@/lib/http/not-implemented";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { dawarichDisconnectSchema } from "@/lib/validations/dawarich";
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
const gate = await requireModuleEnabled(user.id, "environment");
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
const parsed = dawarichDisconnectSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return returnAllZodIssues(parsed.error, 422, {
|
||||
errorCode: "dawarich.invalid",
|
||||
});
|
||||
}
|
||||
|
||||
return notImplemented("dawarich.disconnect");
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* `GET /api/dawarich/places?q=` — the instance's places, to pick the home
|
||||
* place from. Answered from Dawarich live (at most 200, filtered by `q`) and
|
||||
* never stored. 409 `dawarich.not_connected`, 502 `dawarich.unreachable`.
|
||||
* Part of the `environment` module; own account only.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { returnAllZodIssues } from "@/lib/api-response";
|
||||
import { notImplemented } from "@/lib/http/not-implemented";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { dawarichPlacesQuerySchema } from "@/lib/validations/dawarich";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
const gate = await requireModuleEnabled(user.id, "environment");
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
const parsed = dawarichPlacesQuerySchema.safeParse(
|
||||
Object.fromEntries(request.nextUrl.searchParams),
|
||||
);
|
||||
if (!parsed.success) {
|
||||
return returnAllZodIssues(parsed.error, 422, {
|
||||
errorCode: "dawarich.invalid",
|
||||
});
|
||||
}
|
||||
|
||||
return notImplemented("dawarich.places.get");
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* `PATCH /api/dawarich/settings` — the home place (an id on the Dawarich
|
||||
* instance) and the home country (ISO 3166-1 alpha-2) the daily values are
|
||||
* measured against. 422 `dawarich.home_place_unknown` for a place id the
|
||||
* instance does not have. Part of the `environment` module; own account only.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { returnAllZodIssues, safeJson } from "@/lib/api-response";
|
||||
import { notImplemented } from "@/lib/http/not-implemented";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { dawarichSettingsSchema } from "@/lib/validations/dawarich";
|
||||
|
||||
export const PATCH = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
const gate = await requireModuleEnabled(user.id, "environment");
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
const parsed = dawarichSettingsSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return returnAllZodIssues(parsed.error, 422, {
|
||||
errorCode: "dawarich.invalid",
|
||||
});
|
||||
}
|
||||
|
||||
return notImplemented("dawarich.settings.patch");
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* `GET /api/dawarich/status` — whether Dawarich is connected, the home place
|
||||
* and country state, how many days are stored and the connection `health`
|
||||
* (the same shape every connection publishes; no legacy ledger fields).
|
||||
* Part of the `environment` module; own account only.
|
||||
*/
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { notImplemented } from "@/lib/http/not-implemented";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
const gate = await requireModuleEnabled(user.id, "environment");
|
||||
if (!gate.enabled) return gate.response;
|
||||
return notImplemented("dawarich.status.get");
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* `POST /api/dawarich/sync` — fetch the daily values again from `fromDate`
|
||||
* (at most 365 days back; default: the nightly window). 202
|
||||
* `{ enqueued }`; 409 `dawarich.not_connected`. Part of the `environment`
|
||||
* module; own account only.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { returnAllZodIssues, safeJson } from "@/lib/api-response";
|
||||
import { notImplemented } from "@/lib/http/not-implemented";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { dawarichSyncSchema } from "@/lib/validations/dawarich";
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
const gate = await requireModuleEnabled(user.id, "environment");
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
const parsed = dawarichSyncSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return returnAllZodIssues(parsed.error, 422, {
|
||||
errorCode: "dawarich.invalid",
|
||||
});
|
||||
}
|
||||
|
||||
return notImplemented("dawarich.sync");
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* `POST /api/dawarich/test` — probe the connected Dawarich instance and answer
|
||||
* the shared integration probe shape. Part of the `environment` module; own
|
||||
* account only.
|
||||
*/
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { notImplemented } from "@/lib/http/not-implemented";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
|
||||
export const POST = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
const gate = await requireModuleEnabled(user.id, "environment");
|
||||
if (!gate.enabled) return gate.response;
|
||||
return notImplemented("dawarich.test");
|
||||
});
|
||||
@@ -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"),
|
||||
})),
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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
|
||||
? [
|
||||
{
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* `POST /api/environment/travel/suggestions/{id}/dismiss` — never suggest
|
||||
* this run of travel days again. Answers `{ dismissed: true }`; 404
|
||||
* `environment.travel_suggestion.not_found`. Part of the `environment`
|
||||
* module; own account only.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { notImplemented } from "@/lib/http/not-implemented";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
export const POST = apiHandler(
|
||||
async (_request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
await params;
|
||||
const gate = await requireModuleEnabled(user.id, "environment");
|
||||
if (!gate.enabled) return gate.response;
|
||||
return notImplemented("environment.travel_suggestion.dismiss");
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* `GET /api/environment/travel/suggestions` — runs of consecutive Dawarich
|
||||
* travel days no stored trip covers yet, as `{ id, startDate, endDate,
|
||||
* countryCode }`. Accepting one goes through `POST /api/environment/travel`
|
||||
* (the person picks the place); dismissing through the sibling route. Part of
|
||||
* the `environment` module; own account only.
|
||||
*/
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { notImplemented } from "@/lib/http/not-implemented";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
const gate = await requireModuleEnabled(user.id, "environment");
|
||||
if (!gate.enabled) return gate.response;
|
||||
return notImplemented("environment.travel_suggestions.get");
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* `POST /api/labs/{id}/links` — file documents (the report it was read
|
||||
* from) or visits against a lab result.
|
||||
* `DELETE /api/labs/{id}/links` — unfile them.
|
||||
*
|
||||
* `{ targetKind, targetIds }` in, `{ changed, unknown }` out, through the
|
||||
* shared link service like the visit and vaccination link routes; an id that
|
||||
* names nothing this account owns comes back in `unknown`. Own account only,
|
||||
* as the other link routes.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { returnAllZodIssues, safeJson } from "@/lib/api-response";
|
||||
import { notImplemented } from "@/lib/http/not-implemented";
|
||||
import { labResultLinkSchema } from "@/lib/validations/relation-links";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
async function handle(
|
||||
request: NextRequest,
|
||||
{ params }: RouteParams,
|
||||
verb: "link" | "unlink",
|
||||
): Promise<Response> {
|
||||
await requireAuth();
|
||||
await params;
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 16 * 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
const parsed = labResultLinkSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return returnAllZodIssues(parsed.error, 422, {
|
||||
errorCode: "labs.link.invalid",
|
||||
});
|
||||
}
|
||||
|
||||
return notImplemented(`labs.link.${verb}`);
|
||||
}
|
||||
|
||||
export const POST = apiHandler(
|
||||
async (request: NextRequest, context: RouteParams) =>
|
||||
handle(request, context, "link"),
|
||||
);
|
||||
|
||||
export const DELETE = apiHandler(
|
||||
async (request: NextRequest, context: RouteParams) =>
|
||||
handle(request, context, "unlink"),
|
||||
);
|
||||
@@ -21,6 +21,15 @@ vi.mock("@/lib/db", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
const rateLimitMock = vi.hoisted(() => ({ allowed: true }));
|
||||
vi.mock("@/lib/rate-limit", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/rate-limit")>()),
|
||||
checkRecordWriteRateLimit: vi.fn(async () => ({
|
||||
allowed: rateLimitMock.allowed,
|
||||
remaining: rateLimitMock.allowed ? 299 : 0,
|
||||
resetAt: new Date(Date.now() + 60_000),
|
||||
})),
|
||||
}));
|
||||
const tzMock = vi.hoisted(() => ({ zone: "UTC" }));
|
||||
vi.mock("@/lib/tz/resolver", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/tz/resolver")>()),
|
||||
@@ -62,6 +71,7 @@ import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { resolveOrMintBiomarker } from "@/lib/labs/biomarker-store";
|
||||
import { enqueueReminderSatisfy } from "@/lib/jobs/reminder-satisfy";
|
||||
import { checkRecordWriteRateLimit } from "@/lib/rate-limit";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
@@ -90,6 +100,7 @@ function postReq(body: unknown): NextRequest {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
tzMock.zone = "UTC";
|
||||
rateLimitMock.allowed = true;
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(enqueueReminderSatisfy).mockResolvedValue(undefined as never);
|
||||
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
|
||||
@@ -198,3 +209,24 @@ describe("POST /api/labs/ocr/commit dedup across the user's day", () => {
|
||||
expect(prisma.labResult.create).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/labs/ocr/commit write ceiling", () => {
|
||||
it("charges the shared per-account record-write bucket", async () => {
|
||||
const res = await POST(postReq({ rows: [numericRow(95)] }));
|
||||
expect(res.status).toBe(200);
|
||||
expect(checkRecordWriteRateLimit).toHaveBeenCalledWith("user-1");
|
||||
});
|
||||
|
||||
it("answers 429 record_write.rate_limited and writes nothing once exhausted", async () => {
|
||||
rateLimitMock.allowed = false;
|
||||
const res = await POST(postReq({ rows: [numericRow(95)] }));
|
||||
expect(res.status).toBe(429);
|
||||
const body = (await res.json()) as {
|
||||
error: string;
|
||||
meta?: { errorCode?: string };
|
||||
};
|
||||
expect(body.meta?.errorCode).toBe("record_write.rate_limited");
|
||||
expect(prisma.labResult.create).not.toHaveBeenCalled();
|
||||
expect(resolveOrMintBiomarker).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -20,6 +20,7 @@ import { fireAndForget } from "@/lib/logging/fire-and-forget";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
getClientIp,
|
||||
returnAllZodIssues,
|
||||
@@ -30,6 +31,7 @@ import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { invalidateUserHealthScore } from "@/lib/cache/invalidate";
|
||||
import { withIdempotency } from "@/lib/idempotency";
|
||||
import { checkRecordWriteRateLimit } from "@/lib/rate-limit";
|
||||
import { enqueueReminderSatisfy } from "@/lib/jobs/reminder-satisfy";
|
||||
import { emitDataArrival } from "@/lib/arrivals/emit-shared";
|
||||
import { resolveOrMintBiomarker } from "@/lib/labs/biomarker-store";
|
||||
@@ -136,6 +138,17 @@ function inBatchKey(row: OcrCommitRow, tz: string): string {
|
||||
async function commitOcrRows(request: NextRequest) {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
// Shared per-account write ceiling, the same bucket every other lab write
|
||||
// charges (`POST /api/labs`, `POST /api/biomarkers`). A commit can write a
|
||||
// whole report's rows and mint markers on the way, so it was the one lab
|
||||
// write a looping client could repeat without any ceiling.
|
||||
const writeRl = await checkRecordWriteRateLimit(user.id);
|
||||
if (!writeRl.allowed) {
|
||||
return apiError("Too many writes, try again later", 429, {
|
||||
errorCode: "record_write.rate_limited",
|
||||
});
|
||||
}
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 256 * 1024,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* `POST /api/medications/{id}/ingredients/extract` — read a supplement label.
|
||||
*
|
||||
* Multipart `file` (JPEG, PNG, HEIC, WebP or PDF, at most 10 MB). Answers 202
|
||||
* with an AI run; `GET /api/ai-runs/{id}` (`SUPPLEMENT_LABEL_EXTRACT`) carries
|
||||
* a proposal of ingredients, unmatched lines and a serving note. Nothing is
|
||||
* stored until the composition is saved through the PUT beside this route.
|
||||
* Gated by the `supplementLabel` AI capability (a `frugal` usage level makes
|
||||
* it unavailable), six runs an hour, budget reserved like the other document
|
||||
* AI runs. Needs the `nutrients` module.
|
||||
*
|
||||
* Contract authority: `requireRecordAuth("manage", "medications")`. Until the
|
||||
* extraction lands the stub resolves the caller's own record only.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { notImplemented } from "@/lib/http/not-implemented";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
export const POST = apiHandler(
|
||||
async (_request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
await params;
|
||||
const gate = await requireModuleEnabled(user.id, "nutrients");
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
return notImplemented("medications.ingredients.extract");
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* `PUT /api/medications/{id}/ingredients` — a supplement's composition per
|
||||
* unit (tablet, capsule).
|
||||
*
|
||||
* `{ ingredients: [{ nutrient, amount, unit }], source }` replaces the set.
|
||||
* Amounts are stored in the catalogue's canonical unit (an input unit such as
|
||||
* `IU` is kept beside it as `enteredAmount` / `enteredUnit`). The change
|
||||
* re-counts every taken dose of this medication into the day's micronutrients
|
||||
* (`recompute.queued`). Needs the `nutrients` module.
|
||||
*
|
||||
* Contract authority: `requireRecordAuth("manage", "medications")`. Until the
|
||||
* composition lands the stub resolves the caller's own record only.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { returnAllZodIssues, safeJson } from "@/lib/api-response";
|
||||
import { notImplemented } from "@/lib/http/not-implemented";
|
||||
import { withIdempotency } from "@/lib/idempotency";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { medicationIngredientsPutSchema } from "@/lib/validations/medication-ingredients";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
export const PUT = apiHandler(
|
||||
withIdempotency<[NextRequest, RouteParams]>(putIngredients),
|
||||
);
|
||||
|
||||
async function putIngredients(
|
||||
request: NextRequest,
|
||||
{ params }: RouteParams,
|
||||
): Promise<Response> {
|
||||
const { user } = await requireAuth();
|
||||
await params;
|
||||
const gate = await requireModuleEnabled(user.id, "nutrients");
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 16 * 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
const parsed = medicationIngredientsPutSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return returnAllZodIssues(parsed.error, 422, {
|
||||
errorCode: "medications.ingredients.invalid",
|
||||
});
|
||||
}
|
||||
|
||||
return notImplemented("medications.ingredients.put");
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* `POST /api/medications/{id}/links` — file conditions (the indication),
|
||||
* documents or visits against a medication.
|
||||
* `DELETE /api/medications/{id}/links` — unfile them.
|
||||
*
|
||||
* `{ targetKind, targetIds }` in, `{ changed, unknown }` out, through the
|
||||
* shared link service like the visit and vaccination link routes; an id that
|
||||
* names nothing this account owns comes back in `unknown`. Own account only,
|
||||
* as the other link routes.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { returnAllZodIssues, safeJson } from "@/lib/api-response";
|
||||
import { notImplemented } from "@/lib/http/not-implemented";
|
||||
import { medicationLinkSchema } from "@/lib/validations/relation-links";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
async function handle(
|
||||
request: NextRequest,
|
||||
{ params }: RouteParams,
|
||||
verb: "link" | "unlink",
|
||||
): Promise<Response> {
|
||||
await requireAuth();
|
||||
await params;
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 16 * 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
const parsed = medicationLinkSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return returnAllZodIssues(parsed.error, 422, {
|
||||
errorCode: "medication.link.invalid",
|
||||
});
|
||||
}
|
||||
|
||||
return notImplemented(`medication.link.${verb}`);
|
||||
}
|
||||
|
||||
export const POST = apiHandler(
|
||||
async (request: NextRequest, context: RouteParams) =>
|
||||
handle(request, context, "link"),
|
||||
);
|
||||
|
||||
export const DELETE = apiHandler(
|
||||
async (request: NextRequest, context: RouteParams) =>
|
||||
handle(request, context, "unlink"),
|
||||
);
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* `GET /api/nutrients/catalog` — the nutrient catalogue as the forms use it.
|
||||
*
|
||||
* One entry per code: canonical unit, label in the account's language,
|
||||
* whether HealthKit may send it (`syncable`), the input units with their
|
||||
* factor to the canonical unit, and the tolerable upper intake level with its
|
||||
* scope and source (or null). Clients build the composition form from this
|
||||
* and never convert units themselves.
|
||||
*/
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { notImplemented } from "@/lib/http/not-implemented";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireRecordAuth("read", "measurements");
|
||||
const gate = await requireModuleEnabled(user.id, "nutrients");
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
return notImplemented("nutrients.catalog.get");
|
||||
});
|
||||
@@ -53,7 +53,8 @@ function storedDay(date: string, extra: Record<string, unknown> = {}) {
|
||||
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",
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
@@ -54,6 +54,7 @@ import {
|
||||
attachRecordContextEcho,
|
||||
} from "./sharing/record-session-fence";
|
||||
import { refuseOnKeyMismatch } from "./boot/key-mismatch-refusal";
|
||||
import { acceptIncomingRequestId } from "./http/request-id";
|
||||
import {
|
||||
capturedRateLimit,
|
||||
rateLimitResponseHeaders,
|
||||
@@ -229,11 +230,10 @@ export function apiHandler<T extends (...args: any[]) => Promise<Response>>(
|
||||
|
||||
const evt = new WideEventBuilder("http");
|
||||
|
||||
// Propagate x-request-id if present
|
||||
const incomingRequestId = safeRequestProp(
|
||||
request,
|
||||
(r) => r.headers.get("x-request-id"),
|
||||
null,
|
||||
// Propagate x-request-id when it is a well-formed token. A malformed one
|
||||
// never reaches the log or the response; the event keeps its fresh id.
|
||||
const incomingRequestId = acceptIncomingRequestId(
|
||||
safeRequestProp(request, (r) => r.headers.get("x-request-id"), null),
|
||||
);
|
||||
if (incomingRequestId) evt.setRequestId(incomingRequestId);
|
||||
|
||||
|
||||
@@ -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<string> = new Set([
|
||||
"CoachPlan",
|
||||
"CoachReminder",
|
||||
"CycleDayLog",
|
||||
"DawarichDay",
|
||||
"DocumentContentIndex",
|
||||
"DocumentSourceConnection",
|
||||
"DocumentThumbnail",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -184,6 +184,42 @@ const dayNotable = z
|
||||
})
|
||||
.meta({ id: "DayNotable" });
|
||||
|
||||
/**
|
||||
* v1.43 — the day's movement context from Dawarich: daily values only, never
|
||||
* a coordinate or a place name. The record owner's only, like the
|
||||
* environment readings; a delegate never receives it.
|
||||
*/
|
||||
export const dayMovementSchema = z
|
||||
.object({
|
||||
source: z.literal("dawarich"),
|
||||
distance: z.object({
|
||||
unit: z
|
||||
.enum(["km", "mi"])
|
||||
.describe("Resolved from the account's unit preference."),
|
||||
total: z.number().nullable(),
|
||||
driving: z.number().nullable(),
|
||||
train: z.number().nullable(),
|
||||
}),
|
||||
placesVisited: z.number().int().nullable(),
|
||||
awayFromHomeMinutes: z
|
||||
.number()
|
||||
.int()
|
||||
.nullable()
|
||||
.describe("`null` means no coverage that day, not zero."),
|
||||
travel: z
|
||||
.object({
|
||||
isTravelDay: z.boolean(),
|
||||
countryCode: z.string().nullable(),
|
||||
})
|
||||
.nullable(),
|
||||
fetchedAt: z.iso.datetime({ offset: true }),
|
||||
})
|
||||
.meta({
|
||||
id: "DayMovement",
|
||||
description:
|
||||
"Distances to one decimal in `distance.unit`. Hide a null field; never show it as zero.",
|
||||
});
|
||||
|
||||
export const dayResponseSchema = z
|
||||
.object({
|
||||
date: dateKeySchema,
|
||||
@@ -212,6 +248,12 @@ export const dayResponseSchema = z
|
||||
.describe(
|
||||
"Sections the caller cannot see. A section of a switched-off module is left out entirely rather than listed.",
|
||||
),
|
||||
movement: dayMovementSchema
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe(
|
||||
"v1.43 — the day's movement context; absent for a delegate, with the `environment` module off, with no Dawarich connection, and on older servers.",
|
||||
),
|
||||
})
|
||||
.meta({ id: "Day" });
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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) ?? {
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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<AccountSettingColumn, SettingCodec>
|
||||
> = {
|
||||
@@ -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"),
|
||||
|
||||
@@ -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<Record<string, string>> = {
|
||||
* rather than by what is cheapest to write.
|
||||
*/
|
||||
export const COVERAGE_PENDING: Readonly<Record<string, string>> = {
|
||||
// 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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string, string> | null {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
||||
const out: Record<string, string> = {};
|
||||
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<string, string> | 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,
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* The answer of a route whose contract is published but whose behaviour has
|
||||
* not landed on this server yet.
|
||||
*
|
||||
* A route answers this only after it has authenticated the caller, resolved
|
||||
* the record, applied its module gate and validated the request exactly as
|
||||
* the published contract says, so a client already sees the real refusals
|
||||
* (401, 403, 404, 422, 429). What is missing is the effect, and the body says
|
||||
* so plainly instead of pretending: `501` with `errorCode: "not_implemented"`.
|
||||
* Nothing calls these routes in normal use: every client path to them sits
|
||||
* behind a server feature flag that stays `false` until the route is filled.
|
||||
*
|
||||
* `contract-stub-inventory.test.ts` freezes the set of route files that still
|
||||
* answer this; it has to be empty before a release is tagged.
|
||||
*/
|
||||
import { apiError } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
|
||||
export const NOT_IMPLEMENTED_ERROR_CODE = "not_implemented";
|
||||
|
||||
/**
|
||||
* 501 for an operation whose contract exists and whose effect does not yet.
|
||||
* `operation` is a stable `<surface>.<noun>.<verb>` name for the wide event.
|
||||
*/
|
||||
export function notImplemented(operation: string): Response {
|
||||
annotate({
|
||||
action: { name: "contract.not_implemented" },
|
||||
meta: { operation },
|
||||
});
|
||||
return apiError("Not implemented on this server yet", 501, {
|
||||
errorCode: NOT_IMPLEMENTED_ERROR_CODE,
|
||||
operation,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Incoming `x-request-id` acceptance.
|
||||
*
|
||||
* The header is client-controlled, and the value is echoed on the response
|
||||
* and written into every log line of the request. Only a plain correlation
|
||||
* token is adopted — letters, digits and hyphens, 8 to 64 characters, which
|
||||
* covers a UUID and every proxy-generated id in use — so a caller cannot
|
||||
* inject quotes, markup or log-shaped text through it. Anything else is
|
||||
* dropped and the request gets a fresh id.
|
||||
*
|
||||
* Edge-safe: no Node imports, because `src/proxy.ts` reads it too.
|
||||
*/
|
||||
import { randomId } from "@/lib/random-id";
|
||||
|
||||
export const REQUEST_ID_PATTERN = /^[A-Za-z0-9-]{8,64}$/;
|
||||
|
||||
/** The incoming id when it is well-formed, else `null`. */
|
||||
export function acceptIncomingRequestId(
|
||||
value: string | null | undefined,
|
||||
): string | null {
|
||||
if (typeof value !== "string") return null;
|
||||
return REQUEST_ID_PATTERN.test(value) ? value : null;
|
||||
}
|
||||
|
||||
/** The incoming id when it is well-formed, else a fresh UUID. */
|
||||
export function resolveRequestId(value: string | null | undefined): string {
|
||||
return acceptIncomingRequestId(value) ?? randomId();
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -132,7 +132,6 @@ export async function discoverAirQualityHistory(
|
||||
environmentAirQualityEnabled: true,
|
||||
OR: [
|
||||
{ homeLocationEncrypted: { not: null } },
|
||||
{ homeLat: { not: null }, homeLon: { not: null } },
|
||||
{ environmentTravelLocations: { some: {} } },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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<boolean> {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<boolean> {
|
||||
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<boolean> {
|
||||
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<boolean> {
|
||||
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<string>();
|
||||
|
||||
@@ -43,6 +43,8 @@ export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
|
||||
"ai.provider.none",
|
||||
"ai.record.notPermitted",
|
||||
"ai.unavailable",
|
||||
"ai.usage.invalid",
|
||||
"ai.usage.rateLimited",
|
||||
],
|
||||
ai_provider: ["ai_provider.invalid", "ai_provider.no_fields"],
|
||||
aiRuns: [
|
||||
@@ -54,9 +56,13 @@ export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
|
||||
allergy: ["allergy.invalid"],
|
||||
analytics: ["analytics.invalid_query"],
|
||||
anamnesis: [
|
||||
"anamnesis.auditC.invalid",
|
||||
"anamnesis.auditC.rateLimited",
|
||||
"anamnesis.fact.conflict",
|
||||
"anamnesis.fact.currentExists",
|
||||
"anamnesis.fact.invalidValue",
|
||||
"anamnesis.lifeConfirm.invalid",
|
||||
"anamnesis.lifeSections.invalid",
|
||||
],
|
||||
auth: [
|
||||
"auth.admin.required",
|
||||
@@ -118,6 +124,7 @@ export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
|
||||
],
|
||||
"cycle-prefs": ["cycle-prefs.body.invalid_json", "cycle-prefs.invalid"],
|
||||
"disable-coach": ["disable-coach.body.invalid_json"],
|
||||
dawarich: ["dawarich.invalid", "dawarich.rate_limited"],
|
||||
day: ["day.invalid"],
|
||||
demo: ["demo.readOnly"],
|
||||
documents: [
|
||||
@@ -211,10 +218,12 @@ export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
|
||||
labs: [
|
||||
"labs.biomarker.create.invalid",
|
||||
"labs.biomarker.duplicate",
|
||||
"labs.biomarker.merge.invalid",
|
||||
"labs.biomarker.notFound",
|
||||
"labs.biomarker.update.invalid",
|
||||
"labs.biomarker.update.referenceRangeInvalid",
|
||||
"labs.create.invalid",
|
||||
"labs.link.invalid",
|
||||
"labs.ocr.budgetExceeded",
|
||||
"labs.ocr.extractFailed",
|
||||
"labs.ocr.fileTooLarge",
|
||||
@@ -257,6 +266,7 @@ export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
|
||||
medication: [
|
||||
"medication.intake.import.invalid_format",
|
||||
"medication.intake.notTracked",
|
||||
"medication.link.invalid",
|
||||
],
|
||||
medications: [
|
||||
"medications.category.invalid",
|
||||
@@ -269,6 +279,7 @@ export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
|
||||
"medications.course.oneShot",
|
||||
"medications.course.overlap",
|
||||
"medications.course.windowRequired",
|
||||
"medications.ingredients.invalid",
|
||||
"medications.intake.bulk.apple_health_not_mirrored",
|
||||
"medications.intake.bulk.invalid",
|
||||
"medications.intake.bulk.too_large",
|
||||
@@ -435,6 +446,7 @@ export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
|
||||
"modules_conflict",
|
||||
"mood_tag_layout_conflict",
|
||||
"not_configured",
|
||||
"not_implemented",
|
||||
"notification_prefs_conflict",
|
||||
"oidc_only",
|
||||
"private_origin_not_approved",
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
*
|
||||
* Part of the OpenAPI route table; aggregated in `./index.ts`.
|
||||
*/
|
||||
import { HANDOVER_METHODS } from "@/lib/validations/claim-sso";
|
||||
import type { ZodOpenApiObject } from "zod-openapi";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
@@ -521,9 +522,31 @@ const accountPayload = z
|
||||
.describe(
|
||||
"Medications take `trackIntake`: a medication can be kept as a record whose doses are never due, and a dose written against one is refused with `medication.intake.notTracked`.",
|
||||
),
|
||||
cumulativeHourBuckets: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe(
|
||||
"v1.43 — `POST /api/measurements/batch` takes hourly `stats:<HK>:<YYYY-MM-DD>T<HH>` rows for steps, active energy, walking and running distance and flights climbed. Send them only when this is `true`: an older server stores every hour as its own measurement.",
|
||||
),
|
||||
syncTriggerBackfill: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe(
|
||||
'v1.43 — `syncTrigger: "backfill"` is accepted on the measurement batch and the intake bulk. Send it only when this is `true`: an older server refuses the whole batch.',
|
||||
),
|
||||
biomarkerMerge: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe("v1.43 — `POST /api/biomarkers/{id}/merge` is available."),
|
||||
lifeCircumstances: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe(
|
||||
"v1.43 — the life-circumstances routes under `/api/anamnesis/life-circumstances` and `/api/anamnesis/audit-c` are available.",
|
||||
),
|
||||
})
|
||||
.describe(
|
||||
"What this server understands, for a client released apart from it. Constant across accounts; a flag appears once the server supports the feature and stays `true`. Absent on servers older than v1.42.0. A client that meets a flag it does not know ignores it.",
|
||||
"What this server understands, for a client released apart from it. Constant across accounts. A flag that is absent or `false` means this server does not have the feature; a client checks for `true` before it calls a route or sends a request form the flag names. Absent on servers older than v1.42.0. A client that meets a flag it does not know ignores it.",
|
||||
),
|
||||
accountAccess: accountAccessBlock,
|
||||
moduleAccess: moduleAccessMap,
|
||||
@@ -605,7 +628,13 @@ const managedProfileHandoverStatus = z
|
||||
available: z
|
||||
.boolean()
|
||||
.describe(
|
||||
"False on an instance that allows single sign-on only, where a handover cannot be created.",
|
||||
"Whether a handover can be created on this instance. Up to v1.42 false on an instance that allows single sign-on only; from v1.43 true whenever `methods` is not empty.",
|
||||
),
|
||||
methods: z
|
||||
.array(z.enum(HANDOVER_METHODS))
|
||||
.optional()
|
||||
.describe(
|
||||
"v1.43 — how a link can be redeemed here: `password` (not on a single-sign-on-only instance) and `sso` (whenever an identity provider is configured). Absent on older servers.",
|
||||
),
|
||||
open: z
|
||||
.object({
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
documentSuggestResponse,
|
||||
documentSummaryResponse,
|
||||
} from "./documents";
|
||||
import { supplementLabelExtractResult } from "./medications";
|
||||
import { ocrExtractResponse } from "./ocr";
|
||||
import { dataEnvelope, errorEnvelope, stdResponses } from "./shared";
|
||||
|
||||
@@ -35,6 +36,7 @@ const aiRun = z
|
||||
"DOCUMENT_SUMMARY",
|
||||
"DOCUMENT_SUGGEST",
|
||||
"DOCUMENT_EXTRACT",
|
||||
"SUPPLEMENT_LABEL_EXTRACT",
|
||||
]),
|
||||
documentId: z
|
||||
.string()
|
||||
@@ -48,10 +50,11 @@ const aiRun = z
|
||||
documentSummaryResponse,
|
||||
documentSuggestResponse,
|
||||
documentExtractRunResult,
|
||||
supplementLabelExtractResult,
|
||||
])
|
||||
.nullable()
|
||||
.describe(
|
||||
"Present once `status` is `SUCCEEDED`: the body the queuing route answers with synchronously. `DOCUMENT_INDEX` carries `DocumentIndexResponse`, `LABS_OCR_EXTRACT` carries `OcrExtractResponse`, `DOCUMENT_SUMMARY` carries `DocumentSummaryResponse`, `DOCUMENT_SUGGEST` carries `DocumentSuggestResponse`, `DOCUMENT_EXTRACT` carries `DocumentExtractRunResult` (not the extract route's detail body). Decode by `kind`; treat an unknown `kind` as a run this client did not start.",
|
||||
"Present once `status` is `SUCCEEDED`: the body the queuing route answers with synchronously. `DOCUMENT_INDEX` carries `DocumentIndexResponse`, `LABS_OCR_EXTRACT` carries `OcrExtractResponse`, `DOCUMENT_SUMMARY` carries `DocumentSummaryResponse`, `DOCUMENT_SUGGEST` carries `DocumentSuggestResponse`, `DOCUMENT_EXTRACT` carries `DocumentExtractRunResult` (not the extract route's detail body), `SUPPLEMENT_LABEL_EXTRACT` (v1.43) carries `SupplementLabelExtractResult`. Decode by `kind`; treat an unknown `kind` as a run this client did not start.",
|
||||
),
|
||||
error: z
|
||||
.object({
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* OpenAPI route table — AI usage levels (v1.43, #1217).
|
||||
*
|
||||
* Part of the OpenAPI route table; aggregated in `./index.ts`. The request
|
||||
* body reuses the runtime schema from `@/lib/validations/ai-usage`;
|
||||
* `AiUsageState` is the block `GET /api/auth/me` publishes as `ai.usage`
|
||||
* (see `AiAccountBlock` in `./profile`), and the PUT answers the same shape.
|
||||
*/
|
||||
import type { ZodOpenApiObject } from "zod-openapi";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { AI_USAGE_LEVELS, aiUsagePutSchema } from "@/lib/validations/ai-usage";
|
||||
|
||||
import {
|
||||
dataEnvelope,
|
||||
errorEnvelope,
|
||||
notImplementedResponse,
|
||||
stdResponses,
|
||||
} from "./shared";
|
||||
|
||||
export const aiUsageLevel = z.enum(AI_USAGE_LEVELS).meta({
|
||||
id: "AiUsageLevel",
|
||||
description:
|
||||
"How much background AI work an account asks for, in ceiling order. `frugal`: status cards regenerate at most every 24 h after a sync and every 6 h after a hand entry, a smaller snapshot, no background reasoning, a short daily briefing (`recommendations` holds at most one entry, every field stays present), no automatic document reading, no supplement label reading. `standard`: the operator's sync gap (default 6 h), 1 h after a hand entry, background reasoning at the job default. `full`: 1 h after any ingest (the behaviour before v1.43). The interactive Coach is the same at every level.",
|
||||
});
|
||||
|
||||
export const aiUsageState = z
|
||||
.object({
|
||||
level: aiUsageLevel.describe("The level in effect for this record."),
|
||||
preference: aiUsageLevel
|
||||
.nullable()
|
||||
.describe(
|
||||
"The account's stored choice; `null` follows the instance default.",
|
||||
),
|
||||
defaultLevel: aiUsageLevel.describe(
|
||||
"The level an account without its own choice runs at.",
|
||||
),
|
||||
maxLevel: aiUsageLevel.describe(
|
||||
"The highest level the operator allows on this server.",
|
||||
),
|
||||
source: z
|
||||
.enum(["user", "default", "admin_cap"])
|
||||
.describe(
|
||||
"Where `level` comes from: the stored choice, the instance default, or the operator's ceiling capping a stored choice above it (the choice is kept).",
|
||||
),
|
||||
options: z
|
||||
.array(
|
||||
z.object({
|
||||
level: aiUsageLevel,
|
||||
allowed: z.boolean().describe("`level` is at or below `maxLevel`."),
|
||||
estimatedTokensPerDay: z
|
||||
.number()
|
||||
.int()
|
||||
.nullable()
|
||||
.describe(
|
||||
"An estimate, from this account's measured use scaled to the level. `null` without three days of history. Show it as an estimate; never compute one on the client.",
|
||||
),
|
||||
}),
|
||||
)
|
||||
.length(3)
|
||||
.describe("Always three entries, in the order frugal, standard, full."),
|
||||
measuredTokensPerDay: z
|
||||
.number()
|
||||
.int()
|
||||
.nullable()
|
||||
.describe(
|
||||
"Mean tokens per day over the last seven full days (Coach and background work together); `null` without history.",
|
||||
),
|
||||
measuredDays: z
|
||||
.number()
|
||||
.int()
|
||||
.min(0)
|
||||
.max(7)
|
||||
.describe("How many days stand behind `measuredTokensPerDay`."),
|
||||
})
|
||||
.meta({
|
||||
id: "AiUsageState",
|
||||
description:
|
||||
"The account's AI usage level, resolved on the server. Clients render the picker from `options` and never derive allowance or estimates themselves. Absent on servers older than v1.43.",
|
||||
});
|
||||
|
||||
const aiUsagePutRequest = aiUsagePutSchema.meta({
|
||||
id: "AiUsagePutRequest",
|
||||
description:
|
||||
"Set the account's own AI usage level; `null` returns to the instance default. A level above `maxLevel` is refused, never capped silently.",
|
||||
});
|
||||
|
||||
export const aiUsagePaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
"/api/auth/me/ai-usage": {
|
||||
put: {
|
||||
tags: ["Profile"],
|
||||
summary: "Set the AI usage level (v1.43)",
|
||||
description:
|
||||
"Own account only: a cookie session or a full-scope token. Answers the resolved `AiUsageState`. 422 `ai.usage.invalid` for a malformed body; 422 `ai.usage.aboveCeiling` with `meta.maxLevel` for a level above the operator's ceiling; 429 `ai.usage.rateLimited` after 30 changes in an hour.",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: { "application/json": { schema: aiUsagePutRequest } },
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "The resolved AI usage state after the change.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(aiUsageState, "PutAiUsageResponse"),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
"422": {
|
||||
description:
|
||||
"`ai.usage.invalid` (body) or `ai.usage.aboveCeiling` (`meta.maxLevel` names the ceiling).",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
...notImplementedResponse,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -10,6 +10,7 @@
|
||||
* Schemas come from `src/lib/validations/*` where shared with the
|
||||
* runtime request parsing, so the wire contract stays single-source.
|
||||
*/
|
||||
import { HANDOVER_METHODS, claimSsoSchema } from "@/lib/validations/claim-sso";
|
||||
import { z } from "zod/v4";
|
||||
import type { ZodOpenApiObject } from "zod-openapi";
|
||||
import {
|
||||
@@ -42,6 +43,7 @@ import {
|
||||
} from "@/lib/validations/step-up";
|
||||
import {
|
||||
dataEnvelope,
|
||||
notImplementedResponse,
|
||||
profileEmailRateLimitResponse,
|
||||
stdResponses,
|
||||
errorEnvelope,
|
||||
@@ -730,9 +732,28 @@ const claimPreview = z
|
||||
.describe(
|
||||
"Each guardian and the access they keep after the claim, as proposed. The new owner confirms or changes it after signing in.",
|
||||
),
|
||||
methods: z
|
||||
.array(z.enum(HANDOVER_METHODS))
|
||||
.optional()
|
||||
.describe(
|
||||
"v1.43 — how this link can be redeemed: `password` and/or `sso`. Absent on older servers.",
|
||||
),
|
||||
ssoProviderName: z
|
||||
.string()
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe(
|
||||
'v1.43 — the identity provider\'s display name for the "Continue with …" button; null without one.',
|
||||
),
|
||||
})
|
||||
.meta({ id: "ProfileClaimPreview" });
|
||||
|
||||
const claimSsoRequest = claimSsoSchema.meta({
|
||||
id: "ProfileClaimSsoRequest",
|
||||
description:
|
||||
"`token` is the `hlp_` token from the handover link, in the body; `username` follows the registration rules and may not start with `managed-`.",
|
||||
});
|
||||
|
||||
const claimRequest = claimSchema.meta({
|
||||
id: "ProfileClaimRequest",
|
||||
description:
|
||||
@@ -740,6 +761,44 @@ const claimRequest = claimSchema.meta({
|
||||
});
|
||||
|
||||
export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
"/api/auth/claim/sso": {
|
||||
post: {
|
||||
// No credential: the token in the body is the credential.
|
||||
security: [],
|
||||
tags: ["Auth"],
|
||||
summary: "Claim a managed profile through single sign-on (v1.43)",
|
||||
description:
|
||||
"Web only. Checks the link and the username, sets a sealed `hl_claim` cookie (10 minutes, `httpOnly`) and answers where to continue: `/api/auth/oidc/login?intent=claim`. The OIDC callback binds the identity provider's subject to the profile and signs the new owner in; a subject already bound to an account, or a verified email another account holds, is refused there. Works on a single-sign-on-only instance. 404 `profile_claim.invalid` (no usable link) or `profile_claim.sso_unavailable` (no identity provider configured); 409 `profile_claim.taken` (username) or `auth.already_authenticated`; rate-limited per source address like the password claim.",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: { "application/json": { schema: claimSsoRequest } },
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Continue at `redirectTo`.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
z.object({ redirectTo: z.string() }),
|
||||
"ProfileClaimSsoEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
"404": {
|
||||
description:
|
||||
"`profile_claim.invalid` or `profile_claim.sso_unavailable`.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"409": {
|
||||
description: "`profile_claim.taken` or `auth.already_authenticated`.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
...stdResponses,
|
||||
...notImplementedResponse,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/auth/claim/preview": {
|
||||
post: {
|
||||
// No credential: the person claiming the profile has none yet. The
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
* under the owner's name — and both are read-only in fact as well as in
|
||||
* declaration.
|
||||
*/
|
||||
import { recordBenchmark } from "./trend-verdict";
|
||||
import { z } from "zod/v4";
|
||||
import type { ZodOpenApiObject } from "zod-openapi";
|
||||
|
||||
@@ -54,6 +55,12 @@ const personalRecord = z
|
||||
source: measurementSourceEnum,
|
||||
externalId: z.string().nullable(),
|
||||
createdAt: z.iso.datetime({ offset: true }),
|
||||
benchmark: recordBenchmark
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe(
|
||||
"v1.43 — an age- and sex-aware comparison band where a cited norm exists, in the record's unit; null otherwise.",
|
||||
),
|
||||
})
|
||||
.meta({
|
||||
id: "PersonalRecord",
|
||||
|
||||
@@ -18,12 +18,18 @@ import {
|
||||
createBiomarkerSchema,
|
||||
updateBiomarkerSchema,
|
||||
} from "@/lib/validations/biomarkers";
|
||||
import {
|
||||
BIOMARKER_MERGE_CONVERSIONS,
|
||||
BIOMARKER_MERGE_REFUSALS,
|
||||
biomarkerMergeSchema,
|
||||
} from "@/lib/validations/biomarker-merge";
|
||||
|
||||
import {
|
||||
dataEnvelope,
|
||||
errorEnvelope,
|
||||
idempotencyKeyParameter,
|
||||
idempotentWrite,
|
||||
notImplementedResponse,
|
||||
recordRefusal,
|
||||
recordWriteRateLimitResponse,
|
||||
stdResponses,
|
||||
@@ -57,6 +63,12 @@ const biomarkerRow = z
|
||||
hasContext: z.boolean(),
|
||||
context: z.string().nullable(),
|
||||
hidden: z.boolean(),
|
||||
aliases: z
|
||||
.array(z.string())
|
||||
.optional()
|
||||
.describe(
|
||||
"v1.43 — other spellings that resolve to this marker (folded in by a merge, chosen at an import, or typed on the marker), sorted, without the marker's own name. Absent on older servers.",
|
||||
),
|
||||
createdAt: z.string(),
|
||||
updatedAt: z.string(),
|
||||
})
|
||||
@@ -79,11 +91,47 @@ const conflict = {
|
||||
|
||||
const notFound = {
|
||||
"404": {
|
||||
description: "Biomarker not found (or owned by another user).",
|
||||
description:
|
||||
"Biomarker not found (or owned by another user). `meta.errorCode` = `labs.biomarker.notFound`. Since v1.43 a marker that was merged into another answers with `meta.mergedInto` naming the marker it went into: drop the cached marker and open that one.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
};
|
||||
|
||||
const biomarkerMergeRequest = biomarkerMergeSchema.meta({
|
||||
id: "BiomarkerMergeRequest",
|
||||
description:
|
||||
"Fold `sourceIds` (1..10, unique, without the target) into the marker in the path. `dryRun` (default false) answers the same result and writes nothing; a client shows it before asking to confirm, because a merge cannot be undone.",
|
||||
});
|
||||
|
||||
const biomarkerMergeResult = z
|
||||
.object({
|
||||
dryRun: z.boolean(),
|
||||
target: biomarkerRow.describe(
|
||||
"The marker after the merge (unchanged on a dry run).",
|
||||
),
|
||||
sources: z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
unit: z.string(),
|
||||
readingCount: z.number().int(),
|
||||
conversion: z.enum(BIOMARKER_MERGE_CONVERSIONS),
|
||||
refusal: z.enum(BIOMARKER_MERGE_REFUSALS).nullable(),
|
||||
}),
|
||||
),
|
||||
movedReadings: z.number().int(),
|
||||
convertedReadings: z.number().int(),
|
||||
aliasesAdded: z
|
||||
.array(z.string())
|
||||
.describe("Names that become aliases of the target."),
|
||||
mergeId: z.string().nullable().describe("`null` on a dry run."),
|
||||
})
|
||||
.meta({
|
||||
id: "BiomarkerMergeResult",
|
||||
description:
|
||||
"What a merge did, or would do on a dry run. Readings in another unit are converted where the analyte's conversion table knows it; the printed value stays in `sourceValue` / `sourceUnit` on each reading.",
|
||||
});
|
||||
|
||||
export const biomarkerPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
"/api/biomarkers": {
|
||||
get: {
|
||||
@@ -181,9 +229,10 @@ export const biomarkerPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
tags: ["Labs"],
|
||||
summary: "Delete a biomarker",
|
||||
description:
|
||||
"Hard-deletes the catalog definition. The `onDelete: SetNull` FK unlinks every reading (they keep their legacy `analyte` / `unit`), so the history survives. Audits as `biomarker.delete`.",
|
||||
"Hard-deletes the catalog definition together with every reading filed under it, in one transaction. Since v1.43 it needs the same record authority as the edit (manage on the labs section), so a manager of a shared record may delete and the owner is unaffected. Audits as `biomarker.delete` with the marker's name, unit and the number of readings removed.",
|
||||
requestParams: { path: z.object({ id: z.string() }) },
|
||||
responses: {
|
||||
...recordRefusal(),
|
||||
"200": {
|
||||
description: "Deletion succeeded.",
|
||||
content: {
|
||||
@@ -200,4 +249,36 @@ export const biomarkerPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/biomarkers/{id}/merge": {
|
||||
post: {
|
||||
tags: ["Labs"],
|
||||
summary: "Merge markers into this one (v1.43)",
|
||||
description:
|
||||
"Moves every reading of the source markers onto the marker in the path, converting units where the analyte's conversion table knows them, turns the source names into aliases and deletes the sources, in one transaction. A source in a unit without a known conversion refuses the whole merge with 422 `labs.biomarker.merge.unitMismatch` (`meta.sources` names each `{ id, unit, targetUnit, reason }`) and nothing moves. 404 `labs.biomarker.notFound` with `meta.ids` when the target or a source is missing; 422 `labs.biomarker.merge.invalid` for a malformed body or a target among the sources. No undo. Only when `features.biomarkerMerge` is true.",
|
||||
parameters: [idempotencyKeyParameter],
|
||||
requestParams: { path: z.object({ id: z.string() }) },
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: { "application/json": { schema: biomarkerMergeRequest } },
|
||||
},
|
||||
responses: {
|
||||
...idempotentWrite(),
|
||||
"200": {
|
||||
description: "The merge result (or the dry-run preview).",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
biomarkerMergeResult,
|
||||
"MergeBiomarkersResponse",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...notFound,
|
||||
...stdResponses,
|
||||
...recordWriteRateLimitResponse,
|
||||
...notImplementedResponse,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -5,6 +5,7 @@
|
||||
* Schemas come from `src/lib/validations/*` where shared with the
|
||||
* runtime request parsing, so the wire contract stays single-source.
|
||||
*/
|
||||
import { cycleDeleteOutcome, cycleRestoredStart } from "./trend-verdict";
|
||||
import { z } from "zod/v4";
|
||||
import type { ZodOpenApiObject } from "zod-openapi";
|
||||
import {
|
||||
@@ -327,9 +328,20 @@ const cycleHistoryResponse = z.object({
|
||||
}),
|
||||
});
|
||||
|
||||
const cycleDeleteOutcomeEnvelope = dataEnvelope(
|
||||
cycleDeleteOutcome,
|
||||
"CycleDeleteOutcomeEnvelope",
|
||||
);
|
||||
|
||||
const cyclePeriodResponse = z.object({
|
||||
cycle: menstrualCycleDto.nullable(),
|
||||
dayLog: cycleDayLogDto.nullable(),
|
||||
absorbed: cycleRestoredStart
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe(
|
||||
'v1.43 — on `action: "start"`: a later start within the period length folded into this one, or null.',
|
||||
),
|
||||
});
|
||||
|
||||
const cyclePhaseCrosstabRow = z
|
||||
@@ -767,7 +779,20 @@ export const cyclePaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
description: "Day-log updated.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(cycleDayLogDto, "CycleDayLogPatchEnvelope"),
|
||||
schema: dataEnvelope(
|
||||
z.intersection(
|
||||
cycleDayLogDto,
|
||||
z.object({
|
||||
restored: z
|
||||
.array(cycleRestoredStart)
|
||||
.optional()
|
||||
.describe(
|
||||
"v1.43 — starts given back because this edit cleared the flow on a start day.",
|
||||
),
|
||||
}),
|
||||
),
|
||||
"CycleDayLogPatchEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -786,6 +811,13 @@ export const cyclePaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
"Sets `deletedAt` + bumps `syncVersion`; surfaces as a tombstone on the next `/api/sync/changes` page. 204. Idempotent.",
|
||||
requestParams: { path: z.object({ id: z.string() }) },
|
||||
responses: {
|
||||
"200": {
|
||||
description:
|
||||
"v1.43 — with `Prefer: return=representation`: what the delete changed.",
|
||||
content: {
|
||||
"application/json": { schema: cycleDeleteOutcomeEnvelope },
|
||||
},
|
||||
},
|
||||
"204": { description: "Soft-deleted (no body)." },
|
||||
"404": {
|
||||
description: "Day-log not found / not owned.",
|
||||
@@ -941,6 +973,13 @@ export const cyclePaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
"Sets `deletedAt` + bumps `syncVersion`; tombstones on the next sync page. 204. Idempotent.",
|
||||
requestParams: { path: z.object({ id: z.string() }) },
|
||||
responses: {
|
||||
"200": {
|
||||
description:
|
||||
"v1.43 — with `Prefer: return=representation`: what the delete changed.",
|
||||
content: {
|
||||
"application/json": { schema: cycleDeleteOutcomeEnvelope },
|
||||
},
|
||||
},
|
||||
"204": { description: "Soft-deleted (no body)." },
|
||||
"404": {
|
||||
description: "Cycle not found / not owned.",
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
* the single-source `@/lib/dashboard-layout` module so the contract cannot
|
||||
* drift. Part of the OpenAPI route table; aggregated in `./index.ts`.
|
||||
*/
|
||||
import { trendSentiment, trendVerdict } from "./trend-verdict";
|
||||
import { z } from "zod/v4";
|
||||
import type { ZodOpenApiObject } from "zod-openapi";
|
||||
|
||||
@@ -243,6 +244,13 @@ const dashboardMetricCard = z
|
||||
.describe(
|
||||
"The metric's newest reading, for the 'last reading N days ago' caption when it is older than the sparkline window.",
|
||||
),
|
||||
direction: trendVerdict
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe("v1.43 — which way a change of this metric counts."),
|
||||
trendSentiment: trendSentiment
|
||||
.optional()
|
||||
.describe("v1.43 — the verdict applied to this card's `trend`."),
|
||||
})
|
||||
.meta({
|
||||
id: "DashboardMetricCard",
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
/**
|
||||
* OpenAPI route table — Dawarich movement context (v1.43).
|
||||
*
|
||||
* Part of the OpenAPI route table; aggregated in `./index.ts`. Request bodies
|
||||
* reuse the runtime schemas from `@/lib/validations/dawarich`. Daily values
|
||||
* only; no coordinate or place name is stored. Every route belongs to the
|
||||
* `environment` module and is the caller's own account. The day block
|
||||
* (`DayMovement`) is published with `GET /api/day/{date}`.
|
||||
*/
|
||||
import type { ZodOpenApiObject } from "zod-openapi";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import {
|
||||
dawarichConnectSchema,
|
||||
dawarichDisconnectSchema,
|
||||
dawarichSettingsSchema,
|
||||
dawarichSyncSchema,
|
||||
} from "@/lib/validations/dawarich";
|
||||
|
||||
import { connectionHealth } from "./integrations";
|
||||
import {
|
||||
dataEnvelope,
|
||||
errorEnvelope,
|
||||
moduleDisabledResponse,
|
||||
notImplementedResponse,
|
||||
stdResponses,
|
||||
} from "./shared";
|
||||
|
||||
const dawarichConnectRequest = dawarichConnectSchema.meta({
|
||||
id: "DawarichConnectRequest",
|
||||
description:
|
||||
"`url` (http or https, at most 2048) and `apiKey` (1..512); both sealed at rest. A private host needs its origin in `DAWARICH_PRIVATE_ORIGINS`.",
|
||||
});
|
||||
|
||||
const dawarichSettings = dawarichSettingsSchema.meta({
|
||||
id: "DawarichSettings",
|
||||
description:
|
||||
"The home place (an id on the Dawarich instance) and the home country (ISO 3166-1 alpha-2). Without a home country the most frequent country of the last 365 days is used, and without one there is no travel day.",
|
||||
});
|
||||
|
||||
const dawarichStatus = z
|
||||
.object({
|
||||
connected: z.boolean(),
|
||||
configured: z.boolean(),
|
||||
homePlaceSet: z.boolean(),
|
||||
homeCountry: z.string().nullable(),
|
||||
daysStored: z.number().int(),
|
||||
oldestDay: z.string().nullable(),
|
||||
latestDay: z.string().nullable(),
|
||||
health: connectionHealth.nullable(),
|
||||
})
|
||||
.meta({
|
||||
id: "DawarichStatus",
|
||||
description:
|
||||
"The Dawarich connection. `health` is the shape every connection publishes; there are no legacy ledger fields.",
|
||||
});
|
||||
|
||||
const travelSuggestion = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
startDate: z.string(),
|
||||
endDate: z.string(),
|
||||
countryCode: z.string(),
|
||||
})
|
||||
.meta({
|
||||
id: "TravelSuggestion",
|
||||
description:
|
||||
"A run of consecutive travel days no stored trip covers. Accept it through `POST /api/environment/travel` (the person picks the place).",
|
||||
});
|
||||
|
||||
const responses = (
|
||||
ok: Record<string, unknown>,
|
||||
extra: Record<string, unknown> = {},
|
||||
) => ({
|
||||
...ok,
|
||||
...moduleDisabledResponse,
|
||||
...extra,
|
||||
...stdResponses,
|
||||
...notImplementedResponse,
|
||||
});
|
||||
|
||||
const errorOnly = (description: string) => ({
|
||||
description,
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
});
|
||||
|
||||
export const dawarichPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
"/api/dawarich/connect": {
|
||||
post: {
|
||||
tags: ["Integrations"],
|
||||
summary: "Connect Dawarich (v1.43)",
|
||||
description:
|
||||
"Checks the key against the instance, stores the connection sealed and starts a 30-day backfill. 422 `dawarich.invalid`, `private_origin_not_approved`, `dawarich.credentials_rejected` or `dawarich.unreachable`; 429 `dawarich.rate_limited` (ten a minute).",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: { "application/json": { schema: dawarichConnectRequest } },
|
||||
},
|
||||
responses: responses({
|
||||
"200": {
|
||||
description: "Connected.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
z.object({ connected: z.literal(true) }),
|
||||
"DawarichConnectEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
"/api/dawarich/status": {
|
||||
get: {
|
||||
tags: ["Integrations"],
|
||||
summary: "Dawarich connection status (v1.43)",
|
||||
responses: responses({
|
||||
"200": {
|
||||
description: "The connection.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(dawarichStatus, "DawarichStatusEnvelope"),
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
"/api/dawarich/places": {
|
||||
get: {
|
||||
tags: ["Integrations"],
|
||||
summary: "Places on the Dawarich instance (v1.43)",
|
||||
description:
|
||||
"Read live from Dawarich to pick the home place (at most 200, filtered by `q`); never stored. 409 `dawarich.not_connected`; 502 `dawarich.unreachable`.",
|
||||
requestParams: {
|
||||
query: z.object({ q: z.string().max(80).optional() }),
|
||||
},
|
||||
responses: responses(
|
||||
{
|
||||
"200": {
|
||||
description: "The places.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
z.object({
|
||||
places: z.array(
|
||||
z.object({ id: z.number().int(), name: z.string() }),
|
||||
),
|
||||
}),
|
||||
"DawarichPlacesEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"409": errorOnly("`dawarich.not_connected`."),
|
||||
"502": errorOnly("`dawarich.unreachable`."),
|
||||
},
|
||||
),
|
||||
},
|
||||
},
|
||||
"/api/dawarich/settings": {
|
||||
patch: {
|
||||
tags: ["Integrations"],
|
||||
summary: "Dawarich home place and country (v1.43)",
|
||||
description:
|
||||
"422 `dawarich.invalid`; 422 `dawarich.home_place_unknown` for a place id the instance does not have.",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: { "application/json": { schema: dawarichSettings } },
|
||||
},
|
||||
responses: responses({
|
||||
"200": {
|
||||
description: "The settings after the change.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
z.object({
|
||||
homePlaceId: z.number().int().nullable(),
|
||||
homeCountry: z.string().nullable(),
|
||||
}),
|
||||
"DawarichSettingsEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
"/api/dawarich/test": {
|
||||
post: {
|
||||
tags: ["Integrations"],
|
||||
summary: "Probe the Dawarich connection (v1.43)",
|
||||
description:
|
||||
"Answers the shared integration probe shape; a failed probe is an error status. The probe classes are those of the other `/test` routes.",
|
||||
responses: responses({
|
||||
"200": {
|
||||
description: "The probe reached the instance.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
z.object({
|
||||
ok: z.literal(true),
|
||||
lastSyncedAt: z.iso.datetime({ offset: true }).nullable(),
|
||||
latencyMs: z.number().int(),
|
||||
}),
|
||||
"DawarichProbeEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
"/api/dawarich/sync": {
|
||||
post: {
|
||||
tags: ["Integrations"],
|
||||
summary: "Fetch Dawarich daily values again (v1.43)",
|
||||
description:
|
||||
"From `fromDate` (at most 365 days back). 409 `dawarich.not_connected`; 422 `dawarich.invalid`.",
|
||||
requestBody: {
|
||||
required: false,
|
||||
content: { "application/json": { schema: dawarichSyncSchema } },
|
||||
},
|
||||
responses: responses(
|
||||
{
|
||||
"202": {
|
||||
description: "Queued.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
z.object({ enqueued: z.boolean() }),
|
||||
"DawarichSyncEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{ "409": errorOnly("`dawarich.not_connected`.") },
|
||||
),
|
||||
},
|
||||
},
|
||||
"/api/dawarich/disconnect": {
|
||||
post: {
|
||||
tags: ["Integrations"],
|
||||
summary: "Disconnect Dawarich (v1.43)",
|
||||
description:
|
||||
"Forgets the connection; with `deleteData: true` the stored days go too.",
|
||||
requestBody: {
|
||||
required: false,
|
||||
content: { "application/json": { schema: dawarichDisconnectSchema } },
|
||||
},
|
||||
responses: responses({
|
||||
"200": {
|
||||
description: "Disconnected.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
z.object({
|
||||
disconnected: z.literal(true),
|
||||
deletedDays: z.number().int(),
|
||||
}),
|
||||
"DawarichDisconnectEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
"/api/environment/travel/suggestions": {
|
||||
get: {
|
||||
tags: ["Environment"],
|
||||
summary: "Suggested trips from Dawarich travel days (v1.43)",
|
||||
responses: responses({
|
||||
"200": {
|
||||
description: "The suggestions.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
z.object({ suggestions: z.array(travelSuggestion) }),
|
||||
"TravelSuggestionsEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
"/api/environment/travel/suggestions/{id}/dismiss": {
|
||||
post: {
|
||||
tags: ["Environment"],
|
||||
summary: "Dismiss a suggested trip (v1.43)",
|
||||
description: "404 `environment.travel_suggestion.not_found`.",
|
||||
requestParams: { path: z.object({ id: z.string() }) },
|
||||
responses: responses(
|
||||
{
|
||||
"200": {
|
||||
description: "Dismissed.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
z.object({ dismissed: z.literal(true) }),
|
||||
"TravelSuggestionDismissEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{ "404": errorOnly("`environment.travel_suggestion.not_found`.") },
|
||||
),
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -14,6 +14,7 @@
|
||||
* stores until the confirm route commits the user's approvals; a low-confidence
|
||||
* fact fails closed.
|
||||
*/
|
||||
import { withRelated } from "./relation-links";
|
||||
import type { ZodOpenApiObject } from "zod-openapi";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
@@ -169,6 +170,32 @@ const extractedFact = z
|
||||
provenance: factProvenance,
|
||||
committedRecordId: z.string().nullable(),
|
||||
committedRecordType: z.string().nullable(),
|
||||
biomarkerId: z
|
||||
.string()
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe(
|
||||
"v1.43, OBSERVATION only — the marker matched automatically (name, alias or catalogue abbreviation); null without a match.",
|
||||
),
|
||||
matchKind: z
|
||||
.enum(["name", "alias", "catalog"])
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe("v1.43, OBSERVATION only — how `biomarkerId` was found."),
|
||||
suggestions: z
|
||||
.array(
|
||||
z.object({
|
||||
biomarkerId: z.string(),
|
||||
name: z.string(),
|
||||
unit: z.string(),
|
||||
matchKind: z.enum(["catalog", "similar"]),
|
||||
}),
|
||||
)
|
||||
.max(3)
|
||||
.optional()
|
||||
.describe(
|
||||
"v1.43, OBSERVATION only — up to three markers to choose from when nothing matched.",
|
||||
),
|
||||
})
|
||||
.meta({
|
||||
id: "ExtractedFact",
|
||||
@@ -862,7 +889,7 @@ export const inboundDocumentPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
inboundDocumentDetail,
|
||||
withRelated(inboundDocumentDetail),
|
||||
"InboundDocumentDetailEnvelope",
|
||||
),
|
||||
},
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
* reaches no response, because every preventive-care read excludes it. The
|
||||
* published contract stays at two members deliberately.
|
||||
*/
|
||||
import { withRelated } from "./relation-links";
|
||||
import type { ZodOpenApiObject } from "zod-openapi";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
@@ -465,7 +466,10 @@ export const encounterPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
description: "The visit.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(encounter, "GetEncounterEnvelope"),
|
||||
schema: dataEnvelope(
|
||||
withRelated(encounter),
|
||||
"GetEncounterEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
* the module off (or an operator-disabled instance) 403s with
|
||||
* `errorCode:"illness.disabled"` even with a valid Bearer token.
|
||||
*/
|
||||
import { withRelated } from "./relation-links";
|
||||
import type { ZodOpenApiObject } from "zod-openapi";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
@@ -350,7 +351,10 @@ export const illnessPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
description: "The episode.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(illnessEpisode, "GetIllnessEpisodeEnvelope"),
|
||||
schema: dataEnvelope(
|
||||
withRelated(illnessEpisode),
|
||||
"GetIllnessEpisodeEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -28,6 +28,10 @@ import {
|
||||
recordScopeParameter,
|
||||
} from "./account-sharing";
|
||||
import { adminDiagnosticPaths, adminInvitePaths } from "./admin";
|
||||
import { aiUsagePaths } from "./ai-usage";
|
||||
import { dawarichPaths } from "./dawarich";
|
||||
import { lifeCircumstancesPaths } from "./life-circumstances";
|
||||
import { relationLinkPaths } from "./relation-links";
|
||||
import { adminBackupPaths } from "./admin-backups";
|
||||
import { allergyPaths } from "./allergies";
|
||||
import { analyticsPaths } from "./analytics";
|
||||
@@ -71,6 +75,7 @@ import { measurementPaths } from "./measurements";
|
||||
import { measurementReminderPaths } from "./measurement-reminders";
|
||||
import { mentalHealthPaths } from "./mental-health";
|
||||
import {
|
||||
medicationIngredientPaths,
|
||||
medicationPaths,
|
||||
medicationResource,
|
||||
medicationDoseHistoryImportFatalReasonEnum,
|
||||
@@ -187,6 +192,14 @@ export const openApiPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
// order is load-bearing).
|
||||
...dayPaths,
|
||||
...searchPaths,
|
||||
// v1.43 — the new contract surfaces: AI usage levels, supplement
|
||||
// composition, life circumstances and AUDIT-C, Dawarich movement context and
|
||||
// the relation links (appended, spread order is load-bearing).
|
||||
...aiUsagePaths,
|
||||
...medicationIngredientPaths,
|
||||
...lifeCircumstancesPaths,
|
||||
...dawarichPaths,
|
||||
...relationLinkPaths,
|
||||
// The environmental-context overview. Its own module because nothing else
|
||||
// owns the surface (appended, spread order is load-bearing).
|
||||
//
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
* Schemas come from `src/lib/validations/*` where shared with the
|
||||
* runtime request parsing, so the wire contract stays single-source.
|
||||
*/
|
||||
import { trendSentiment, trendVerdict } from "../trend-verdict";
|
||||
import { z } from "zod/v4";
|
||||
import { measurementSourceEnum, measurementTypeEnum } from "../shared";
|
||||
import { METRIC_STATUS_IDS } from "@/lib/insights/metric-status-registry";
|
||||
@@ -1258,6 +1259,12 @@ export const dashboardSnapshotResponse = z
|
||||
"Server-computed band / target math resolved from the user's profile facts, so the client reads these numbers instead of recomputing them. Every sub-field is null only when the driving profile fact is missing (no date of birth for blood pressure, no height and no weight target for weight).",
|
||||
),
|
||||
tiles: z.object({
|
||||
directions: z
|
||||
.record(z.string(), trendVerdict)
|
||||
.optional()
|
||||
.describe(
|
||||
"v1.43 — per measurement type, which way a change counts. `weightTrend` stays.",
|
||||
),
|
||||
summaries: dataSummaryRecord,
|
||||
lastSeenByType: z.record(z.string(), z.unknown()),
|
||||
mood: z.object({
|
||||
@@ -2478,6 +2485,14 @@ const targetItem = z
|
||||
.describe(
|
||||
"Per-tile extras. Only the medication-compliance tile carries any today; every other tile omits the key.",
|
||||
),
|
||||
direction: trendVerdict
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe("v1.43 — which way a change of this target counts."),
|
||||
trendSentiment: trendSentiment
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe("v1.43 — the verdict applied to this tile's change."),
|
||||
})
|
||||
.meta({
|
||||
id: "TargetItem",
|
||||
|
||||
@@ -252,6 +252,13 @@ const appleHealthMetricFreshnessEntry = metricFreshnessEntry
|
||||
.describe(
|
||||
'When a batch with `syncTrigger: "background"` last carried this type (since v1.42.4). A later `foreground`, `push` or `manual` batch moves `lastReceivedAt` and `lastTrigger` but leaves this standing, so opening the app does not hide an earlier background arrival. Null when the type has never arrived in the background as far as the server recorded: before v1.42.4 only the latest arrival was kept, so a type whose latest arrival then was a background one starts with that date and every other type starts null. Same omission rules as `lastReceivedAt`.',
|
||||
),
|
||||
lastBackfillAt: z.iso
|
||||
.datetime({ offset: true })
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe(
|
||||
'v1.43 — when a batch with `syncTrigger: "backfill"` last carried this type. A backfill moves only this and `syncProgress`, never `lastReceivedAt`, `lastTrigger`, `lastBackgroundAt` or the account-level sync stamps, so catching up history does not read as a run of healthy daily syncs. Absent on older servers.',
|
||||
),
|
||||
})
|
||||
.meta({
|
||||
id: "AppleHealthMetricFreshnessEntry",
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
* here (the route serialises a derived `rangeStatus` + `hasNote` the input
|
||||
* schema doesn't carry).
|
||||
*/
|
||||
import { withRelated } from "./relation-links";
|
||||
import type { ZodOpenApiObject } from "zod-openapi";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
@@ -233,7 +234,10 @@ export const labsPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
description: "Lab-result detail.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(labResultDetail, "GetLabResultResponse"),
|
||||
schema: dataEnvelope(
|
||||
withRelated(labResultDetail),
|
||||
"GetLabResultResponse",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -0,0 +1,356 @@
|
||||
/**
|
||||
* OpenAPI route table — life circumstances and AUDIT-C (v1.43, #614).
|
||||
*
|
||||
* Part of the OpenAPI route table; aggregated in `./index.ts`. Request bodies
|
||||
* reuse the runtime schemas from `@/lib/validations/life-circumstances`. Every
|
||||
* route here is new: the new fact kinds never appear on
|
||||
* `GET /api/anamnesis/facts`, and AUDIT-C never on
|
||||
* `/api/mental-health/assessments`, because older app builds decode both
|
||||
* lists closed. Only when `features.lifeCircumstances` is true.
|
||||
*/
|
||||
import type { ZodOpenApiObject } from "zod-openapi";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import {
|
||||
AUDIT_C_CUTOFF_SOURCES,
|
||||
LIFE_AI_SECTIONS,
|
||||
LIFE_FACT_KINDS,
|
||||
LIFE_SECTION_KEYS,
|
||||
auditCRequestSchema,
|
||||
lifeAiSectionsPatchSchema,
|
||||
lifeConfirmSchema,
|
||||
} from "@/lib/validations/life-circumstances";
|
||||
|
||||
import {
|
||||
dataEnvelope,
|
||||
errorEnvelope,
|
||||
idempotencyKeyParameter,
|
||||
idempotentWrite,
|
||||
notImplementedResponse,
|
||||
recordRefusal,
|
||||
stdResponses,
|
||||
} from "./shared";
|
||||
|
||||
const lifeFactRevision = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
kind: z.enum(["SMOKING_STATUS", "SHIFT_SCHEDULE", ...LIFE_FACT_KINDS]),
|
||||
value: z
|
||||
.unknown()
|
||||
.describe(
|
||||
"`SMOKING_STATUS` and `SHIFT_SCHEDULE` carry their string value; every other kind an object with `schema: 1` whose shape is fixed per kind (see the life-circumstances contract). `null` when `unreadable`.",
|
||||
),
|
||||
unreadable: z
|
||||
.boolean()
|
||||
.describe("The sealed value could not be opened on this host."),
|
||||
validFrom: z.iso.datetime({ offset: true }),
|
||||
validUntil: z.null(),
|
||||
provenance: z.enum(["USER_REPORTED", "USER_CORRECTION"]),
|
||||
createdAt: z.iso.datetime({ offset: true }),
|
||||
})
|
||||
.meta({
|
||||
id: "LifeFactRevision",
|
||||
description: "The current revision of one life-circumstance fact.",
|
||||
});
|
||||
|
||||
const auditCCutoff = z.object({
|
||||
value: z.number().int(),
|
||||
sex: z.enum(["male", "female"]),
|
||||
source: z.enum(AUDIT_C_CUTOFF_SOURCES),
|
||||
sourceLabel: z.string(),
|
||||
sourceUrl: z.string(),
|
||||
});
|
||||
|
||||
const auditCResult = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
takenAt: z.iso.datetime({ offset: true }),
|
||||
items: z.tuple([z.number().int(), z.number().int(), z.number().int()]),
|
||||
totalScore: z.number().int().min(0).max(12),
|
||||
locale: z.string(),
|
||||
cutoff: auditCCutoff
|
||||
.nullable()
|
||||
.describe(
|
||||
"The screening cut-off for the entry's language and the profile's sex (German S3 guideline, Korean KNHANES, else Bradley 2007). `null` when the profile has no sex; show the sum only.",
|
||||
),
|
||||
aboveCutoff: z.boolean().nullable(),
|
||||
validatedLocale: z
|
||||
.boolean()
|
||||
.describe(
|
||||
"Whether the item wording shown was a validated translation for `locale`.",
|
||||
),
|
||||
})
|
||||
.meta({
|
||||
id: "AuditCResult",
|
||||
description:
|
||||
"One AUDIT-C entry, scored on the server. A screening result, not a diagnosis.",
|
||||
});
|
||||
|
||||
const screeningHint = z.object({
|
||||
id: z.enum(["lung_ldct", "aaa_ultrasound"]),
|
||||
status: z.enum(["suggested", "not_eligible", "unknown"]),
|
||||
reasonKeys: z.array(z.string()),
|
||||
region: z.enum(["DE", "US"]),
|
||||
sourceLabel: z.string(),
|
||||
sourceUrl: z.string(),
|
||||
});
|
||||
|
||||
const lifeCircumstances = z
|
||||
.object({
|
||||
sections: z
|
||||
.array(
|
||||
z.object({
|
||||
key: z.enum(LIFE_SECTION_KEYS),
|
||||
aiSection: z.enum([
|
||||
"SMOKING_STATUS",
|
||||
"ALCOHOL_PATTERN",
|
||||
"ACTIVITY",
|
||||
"WORK",
|
||||
"HABITS",
|
||||
"GOALS",
|
||||
]),
|
||||
aiIncluded: z.boolean(),
|
||||
lastConfirmedAt: z.iso.datetime({ offset: true }).nullable(),
|
||||
reviewDue: z
|
||||
.boolean()
|
||||
.describe("Older than 365 days; show the quiet yearly check."),
|
||||
facts: z.array(lifeFactRevision),
|
||||
}),
|
||||
)
|
||||
.describe("Always all six, in this order."),
|
||||
extraAiSections: z.object({ SHIFT_SCHEDULE: z.boolean() }),
|
||||
derived: z.object({
|
||||
packYears: z.object({
|
||||
value: z.number().nullable(),
|
||||
status: z.enum(["computed", "not_applicable", "missing_inputs"]),
|
||||
}),
|
||||
activityMinutesPerWeek: z.object({
|
||||
value: z.number().int().nullable(),
|
||||
meetsWho150: z.boolean().nullable(),
|
||||
measuredStepsAvailable: z.boolean(),
|
||||
}),
|
||||
freeWeekdays: z.object({
|
||||
days: z.array(z.number().int().min(1).max(7)),
|
||||
source: z.enum(["fact", "default_weekend"]),
|
||||
}),
|
||||
screeningHints: z.array(screeningHint),
|
||||
}),
|
||||
auditC: z.object({
|
||||
latest: auditCResult.nullable(),
|
||||
history: z
|
||||
.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
takenAt: z.iso.datetime({ offset: true }),
|
||||
totalScore: z.number().int(),
|
||||
}),
|
||||
)
|
||||
.describe("Newest first, at most 20."),
|
||||
offerDue: z.boolean(),
|
||||
instrument: z.object({
|
||||
items: z
|
||||
.array(
|
||||
z.object({
|
||||
stem: z.string(),
|
||||
options: z.array(
|
||||
z.object({ value: z.number().int(), label: z.string() }),
|
||||
),
|
||||
}),
|
||||
)
|
||||
.describe(
|
||||
"The three items verbatim in the account language; render as delivered.",
|
||||
),
|
||||
locale: z.string(),
|
||||
validatedLocale: z.boolean(),
|
||||
standardDrink: z.string(),
|
||||
attribution: z.string(),
|
||||
}),
|
||||
}),
|
||||
takeOver: z.array(
|
||||
z.object({
|
||||
field: z.enum(["conditions", "allergies"]),
|
||||
freeTextPresent: z.boolean(),
|
||||
structuredCount: z.number().int(),
|
||||
available: z.boolean(),
|
||||
}),
|
||||
),
|
||||
})
|
||||
.meta({
|
||||
id: "LifeCircumstances",
|
||||
description:
|
||||
"The six life-circumstance sections with their answers, AI switches and review state, the derived values with their sources, AUDIT-C, and the take-over hints for the free-text conditions and allergies. Every number is resolved on the server.",
|
||||
});
|
||||
|
||||
const lifeAiSectionsPatchRequest = lifeAiSectionsPatchSchema.meta({
|
||||
id: "LifeAiSectionsPatchRequest",
|
||||
});
|
||||
const lifeConfirmRequest = lifeConfirmSchema.meta({
|
||||
id: "LifeConfirmRequest",
|
||||
});
|
||||
const auditCRequest = auditCRequestSchema.meta({ id: "AuditCRequest" });
|
||||
|
||||
export const lifeCircumstancesPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
"/api/anamnesis/life-circumstances": {
|
||||
get: {
|
||||
tags: ["Anamnesis"],
|
||||
summary: "Life circumstances (v1.43)",
|
||||
description:
|
||||
"The life-circumstance sections of the record. Delegates read it under the `profile` grant, like the facts.",
|
||||
responses: {
|
||||
...recordRefusal(),
|
||||
"200": {
|
||||
description: "The life circumstances.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
lifeCircumstances,
|
||||
"LifeCircumstancesEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
...notImplementedResponse,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/anamnesis/life-circumstances/ai-sections": {
|
||||
patch: {
|
||||
tags: ["Anamnesis"],
|
||||
summary: "Switch life-circumstance sections for the AI (v1.43)",
|
||||
description:
|
||||
"Sections not named keep their state. Own account only. 422 `anamnesis.lifeSections.invalid`; 409 `about_me_conflict` for a stale `baseUpdatedAt`.",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": { schema: lifeAiSectionsPatchRequest },
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "The switches after the change.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
z.object({
|
||||
aiSections: z.partialRecord(
|
||||
z.enum(LIFE_AI_SECTIONS),
|
||||
z.boolean(),
|
||||
),
|
||||
updatedAt: z.iso.datetime({ offset: true }),
|
||||
}),
|
||||
"LifeAiSectionsEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
"409": {
|
||||
description: "`about_me_conflict`.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
...stdResponses,
|
||||
...notImplementedResponse,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/anamnesis/life-circumstances/confirm": {
|
||||
post: {
|
||||
tags: ["Anamnesis"],
|
||||
summary: "Confirm a life-circumstance section is still true (v1.43)",
|
||||
description:
|
||||
"Stores the confirmation instant only, never a new revision. Own account only. 422 `anamnesis.lifeConfirm.invalid`.",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: { "application/json": { schema: lifeConfirmRequest } },
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "The confirmed section.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
z.object({
|
||||
section: z.enum(LIFE_SECTION_KEYS),
|
||||
lastConfirmedAt: z.iso.datetime({ offset: true }),
|
||||
}),
|
||||
"LifeConfirmEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
...notImplementedResponse,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/anamnesis/audit-c": {
|
||||
post: {
|
||||
tags: ["Anamnesis"],
|
||||
summary: "Record an AUDIT-C (v1.43)",
|
||||
description:
|
||||
"Scores the three items on the server. A replay with the same `externalId` answers 200 with the stored result. 422 `anamnesis.auditC.invalid`; 429 `anamnesis.auditC.rateLimited` after 30 in an hour.",
|
||||
parameters: [idempotencyKeyParameter],
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: { "application/json": { schema: auditCRequest } },
|
||||
},
|
||||
responses: {
|
||||
...idempotentWrite(),
|
||||
"200": {
|
||||
description: "A replay of an entry already stored.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
z.object({ result: auditCResult }),
|
||||
"AuditCReplayEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
"201": {
|
||||
description: "The stored and scored entry.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
z.object({ result: auditCResult }),
|
||||
"AuditCCreatedEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
...notImplementedResponse,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/anamnesis/audit-c/{id}": {
|
||||
delete: {
|
||||
tags: ["Anamnesis"],
|
||||
summary: "Remove an AUDIT-C entry (v1.43)",
|
||||
description: "Soft delete. 404 `anamnesis.auditC.notFound`.",
|
||||
requestParams: { path: z.object({ id: z.string() }) },
|
||||
responses: {
|
||||
"200": {
|
||||
description: "The removed entry.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
removedAt: z.iso.datetime({ offset: true }),
|
||||
}),
|
||||
"AuditCRemovedEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
"404": {
|
||||
description: "`anamnesis.auditC.notFound`.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
...stdResponses,
|
||||
...notImplementedResponse,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
Loaded 100 of 131 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user