feat(schema): add per-user Polar/Oura credentials, measurement reminders, lab results

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.
This commit is contained in:
Marc-André Bombeck committed 2026-06-14 23:04:48 +02:00
1 parent 94fd837d48
commit 355fa9a670
2 files changed
+247

No files matched your search

@@ -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 $$;
+129
View File
@@ -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 {