From 7a4ee19c6826ef719197177ef27c8315c03fbaba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 8 Aug 2026 06:51:42 +0200 Subject: [PATCH] feat(mood): add the MoodContext sub-row and its encrypted note - migration 0311 creates mood_contexts, keyed by the entry, cascading from both the entry and the owner - work, contacts, leisure and one notable event; every field nullable, and an entry with nothing filled in gets no row at all - notes_encrypted registered in ENCRYPTED_COLUMNS and in the rotation script, so a key rotation cannot strand it --- .../0311_mood_context/migration.sql | 84 +++++++++++++++ prisma/schema.prisma | 101 ++++++++++++++++++ scripts/rotate-encryption-key.ts | 11 ++ src/lib/crypto/encrypted-columns.ts | 4 + 4 files changed, 200 insertions(+) create mode 100644 prisma/migrations/0311_mood_context/migration.sql diff --git a/prisma/migrations/0311_mood_context/migration.sql b/prisma/migrations/0311_mood_context/migration.sql new file mode 100644 index 000000000..2930d6233 --- /dev/null +++ b/prisma/migrations/0311_mood_context/migration.sql @@ -0,0 +1,84 @@ +-- v1.38 — the day's context around a mood entry. +-- +-- Work, contacts, leisure and a notable event, as an optional sub-row rather +-- than as twenty more columns on `mood_entries`. Level A went the other way in +-- migration 0310, and for the opposite reasons: those five values are on +-- nearly every row and every mood reader wants them, while this is sparse by +-- design (only the sections somebody opened produce anything at all) and only +-- the context surfaces read it. +-- +-- An entry with nothing filled in gets NO row here. A table of all-NULL rows +-- would record "asked and answered nothing" where the truth is "never asked", +-- and the two are different facts. +-- +-- Nothing in this table duplicates another module. Sleep, activity and vitals +-- are resolved at read time from the modules that own them, and body symptoms +-- stay in the illness tables. A stored copy would be a second home for the +-- same fact and would drift the first time somebody corrected the original. +-- +-- The two multi-select lists are JSON arrays in TEXT columns, matching the +-- `mood_entries.tags` convention. Not queryable, read whole — acceptable +-- because every reader wants an entry's whole context anyway. +-- +-- No CHECK constraints on the numeric ranges. Bounds are enforced server-side, +-- the same way `mood_entry_tag_links.rating` documents: a later change to a +-- scale must not strand rows written under the old one. + +CREATE TABLE "mood_contexts" ( + "mood_entry_id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + + -- Work. `work_status` is one of off / partial / regular / overtime. + "work_status" TEXT, + "work_minutes" INTEGER, + "overtime_minutes" INTEGER, + "work_load" INTEGER, + "work_satisfaction" INTEGER, + + -- Contacts. `contact_circles` is a JSON array of circle keys; the count is + -- never scored, which is why the experienced quality is its own column. + "contact_circles" TEXT, + "contact_form" TEXT, + "contact_extent" TEXT, + "contact_quality" INTEGER, + "contact_support" INTEGER, + + -- Leisure. `leisure_categories` is a JSON array of category keys. + "leisure_categories" TEXT, + "leisure_minutes" INTEGER, + "leisure_joy" INTEGER, + "leisure_recovery" INTEGER, + + -- One notable event, if there was one. + "event_type" TEXT, + "event_valence" INTEGER, + "event_at" TIMESTAMPTZ(3), + + -- One AES-256-GCM note for the whole context, not one per section: four + -- ciphertext columns would be four rotation entries and four ways to + -- forget one. Registered in ENCRYPTED_COLUMNS and in the rotation script. + "notes_encrypted" BYTEA, + + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(3) NOT NULL, + + CONSTRAINT "mood_contexts_pkey" PRIMARY KEY ("mood_entry_id") +); + +-- Deleting the entry deletes its context. The context has no meaning without +-- the entry it describes, and a row pointing at a deleted entry would be +-- readable by nobody and deletable by nobody. The user cascade matches every +-- other user-scoped table here. +ALTER TABLE "mood_contexts" + ADD CONSTRAINT "mood_contexts_mood_entry_id_fkey" + FOREIGN KEY ("mood_entry_id") REFERENCES "mood_entries"("id") + ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "mood_contexts" + ADD CONSTRAINT "mood_contexts_user_id_fkey" + FOREIGN KEY ("user_id") REFERENCES "users"("id") + ON DELETE CASCADE ON UPDATE CASCADE; + +-- The user-scoped sweep: the context analytics read a window of an account's +-- contexts, and the account-deletion path clears them by owner. +CREATE INDEX "mood_contexts_user_id_idx" ON "mood_contexts"("user_id"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index affc5e70c..92cb5c569 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -773,6 +773,7 @@ model User { googleHealthConnection GoogleHealthConnection? googleHealthOAuthStates GoogleHealthOAuthState[] moodEntries MoodEntry[] + moodContexts MoodContext[] mentalHealthAssessments MentalHealthAssessment[] moodTagsCustom MoodTag[] @relation("UserMoodTags") moodTagCategoriesCustom MoodTagCategory[] @relation("UserMoodTagCategories") @@ -4124,6 +4125,12 @@ model MoodEntry { // `tags` untouched. tagLinks MoodEntryTagLink[] + /// v1.38 — the day's context, when somebody chose to add one. A separate + /// optional row rather than columns here: it is genuinely sparse, it is + /// twenty fields wide, and no reader outside the context surfaces wants it + /// inline. + context MoodContext? + @@unique([userId, date, moodLoggedAt]) // v1.12.1 — NULL-distinct external dedup key (migration 0122). Importers // that carry a source-stable id upsert on this; native/MANUAL entries @@ -4155,6 +4162,100 @@ model MoodEntry { @@map("mood_entries") } +/// v1.38 — the shape of the day around a mood entry: work, contacts, leisure, +/// and a notable event if there was one. +/// +/// A 1:1 optional sub-row keyed by the entry it belongs to. Every field is +/// nullable and an entry with nothing filled in has no row at all — an empty +/// sub-object is not a row, because a table full of all-NULL rows would say +/// "asked and answered nothing" where the truth is "never asked". +/// +/// **Nothing here is captured twice.** Sleep, activity and vitals belong to +/// the modules that own them and are resolved at read time +/// (`src/lib/mood/linked-context.ts`), never copied onto this row: a copy +/// would go stale the moment somebody corrected a sleep session, and then two +/// surfaces would disagree about the same night. Body symptoms and their +/// severity belong to the illness module (`IllnessDayLog`, +/// `IllnessSymptomLink`), which this row links to and does not duplicate. +/// +/// The multi-select lists ride as JSON arrays in string columns, matching +/// `MoodEntry.tags`. That column shape is not queryable and is read whole — +/// acceptable here because every reader wants the entry's whole context +/// anyway, and the alternative is two join tables for what is a checkbox row. +/// A future filter over "days that included the partner" would need a real +/// column or a join, and would be a schema change, not a query change. +/// +/// The numeric ranges are stated in the trailing comments and enforced in Zod, +/// not as CHECK constraints — the same trade `MoodEntryTagLink.rating` +/// documents: a later change to a scale must not strand rows written under the +/// old one. +/// +/// A delegate holding a READ grant sees this row, including the decrypted +/// note, because a grant is whole-record by design and carries no per-domain +/// scope. That is deliberate and is stated here so nobody discovers it later: +/// withholding part of a record from a delegate would be a new capability, not +/// a field select. +model MoodContext { + /// The entry this context belongs to, and the primary key. One context per + /// entry, enforced by the key rather than by application code. + moodEntryId String @id @map("mood_entry_id") + /// Carried so the context can be queried and deleted per user without a + /// join through the entry. Every read still pins it. + userId String @map("user_id") + + // ── Work ──────────────────────────────────────────────────── + /// `off` | `partial` | `regular` | `overtime` — `WORK_STATUS_KEYS`. + workStatus String? @map("work_status") + workMinutes Int? @map("work_minutes") // 0-1440 + overtimeMinutes Int? @map("overtime_minutes") // 0-1440 + workLoad Int? @map("work_load") // 0-10, inverse-oriented (higher is heavier) + workSatisfaction Int? @map("work_satisfaction") // 0-10 + + // ── Contacts ──────────────────────────────────────────────── + /// JSON array of `CONTACT_CIRCLE_KEYS`. Never scored: how many people + /// somebody saw is not a good or a bad number, which is why the experienced + /// quality below is its own field. + contactCircles String? @map("contact_circles") + /// `inPerson` | `phone` | `video` | `written` — `CONTACT_FORM_KEYS`. + contactForm String? @map("contact_form") + /// `brief` | `medium` | `extended` — `CONTACT_EXTENT_KEYS`. + contactExtent String? @map("contact_extent") + contactQuality Int? @map("contact_quality") // 0-10 + contactSupport Int? @map("contact_support") // 0-10 + + // ── Leisure ───────────────────────────────────────────────── + /// JSON array of `LEISURE_CATEGORY_KEYS`. + leisureCategories String? @map("leisure_categories") + leisureMinutes Int? @map("leisure_minutes") // 0-1440 + leisureJoy Int? @map("leisure_joy") // 0-10 + leisureRecovery Int? @map("leisure_recovery") // 0-10 + + // ── A notable event ───────────────────────────────────────── + /// One of `EVENT_TYPE_KEYS`. One per entry: a day with two notable events + /// has two things worth writing in the note, and a multi-select would invite + /// ticking every box instead of naming the one that mattered. + eventType String? @map("event_type") + eventValence Int? @map("event_valence") // -5 to +5 + /// When it happened, if the person knew. Absent is common and means absent. + eventAt DateTime? @map("event_at") + + /// One free-text note for the whole context, AES-256-GCM at rest (Bytes, the + /// shared codec every other note column uses). One column rather than one + /// per section: four ciphertext columns would be four rotation entries and + /// four ways to forget one. Registered in `ENCRYPTED_COLUMNS` and in + /// `scripts/rotate-encryption-key.ts`. + notesEncrypted Bytes? @map("notes_encrypted") + + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + moodEntry MoodEntry @relation(fields: [moodEntryId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId]) + @@map("mood_contexts") +} + // ─── Mood Tag Taxonomy (v1.8.5 W6-tax) ──────────────────────── // // Structured, icon-bearing tag catalog modelled on the standalone mood diff --git a/scripts/rotate-encryption-key.ts b/scripts/rotate-encryption-key.ts index f764c55df..7c4c2ddd1 100644 --- a/scripts/rotate-encryption-key.ts +++ b/scripts/rotate-encryption-key.ts @@ -479,6 +479,17 @@ async function main() { prisma.measurement, ), ); + // v1.38 — the day-context note on a mood entry. Its own table, so its own + // walk; a context row exists only where somebody added one, and a row with + // no note carries NULL here and is skipped like every other nullable Bytes + // column. + results.push( + await rotateBytesColumn( + "MoodContext", + "notesEncrypted", + prisma.moodContext, + ), + ); // ───── v1.25 medication free-text notes (Bytes columns) ───── // "notesEncrypted" (MedicationSideEffect + MedicationInventoryItem) + diff --git a/src/lib/crypto/encrypted-columns.ts b/src/lib/crypto/encrypted-columns.ts index 3e3cc1241..de88ef822 100644 --- a/src/lib/crypto/encrypted-columns.ts +++ b/src/lib/crypto/encrypted-columns.ts @@ -204,6 +204,10 @@ export const ENCRYPTED_COLUMNS: readonly EncryptedColumn[] = [ // IllnessDayLog / LabResult note columns above; Bytes via the shared codec. { model: "MoodEntry", field: "noteEncrypted", kind: "bytes" }, { model: "Measurement", field: "notesEncrypted", kind: "bytes" }, + // v1.38 — the one free-text note on a mood entry's day context. Same codec + // as the entry note beside it; one column for the whole context rather than + // one per section, so there is one rotation entry rather than four. + { model: "MoodContext", field: "notesEncrypted", kind: "bytes" }, // ───── v1.25 medication free-text notes (Bytes columns) ───── // Side-effect log note, dose-change titration note, and inventory-item