diff --git a/prisma/migrations/0334_vorsorge_skip_ledger/migration.sql b/prisma/migrations/0334_vorsorge_skip_ledger/migration.sql new file mode 100644 index 000000000..f9e157775 --- /dev/null +++ b/prisma/migrations/0334_vorsorge_skip_ledger/migration.sql @@ -0,0 +1,54 @@ +-- Refs #223 + iOS #68 — Vorsorge snooze/skip + the completion ledger. +-- All additive; every existing reminder keeps its exact behaviour. + +-- Snooze cursor: non-NULL and in the future = "pushed back until this +-- instant". The snooze write sets next_due_at to the SAME instant, so every +-- due-state consumer moves with it; expiry is the clock passing the value. +ALTER TABLE "measurement_reminders" ADD COLUMN "snoozed_until" TIMESTAMP(3); + +-- Last honest skip. A skip re-anchors next_due_at at the skip instant but +-- never moves last_satisfied_at — done and skipped stay distinguishable. +ALTER TABLE "measurement_reminders" ADD COLUMN "last_skipped_at" TIMESTAMP(3); + +-- Lifetime skip counter ("skipped N times"). +ALTER TABLE "measurement_reminders" ADD COLUMN "skip_count" INTEGER NOT NULL DEFAULT 0; + +-- The completion ledger (iOS #68). The engine is single-cursor, so before +-- this table each satisfy overwrote the last and history was unrecoverable. +-- Append-only: one row per satisfy / skip, written by the one satisfy +-- primitive and the skip route. History starts with this release — the +-- cursor holds nothing to backfill from. +CREATE TYPE "measurement_reminder_event_kind" AS ENUM ('SATISFIED', 'SKIPPED'); + +CREATE TABLE "measurement_reminder_events" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "reminder_id" TEXT NOT NULL, + "kind" "measurement_reminder_event_kind" NOT NULL, + "occurred_at" TIMESTAMP(3) NOT NULL, + "on_time" BOOLEAN NOT NULL, + "source" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "measurement_reminder_events_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX "measurement_reminder_events_reminder_id_occurred_at_idx" + ON "measurement_reminder_events"("reminder_id", "occurred_at"); + +CREATE INDEX "measurement_reminder_events_user_id_idx" + ON "measurement_reminder_events"("user_id"); + +ALTER TABLE "measurement_reminder_events" + ADD CONSTRAINT "measurement_reminder_events_user_id_fkey" + FOREIGN KEY ("user_id") REFERENCES "users"("id") + ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "measurement_reminder_events" + ADD CONSTRAINT "measurement_reminder_events_reminder_id_fkey" + FOREIGN KEY ("reminder_id") REFERENCES "measurement_reminders"("id") + ON DELETE CASCADE ON UPDATE CASCADE; + +-- Custom-metric entries gain the soft-delete tombstone every peer entry +-- surface already has (undo parity — the one hard delete left). +ALTER TABLE "custom_metric_entries" ADD COLUMN "deleted_at" TIMESTAMP(3); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 25c7d873d..b2c46a171 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -879,6 +879,9 @@ model User { // Server computes the canonical `nextDueAt`; the cron fires through the // existing notification dispatcher. Soft-deleted. measurementReminders MeasurementReminder[] + // v1.37.20 — completion ledger behind the reminders (iOS #68): one + // append-only row per satisfy / skip. Cascade-deleted with the account. + measurementReminderEvents MeasurementReminderEvent[] // 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. @@ -5187,6 +5190,20 @@ model MeasurementReminder { /// excluded everywhere), whereas a booster is the engine's native case — /// recurring, satisfiable, cadence-bearing. vaccinationAntigen String? @map("vaccination_antigen") + /// v1.37.20 — snooze cursor. Non-NULL and in the future = "pushed back until + /// this instant". The snooze write sets `nextDueAt` to the SAME instant, so + /// every due-state consumer (digest, dashboard, cron, AI block) moves with + /// it without knowing snooze exists. Self-expiring: once the instant passes, + /// the clock comparison stops being true and no cleanup is needed. Any + /// recompute of `nextDueAt` (satisfy, skip, cadence PATCH) nulls it. + snoozedUntil DateTime? @map("snoozed_until") + /// v1.37.20 — last honest skip. A skip re-anchors `nextDueAt` at the skip + /// instant but NEVER moves `lastSatisfiedAt` — done and skipped stay + /// distinguishable forever. "Skipped cycle" derives as + /// `lastSkippedAt > lastSatisfiedAt` (or non-NULL with no satisfy yet). + lastSkippedAt DateTime? @map("last_skipped_at") + /// v1.37.20 — lifetime skip counter ("skipped N times"). + skipCount Int @default(0) @map("skip_count") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") /// Soft-delete tombstone. NULL = live; non-NULL = deleted at the instant. @@ -5201,6 +5218,10 @@ model MeasurementReminder { /// this reminder. SetNull on the far side — deleting the reminder leaves /// the vaccination record intact. vaccinations VaccinationRecord[] + /// v1.37.20 — the completion ledger (iOS #68): every satisfy and every + /// skip appends one row. Cascade: the ledger has no meaning without its + /// reminder. + events MeasurementReminderEvent[] @@index([userId, deletedAt]) @@index([userId, nextDueAt]) @@ -5234,6 +5255,55 @@ enum ReminderOrigin { @@map("reminder_origin") } +/// v1.37.20 — what a completion-ledger row records: an honest fulfilment +/// (`SATISFIED` — a matching reading landed or the user marked it done) or an +/// honest skip (`SKIPPED` — the user deliberately let this cycle go, which is +/// NOT a completion and never touches `lastSatisfiedAt`). +enum MeasurementReminderEventKind { + SATISFIED + SKIPPED + + @@map("measurement_reminder_event_kind") +} + +/// v1.37.20 — the Vorsorge completion ledger (iOS #68). The reminder engine is +/// single-cursor (`nextDueAt` + `lastSatisfiedAt`), so before this table each +/// satisfy overwrote the previous one and history was unrecoverable. Every +/// path through the ONE satisfy primitive (`satisfyReminder`) and the skip +/// route appends one row here; nothing ever updates or deletes a row short of +/// deleting the reminder itself. History starts at the release that ships +/// this — the single cursor holds nothing to backfill from, which the iOS #68 +/// thread records as agreed. +model MeasurementReminderEvent { + id String @id @default(cuid()) + userId String @map("user_id") + reminderId String @map("reminder_id") + kind MeasurementReminderEventKind + /// The event instant: the satisfy instant (which may be the backdated + /// reading's own timestamp) or the skip instant (always the server clock). + occurredAt DateTime @map("occurred_at") + /// Derived AT WRITE TIME against the reminder's `nextDueAt` as it stood + /// before the event re-anchored it: `true` when the event landed at or + /// before the due instant (or the cadence had no computable due). Clients + /// (web + iOS) consume this resolved value and never re-derive it — the + /// pre-event `nextDueAt` is gone after the write, so write time is the only + /// honest moment to answer the question. + onTime Boolean @map("on_time") + /// Which path produced the event — a closed set of engine call sites + /// (`manual`, `auto_measurement`, `auto_lab`, `telegram`, `vaccination`, + /// `encounter`, `skip`), asserted in code, not free text. + source String + createdAt DateTime @default(now()) @map("created_at") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + reminder MeasurementReminder @relation(fields: [reminderId], references: [id], onDelete: Cascade) + + /// The history endpoint reads newest-first per reminder. + @@index([reminderId, occurredAt]) + @@index([userId]) + @@map("measurement_reminder_events") +} + // ─── Lab results ───────────────────────────────────────────── /// v1.17.1 — minimal structured lab store pairing with the Vorsorge "annual @@ -5432,6 +5502,10 @@ model CustomMetricEntry { measuredAt DateTime @map("measured_at") note String? createdAt DateTime @default(now()) @map("created_at") + /// v1.37.20 — soft-delete tombstone, peer parity with Measurement / + /// MoodEntry (every other entry surface deletes recoverably with an undo + /// toast; custom-metric entries were the one hard delete). NULL = live. + deletedAt DateTime? @map("deleted_at") user User @relation(fields: [userId], references: [id], onDelete: Cascade) metric CustomMetric @relation(fields: [customMetricId], references: [id], onDelete: Cascade)