mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
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:
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");
|
||||
@@ -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
|
||||
|
||||
@@ -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) +
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user