Files
HealthLog/prisma/migrations/0054_personal_record/migration.sql
T
Marc-André Bombeck 8f57f5e0d7 feat(schema): PersonalRecord + direction helper (Migration 0054)
Schema-only release of the personal-records feature. Lands now so the
v1.5 iOS-Swift app can build its query path against the GET
/api/personal-records contract from day one; the detection worker
that populates the rows is explicitly out of v1.4.25 scope and
will land in v1.4.26 or v1.5.

Schema (prisma/migrations/0054_personal_record):
  - PersonalRecordDirection enum (MAX | MIN) stored on the row so
    the future detection worker doesn't re-resolve direction at
    query time.
  - personal_records table: userId FK (cascade), metricType
    (MeasurementType enum), nullable metricSlot for sport-specific
    PRs, direction NOT NULL, value + unit + achievedAt, optional
    sourceMeasurementId (SET NULL on delete so PRs survive the
    underlying measurement), source provenance, externalId.
  - Composite unique (userId, metricType, metricSlot, achievedAt)
    for idempotent worker re-runs.
  - Index (userId, metricType, value) for the "current PR" read
    pattern (ORDER BY value DESC LIMIT 1 / ASC LIMIT 1).

Direction helper (src/lib/personal-records/pr-direction.ts):
  - getPRDirection(MeasurementType) -> MAX | MIN | null. Exhaustive
    switch covering all 28 canonical enum values:
      MAX — steps, active energy, flights, distance, VO2 max, HRV,
            total body water, bone mass, muscle mass, time in daylight.
      MIN — resting HR, body fat, fat mass, visceral fat, vascular age,
            pulse-wave velocity, audio exposure (env + headphone).
      null — BP, glucose, body / skin temperature, pulse, SpO2, weight,
             sleep duration, fat-free mass (homeostatic, clinical-edge,
             or direction-ambiguous metrics; the v1.4.26 worker can
             enrich weight from User.thresholdsJson when it ships).
  - isPRTrackable() convenience predicate for the future worker.

API surface (src/app/api/personal-records/route.ts):
  - GET returns { data: PersonalRecord[], error: null } (canonical
    apiSuccess envelope).
  - Optional ?metricType=X filter, validated against
    measurementTypeEnum; unknown values drop silently so a
    loose-typed caller never 400s.
  - No write endpoint, no detection worker — explicitly v1.4.26
    scope.

Tests (drift-guard + route):
  - pr-direction: every MeasurementType returns one of MAX / MIN /
    null; per-direction bucket membership pinned.
  - route: 401 unauth, empty response, valid filter passthrough,
    unknown-filter graceful drop, seeded-record round-trip.
2026-05-14 16:01:26 +02:00

51 lines
2.7 KiB
SQL

-- v1.4.25 W8d — PersonalRecord schema (no detection worker yet).
--
-- Schema lands now so the v1.5 iOS-Swift app can build its query path
-- against a real GET /api/personal-records route from day one. The
-- worker that populates the rows (sweep on insert / nightly job) is
-- explicitly out of v1.4.25 scope — see the deferred section in the
-- W8d phase report. v1.4.25 ships the bare list endpoint that returns
-- whatever rows the DB carries (initially empty for every user).
--
-- Direction enum (MAX | MIN) is stored on the row rather than derived
-- at read time so the future worker doesn't re-resolve a per-metric
-- lookup on every query. Some metric types deliberately carry NULL
-- direction in the helper (BP, glucose, weight, ambiguous-direction
-- metrics) — those never produce a PersonalRecord row at all, so the
-- column stays NOT NULL for the metrics where a record is actually
-- defined.
--
-- Composite uniqueness on (userId, metricType, metricSlot, achievedAt)
-- ensures the detection worker is idempotent — re-running it never
-- inserts a duplicate. A multi-source tie (e.g. iPhone + Apple Watch
-- both report 12_348 steps for the same day) is broken by including
-- `source` so each provenance retains its row; the display layer
-- runs the existing pickCanonicalSourceRows() helper to surface one.
CREATE TYPE "personal_record_direction" AS ENUM ('MAX', 'MIN');
CREATE TABLE "personal_records" (
"id" TEXT PRIMARY KEY,
"user_id" TEXT NOT NULL REFERENCES "users"("id") ON DELETE CASCADE,
"metric_type" "measurement_type" NOT NULL,
-- Sport-specific slot for workout-driven PRs (e.g. "running_5km_time").
-- NULL for measurement-driven PRs that don't need a sport dimension.
"metric_slot" TEXT,
"direction" "personal_record_direction" NOT NULL,
"value" DOUBLE PRECISION NOT NULL,
"unit" TEXT NOT NULL,
"achieved_at" TIMESTAMP(3) NOT NULL,
-- FK to the Measurement row that achieved the record. SET NULL on
-- delete so a PR survives the deletion of the originating
-- measurement (the PR is the historical fact, not a derived view).
"source_measurement_id" TEXT REFERENCES "measurements"("id") ON DELETE SET NULL,
"source" "measurement_source" NOT NULL DEFAULT 'MANUAL',
"external_id" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE UNIQUE INDEX "personal_records_dedup_key"
ON "personal_records" ("user_id", "metric_type", "metric_slot", "achieved_at");
CREATE INDEX "personal_records_user_metric_idx"
ON "personal_records" ("user_id", "metric_type", "value");