From 355fa9a67089cd54d5618243c2844cb85b9adf61 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 14 Jun 2026 23:04:48 +0200 Subject: [PATCH] feat(schema): add per-user Polar/Oura credentials, measurement reminders, lab results MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migration 0162 lands four additive surfaces for v1.17.1, all forward-only and non-destructive. - users: Polar and Oura BYO-app client id/secret, encrypted at app level and stored as text, mirroring the existing WHOOP/Fitbit credential columns 1:1. Each self-hoster registers their own Polar AccessLink / Oura Cloud app and pastes the client id/secret into Settings before the OAuth connect flow. - devices: live_activity_push_token for the iOS Live Activity update/end APNs channel. The client owns the ActivityKit lifecycle; the server only stores the most recent push token so the worker can address the update. - measurement_reminders: preventive-care ("Vorsorge") reminders. Cadence is a rolling interval_days or an RFC-5545 rrule, so the recurrence engine drives the server-authoritative next_due_at. The completion target is an optional measurement_type (auto-resolved when a matching reading lands) or a free-text label that resolves on a manual check-off. Soft-deleted. - lab_results: minimal structured lab store — one analyte per row, optional panel grouping and reference range, optional encrypted note — so a returned blood panel has somewhere to land alongside the Vorsorge reminder. No enum change: the Polar/Oura/Nightscout measurement sources already landed in migration 0160. --- .../migration.sql | 118 ++++++++++++++++ prisma/schema.prisma | 129 ++++++++++++++++++ 2 files changed, 247 insertions(+) create mode 100644 prisma/migrations/0162_v1171_creds_reminders_labs/migration.sql diff --git a/prisma/migrations/0162_v1171_creds_reminders_labs/migration.sql b/prisma/migrations/0162_v1171_creds_reminders_labs/migration.sql new file mode 100644 index 000000000..8a7615be6 --- /dev/null +++ b/prisma/migrations/0162_v1171_creds_reminders_labs/migration.sql @@ -0,0 +1,118 @@ +-- v1.17.1 — schema foundation for per-user Polar/Oura BYO-app credentials, +-- preventive-care (Vorsorge) measurement reminders, a minimal structured lab +-- store, and the iOS Live Activity push-token channel (#22). +-- +-- Purely-additive: four new nullable columns on `users` (encrypted client +-- credentials), one new nullable column on `devices`, and two new tables. No +-- backfill, no existing row touched, no enum change (Polar/Oura/Nightscout +-- measurement sources already landed in migration 0160). +-- +-- 1. `users` += Polar + Oura BYO-app client id/secret (encrypted at app +-- level, stored as TEXT), mirroring the WHOOP/Fitbit credential columns. +-- 2. `devices` += `live_activity_push_token` for the iOS Live Activity +-- update/end APNs channel (#22). NULL when no active Live Activity. +-- 3. `measurement_reminders` — preventive-care reminders. Cadence is a +-- rolling `interval_days` OR an RFC-5545 `rrule`; the recurrence engine +-- drives `next_due_at` (server-authoritative). Completion target is an +-- optional `measurement_type` (auto-resolve) or a free-text `label`. +-- Soft-deleted. +-- 4. `lab_results` — one analyte per row, optional panel grouping + optional +-- reference range, optional encrypted note. Pairs with the Vorsorge +-- "annual blood panel" reminder so a result has somewhere to land. +-- Soft-deleted. +-- +-- Idempotent guards (`IF NOT EXISTS`) make reruns safe. Forward-only. +-- +-- Reversibility: every column drops with `DROP COLUMN IF EXISTS`; both tables +-- drop with `DROP TABLE IF EXISTS`. No data is rewritten. + +-- ── 1. users — Polar + Oura BYO-app client credentials (encrypted) ───── +ALTER TABLE "users" + ADD COLUMN IF NOT EXISTS "polar_client_id_encrypted" TEXT; +ALTER TABLE "users" + ADD COLUMN IF NOT EXISTS "polar_client_secret_encrypted" TEXT; +ALTER TABLE "users" + ADD COLUMN IF NOT EXISTS "oura_client_id_encrypted" TEXT; +ALTER TABLE "users" + ADD COLUMN IF NOT EXISTS "oura_client_secret_encrypted" TEXT; + +-- ── 2. devices — iOS Live Activity push token (#22) ──────────────────── +ALTER TABLE "devices" + ADD COLUMN IF NOT EXISTS "live_activity_push_token" TEXT; + +-- ── 3. measurement_reminders — preventive-care (Vorsorge) reminders ──── +CREATE TABLE IF NOT EXISTS "measurement_reminders" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "label" TEXT NOT NULL, + "measurement_type" "MeasurementType", + "interval_days" INTEGER, + "rrule" TEXT, + "anchor_date" TIMESTAMP(3), + "notify_hour" INTEGER NOT NULL DEFAULT 9, + "location" TEXT, + "next_due_at" TIMESTAMP(3), + "last_satisfied_at" TIMESTAMP(3), + "enabled" BOOLEAN NOT NULL DEFAULT true, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + "deleted_at" TIMESTAMP(3), + + CONSTRAINT "measurement_reminders_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX IF NOT EXISTS "measurement_reminders_user_id_deleted_at_idx" + ON "measurement_reminders" ("user_id", "deleted_at"); +CREATE INDEX IF NOT EXISTS "measurement_reminders_user_id_next_due_at_idx" + ON "measurement_reminders" ("user_id", "next_due_at"); + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'measurement_reminders_user_id_fkey' + ) THEN + ALTER TABLE "measurement_reminders" + ADD CONSTRAINT "measurement_reminders_user_id_fkey" + FOREIGN KEY ("user_id") REFERENCES "users" ("id") + ON DELETE CASCADE ON UPDATE CASCADE; + END IF; +END $$; + +-- ── 4. lab_results — minimal structured lab store ────────────────────── +CREATE TABLE IF NOT EXISTS "lab_results" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "panel" TEXT, + "analyte" TEXT NOT NULL, + "value" DOUBLE PRECISION NOT NULL, + "unit" TEXT NOT NULL, + "reference_low" DOUBLE PRECISION, + "reference_high" DOUBLE PRECISION, + "taken_at" TIMESTAMP(3) NOT NULL, + "source" TEXT NOT NULL DEFAULT 'MANUAL', + "note_encrypted" BYTEA, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + "deleted_at" TIMESTAMP(3), + + CONSTRAINT "lab_results_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX IF NOT EXISTS "lab_results_user_id_analyte_taken_at_idx" + ON "lab_results" ("user_id", "analyte", "taken_at"); +CREATE INDEX IF NOT EXISTS "lab_results_user_id_deleted_at_idx" + ON "lab_results" ("user_id", "deleted_at"); + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'lab_results_user_id_fkey' + ) THEN + ALTER TABLE "lab_results" + ADD CONSTRAINT "lab_results_user_id_fkey" + FOREIGN KEY ("user_id") REFERENCES "users" ("id") + ON DELETE CASCADE ON UPDATE CASCADE; + END IF; +END $$; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index ba56aa388..715611d14 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -127,12 +127,26 @@ model User { // tokens are stored encrypted. polarAccessTokenEncrypted String? @map("polar_access_token_encrypted") polarUserIdEncrypted String? @map("polar_user_id_encrypted") + // v1.17.1 — Polar BYO-app client credentials (W1c, per-user, encrypted at + // app level). Mirrors the WHOOP/Fitbit `clientId`/`clientSecret` columns 1:1 + // so a self-hoster pastes their own Polar AccessLink app's client id/secret + // into Settings before the OAuth connect flow runs. Stored encrypted as a + // string via `src/lib/crypto.ts`, matching every other `*Encrypted` column. + polarClientIdEncrypted String? @map("polar_client_id_encrypted") + polarClientSecretEncrypted String? @map("polar_client_secret_encrypted") // v1.17.0 — Oura OAuth credentials (F4, per-user, encrypted at app level). // Mirrors the WHOOP credential columns; OAuth access/refresh tokens stored // encrypted. ouraAccessTokenEncrypted String? @map("oura_access_token_encrypted") ouraRefreshTokenEncrypted String? @map("oura_refresh_token_encrypted") + // v1.17.1 — Oura BYO-app client credentials (W1c, per-user, encrypted at app + // level). Mirrors the WHOOP/Fitbit `clientId`/`clientSecret` columns 1:1 so a + // self-hoster pastes their own Oura Cloud app's client id/secret into Settings + // before the OAuth connect flow runs. Stored encrypted as a string via + // `src/lib/crypto.ts`, matching every other `*Encrypted` column. + ouraClientIdEncrypted String? @map("oura_client_id_encrypted") + ouraClientSecretEncrypted String? @map("oura_client_secret_encrypted") // moodLog integration (per-user, encrypted) moodLogUrlEncrypted String? @map("mood_log_url_encrypted") @@ -527,6 +541,15 @@ model User { // v1.11.0 W3 — one durable period-narrative row per (period, locale). // Delete/regenerate-clean; the generated prose is AES-256-GCM at rest. insightNarratives InsightNarrative[] + // v1.17.1 — preventive-care / measurement (Vorsorge) reminders. One row + // per user-created reminder ("BP every 7 days", "annual blood panel"). + // Server computes the canonical `nextDueAt`; the cron fires through the + // existing notification dispatcher. Soft-deleted. + measurementReminders MeasurementReminder[] + // v1.17.1 — minimal structured lab store (LDL, HbA1c, ferritin, TSH, …) + // that pairs with the Vorsorge "annual blood panel" reminder so a result + // has somewhere to land. Soft-deleted. + labResults LabResult[] // v1.7.0 profile — optional patient-identity fields surfaced on the // health-record export cover (PDF) and the FHIR `Patient` resource. @@ -3090,6 +3113,14 @@ model Device { /// echoed in v1.7.0; cron suppression stays user-level (APNs fans out to /// all the user's devices and iOS dedupes locally). medicationDelivery String? @map("medication_delivery") + /// v1.17.1 (#22) — APNs push token for the iOS Live Activity update/end + /// channel. ActivityKit issues a per-Activity push token distinct from the + /// device APNs token; the server stores the most recent one so the worker + /// can push a Live Activity content-state update or an end signal. NULL when + /// the device has no active Live Activity. The iOS client owns the + /// ActivityKit lifecycle and registers/clears this token; no server-side + /// behaviour hangs off it beyond addressing the update push. + liveActivityPushToken String? @map("live_activity_push_token") lastSeen DateTime @default(now()) @map("last_seen") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") @@ -3105,6 +3136,104 @@ model Device { @@map("devices") } +// ─── Measurement / Vorsorge reminders ──────────────────────── + +/// v1.17.1 — preventive-care ("Vorsorge") reminders: "measure BP every 7 +/// days", "annual blood panel", "log weight weekly". Reuses the existing +/// medication-reminder cadence vocabulary (`intervalDays` rolling cadence OR +/// an RFC-5545 `rrule`) so the recurrence engine drives the due-date math, and +/// rides the existing notification dispatcher + `push_attempts` ledger. The +/// completion target is either a `measurementType` (auto-resolved when a +/// matching measurement lands) or a free-text `label` for non-measurement +/// Vorsorge that only resolves on a manual check-off. `nextDueAt` is +/// server-computed + stored (server-authoritative — iOS consumes the resolved +/// value, never recomputes). Soft-deleted. +model MeasurementReminder { + id String @id @default(cuid()) + userId String @map("user_id") + /// User-facing copy: "Blutdruck messen", "Großes Blutbild". + label String + /// Auto-resolve target. NULL = free-text / checklist Vorsorge (a blood + /// panel, a dermatology check) with no measurement to match against; those + /// resolve only on a manual satisfy. Non-NULL = the cron advances + /// `lastSatisfiedAt` when a matching Measurement of this type lands. + measurementType MeasurementType? @map("measurement_type") + /// Rolling cadence: fire every N days (7 / 30 / 365). Maps to the recurrence + /// engine's `rollingIntervalDays`. Mutually exclusive with `rrule` in + /// practice; both NULL is a one-shot reminder anchored on `anchorDate`. + intervalDays Int? @map("interval_days") + /// Optional RFC-5545 recurrence (e.g. `FREQ=YEARLY`). Maps straight through + /// the same recurrence engine the medication schedules use. + rrule String? + /// First-due anchor; the resolver falls back to `lastSatisfiedAt ?? createdAt` + /// when NULL. + anchorDate DateTime? @map("anchor_date") + /// Local hour (0–23) to fire the nudge, mirroring the mood-reminder hour. + /// DST-safe windowing is applied at the cron via the existing TZ helpers. + notifyHour Int @default(9) @map("notify_hour") + /// Free-text "wo": "Hausarzt", "Labor Dr. X". Optional. + location String? + /// Server-computed canonical next-due instant (server-authoritative). + nextDueAt DateTime? @map("next_due_at") + /// When the reminder was last fulfilled (a matching measurement or a manual + /// satisfy). Re-anchors the next-due computation. + lastSatisfiedAt DateTime? @map("last_satisfied_at") + /// Master on/off for this reminder. Distinct from soft-delete: a disabled + /// reminder is retained + editable but never fires. + enabled Boolean @default(true) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + /// Soft-delete tombstone. NULL = live; non-NULL = deleted at the instant. + deletedAt DateTime? @map("deleted_at") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId, deletedAt]) + @@index([userId, nextDueAt]) + @@map("measurement_reminders") +} + +// ─── Lab results ───────────────────────────────────────────── + +/// v1.17.1 — minimal structured lab store pairing with the Vorsorge "annual +/// blood panel" reminder. HealthLog has no first-class blood-biomarker type +/// beyond glucose, so a self-hoster who gets a panel back had nowhere to record +/// LDL / HbA1c / ferritin / TSH. This is the lean storage row: one analyte per +/// row, optional panel grouping, optional reference range. The doctor-report / +/// charting wiring lands in sibling work — this model only owns the shape. +/// Soft-deleted. +model LabResult { + id String @id @default(cuid()) + userId String @map("user_id") + /// Optional grouping label, e.g. "Großes Blutbild". NULL = standalone value. + panel String? + /// The biomarker name, e.g. "LDL", "HbA1c", "Ferritin". + analyte String + value Float + unit String + /// Optional reference-range bounds as reported by the lab. + referenceLow Float? @map("reference_low") + referenceHigh Float? @map("reference_high") + /// When the sample was taken (UTC instant; wall-clock anchoring is the + /// reader's concern per the schema's UTC-only convention). + takenAt DateTime @map("taken_at") + /// Provenance: "MANUAL" today; import paths set their own source string. + source String @default("MANUAL") + /// Optional free-text note, AES-256-GCM encrypted at rest via + /// `src/lib/crypto.ts` (Bytes column, `*Encrypted` suffix per convention). + noteEncrypted Bytes? @map("note_encrypted") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + /// Soft-delete tombstone. NULL = live; non-NULL = deleted at the instant. + deletedAt DateTime? @map("deleted_at") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId, analyte, takenAt]) + @@index([userId, deletedAt]) + @@map("lab_results") +} + // ─── User Achievements ─────────────────────────────────────── model UserAchievement {