diff --git a/prisma/migrations/0196_coach_reminder/migration.sql b/prisma/migrations/0196_coach_reminder/migration.sql new file mode 100644 index 000000000..d11bcd3a0 --- /dev/null +++ b/prisma/migrations/0196_coach_reminder/migration.sql @@ -0,0 +1,42 @@ +-- v1.22 (B2/B6) — Coach episodic reminder memory. +-- +-- A time-anchored "bring this back later" intent the Coach captured from a +-- conversation. The missing memory type between coach_facts (durable traits) +-- and coach_plans (standing if-then habits): an episode with a due moment and +-- a done state. The free-text note is AES-256-GCM ciphertext stored as `bytea` +-- via the shared Bytes codec — the same shape as coach_plans.if_cue_encrypted. +-- metric / status / trigger fields / dates / source stay plain so the daily +-- sweep and the injection picker can rank and filter without a per-row decrypt. +-- status (proposed | active | due | surfaced | done | dismissed) and +-- trigger_kind (date | context) are app-side closed enums, deliberately NOT DB +-- enums so this migration stays purely additive. Soft-delete via deleted_at. +CREATE TABLE "coach_reminders" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "note_encrypted" BYTEA NOT NULL, + "metric" TEXT, + "related_plan_id" TEXT, + "trigger_kind" TEXT NOT NULL DEFAULT 'date', + "due_at" TIMESTAMP(3), + "context_cue" TEXT, + "status" TEXT NOT NULL DEFAULT 'active', + "source" TEXT NOT NULL, + "source_conversation_id" TEXT, + "last_surfaced_at" TIMESTAMP(3), + "surface_count" INTEGER NOT NULL DEFAULT 0, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + "deleted_at" TIMESTAMP(3), + + CONSTRAINT "coach_reminders_pkey" PRIMARY KEY ("id") +); + +-- The daily sweep scans a user's active reminders whose due_at has passed; the +-- status + deleted_at columns are part of that predicate, so the composite +-- index lets the planner serve the sweep + the injection read index-backed. +CREATE INDEX "coach_reminders_user_id_status_due_at_deleted_at_idx" ON "coach_reminders"("user_id", "status", "due_at", "deleted_at"); + +-- The context-cue evaluation pass filters by trigger_kind + status per user. +CREATE INDEX "coach_reminders_user_id_trigger_kind_status_idx" ON "coach_reminders"("user_id", "trigger_kind", "status"); + +ALTER TABLE "coach_reminders" ADD CONSTRAINT "coach_reminders_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index bd9779f55..dc4333ff4 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -571,6 +571,7 @@ model User { coachConversations CoachConversation[] coachFacts CoachFact[] coachPlans CoachPlan[] + coachReminders CoachReminder[] coachUsage CoachUsage[] // v1.4.25 W8d — workout passthrough (Apple HKWorkout / Withings // getworkouts / future Strava). Schema lands now so the v1.5 iOS @@ -3938,6 +3939,56 @@ model CoachPlan { @@map("coach_plans") } +// v1.22 (B2/B6) — durable, user-scoped EPISODIC reminder memory: a +// time-anchored "bring this back later" intent the Coach captured from a +// conversation ("remind me about my sleep next week"). This is the middle +// memory type that neither CoachFact (durable traits) nor CoachPlan (standing +// if-then habits) covers — an episode with a due moment and a done state. +// +// The free-text note is the user's own framing, encrypted at rest (AES-256-GCM +// via the shared Bytes codec, exactly like CoachPlan's cue/action). `metric`, +// `status`, the trigger fields and the dates stay plain so the daily sweep and +// the injection picker can rank / filter without paying a per-row decrypt. +// +// `status` is an APP-SIDE closed enum (NOT a DB enum, to keep the migration +// purely additive): proposed | active | due | surfaced | done | dismissed. +// `triggerKind` is likewise app-side closed: date | context. Soft-delete via +// `deletedAt` so a "forget this" action is reversible / auditable. +model CoachReminder { + id String @id @default(cuid()) + userId String @map("user_id") + // What to bring back — the user's own framing, encrypted at rest. + noteEncrypted Bytes @map("note_encrypted") + // Optional link to the metric / plan it concerns; plain for filtering. + metric String? + relatedPlanId String? @map("related_plan_id") + // Trigger model — app-side closed enum (date | context). For `date`, `dueAt` + // carries the fire moment; for `context`, `contextCue` names a closed cue. + triggerKind String @default("date") @map("trigger_kind") + dueAt DateTime? @map("due_at") + contextCue String? @map("context_cue") + // Lifecycle: proposed | active | due | surfaced | done | dismissed. + status String @default("active") + // Provenance — how it was captured (sentinel | extractor | manual | action). + source String + sourceConversationId String? @map("source_conversation_id") + lastSurfacedAt DateTime? @map("last_surfaced_at") + // How many times the sweep has surfaced this without the user acting; capped + // before auto-dismiss so an ignored reminder cannot nag indefinitely. + surfaceCount Int @default(0) @map("surface_count") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + deletedAt DateTime? @map("deleted_at") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + // The daily sweep scans active reminders whose dueAt has passed; the status + + // deleted_at columns are part of the predicate so the read stays index-backed. + @@index([userId, status, dueAt, deletedAt]) + @@index([userId, triggerKind, status]) + @@map("coach_reminders") +} + // v1.15.20 — user-authored "about me" self-description the AI surfaces // (Coach system prompt + daily-briefing user prompt) inject as a clearly // delimited, user-provided context block. 1:1 with User and lazily diff --git a/scripts/rotate-encryption-key.ts b/scripts/rotate-encryption-key.ts index 53149ef99..7f786aeb6 100644 --- a/scripts/rotate-encryption-key.ts +++ b/scripts/rotate-encryption-key.ts @@ -328,6 +328,16 @@ async function main() { results.push(await rotateBytesColumn("CoachPlan", field, prisma.coachPlan)); } + // ───── CoachReminder (Bytes column) ───── + // "noteEncrypted" + results.push( + await rotateBytesColumn( + "CoachReminder", + "noteEncrypted", + prisma.coachReminder, + ), + ); + // ───── UserHealthProfile (Bytes columns) ───── // "aboutMeEncrypted" "conditionsEncrypted" "allergiesEncrypted" // "coachFocusEncrypted" "pendingQuestionsEncrypted" diff --git a/src/lib/crypto/encrypted-columns.ts b/src/lib/crypto/encrypted-columns.ts index 75613e154..1cf71c46d 100644 --- a/src/lib/crypto/encrypted-columns.ts +++ b/src/lib/crypto/encrypted-columns.ts @@ -112,6 +112,8 @@ export const ENCRYPTED_COLUMNS: readonly EncryptedColumn[] = [ { model: "CoachPlan", field: "ifCueEncrypted", kind: "bytes" }, { model: "CoachPlan", field: "thenActionEncrypted", kind: "bytes" }, { model: "CoachPlan", field: "targetEncrypted", kind: "bytes" }, + // v1.22 (B2/B6) — Coach episodic reminder free-text note. + { model: "CoachReminder", field: "noteEncrypted", kind: "bytes" }, // ───── User health profile (Bytes columns) ───── { model: "UserHealthProfile", field: "aboutMeEncrypted", kind: "bytes" },