mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
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:
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 $$;
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user