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
This commit is contained in:
Marc-André Bombeck committed 2026-08-08 06:51:42 +02:00
1 parent 2f68b3dc79
commit 7a4ee19c68
4 files changed
+200

No files matched your search

@@ -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");
+101
View File
@@ -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
+11
View File
@@ -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) +
+4
View File
@@ -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