From a25ec211cd997e5349ce1b60965f381bafee1c76 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 8 Aug 2026 07:07:03 +0200 Subject: [PATCH] feat(mood): capture and persist a mood entry's day context - moodContextSchema derives every enum from the vocabulary, so a value it does not name answers 422 through returnAllZodIssues rather than being dropped - the context is written in the same transaction as the entry and its links; a failure rolls the entry back rather than leaving a client retrying a 5xx into a duplicate - an entry with nothing filled in stores no row, and a context that says nothing removes the one that was there - the create, list, detail and edit reads answer the context with its lists decoded and its note decrypted; the ciphertext never leaves the server --- .../mood-entries/[id]/__tests__/route.test.ts | 17 + src/app/api/mood-entries/[id]/route.ts | 41 +- .../api/mood-entries/__tests__/route.test.ts | 10 + src/app/api/mood-entries/route.ts | 252 +++++++------ src/lib/mood/context.ts | 218 +++++++++++ src/lib/validations/mood.ts | 96 +++++ tests/integration/mood-context.test.ts | 357 ++++++++++++++++++ 7 files changed, 880 insertions(+), 111 deletions(-) create mode 100644 src/lib/mood/context.ts create mode 100644 tests/integration/mood-context.test.ts diff --git a/src/app/api/mood-entries/[id]/__tests__/route.test.ts b/src/app/api/mood-entries/[id]/__tests__/route.test.ts index ec47b786a..3570b2b13 100644 --- a/src/app/api/mood-entries/[id]/__tests__/route.test.ts +++ b/src/app/api/mood-entries/[id]/__tests__/route.test.ts @@ -11,6 +11,16 @@ const txClient = vi.hoisted(() => ({ moodEntryTagLink: { findMany: vi.fn(), }, + // v1.38 — the day context is replaced inside the same transaction. Answering + // null from `findUnique` keeps this fake honest: what a context write + // actually stores is proven against real Postgres in + // `tests/integration/mood-context.test.ts`, not by a stub that agrees with + // whatever it is asked. + moodContext: { + deleteMany: vi.fn(), + upsert: vi.fn(), + findUnique: vi.fn(), + }, })); vi.mock("@/lib/db", () => ({ @@ -114,6 +124,8 @@ beforeEach(() => { ); txClient.moodEntry.update.mockResolvedValue(UPDATED_ENTRY as never); txClient.moodEntryTagLink.findMany.mockResolvedValue([]); + txClient.moodContext.deleteMany.mockResolvedValue({ count: 0 }); + txClient.moodContext.findUnique.mockResolvedValue(null); vi.mocked(replaceTagLinks).mockResolvedValue(undefined); vi.mocked(replaceRatedFactorLinks).mockResolvedValue(undefined); // v1.7.0 sync — PUT now looks the row up via `findFirst` with a @@ -296,6 +308,11 @@ describe("PUT /api/mood-entries/[id] — split tag replacement", () => { moodEntryTagLink: { findMany: vi.fn(), }, + moodContext: { + deleteMany: vi.fn(), + upsert: vi.fn(), + findUnique: vi.fn(), + }, }; vi.mocked(replaceTagLinks).mockImplementationOnce(async () => { draft.binary = ["calm"]; diff --git a/src/app/api/mood-entries/[id]/route.ts b/src/app/api/mood-entries/[id]/route.ts index c3f09b24c..f6535df55 100644 --- a/src/app/api/mood-entries/[id]/route.ts +++ b/src/app/api/mood-entries/[id]/route.ts @@ -16,6 +16,7 @@ import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { moodDateKey, DEFAULT_TIMEZONE } from "@/lib/mood/date-key"; import { deriveA1, shapeLevelA } from "@/lib/mood/level-a"; +import { contextForWire, persistMoodContext } from "@/lib/mood/context"; import { encryptNote, shapeMoodNote } from "@/lib/crypto/note-cipher"; import { invalidateUserMood } from "@/lib/cache/invalidate"; import { recomputeMoodBucketsForEntry } from "@/lib/rollups/mood-rollups"; @@ -47,6 +48,9 @@ export const GET = apiHandler( // (not `findUnique`) because `deletedAt` is not part of a unique index. const entry = await prisma.moodEntry.findFirst({ where: { id, deletedAt: null }, + // v1.38 — the day context rides with the entry rather than behind a + // second request: the edit surface needs both or neither. + include: { context: true }, }); if (!entry || entry.userId !== user.id) { @@ -58,10 +62,13 @@ export const GET = apiHandler( meta: { moodEntryId: id }, }); + const { context, ...row } = entry; return apiSuccess({ // v1.37 — level-A values under the keys the write path takes. - ...shapeLevelA(shapeMoodNote(entry)), - tags: parseTags(entry.tags), + ...shapeLevelA(shapeMoodNote(row)), + tags: parseTags(row.tags), + // v1.38 — lists decoded, note decrypted, ciphertext dropped. + context: context ? contextForWire(context) : null, }); }, ); @@ -201,6 +208,20 @@ export const PUT = apiHandler( ); } + // v1.38 — the day context replaces whole when the request carried + // one, and is left alone when it did not, matching the two link sets + // above. Inside the same transaction: a context write that fails rolls + // the edit and its syncVersion increment back with it. + const contextOutcome = await persistMoodContext( + tx, + id, + user.id, + data.context, + ); + const storedContext = await tx.moodContext.findUnique({ + where: { moodEntryId: id }, + }); + // Return the same split shape as list/create: binary keys are // independent from rated-factor keys and their per-entry scores. const links = await tx.moodEntryTagLink.findMany({ @@ -213,6 +234,8 @@ export const PUT = apiHandler( return { entry: updated, + contextOutcome, + persistedContext: storedContext, persistedTagKeys: links .filter((link) => link.moodTag.kind !== "RATED") .map((link) => link.moodTag.key), @@ -252,8 +275,13 @@ export const PUT = apiHandler( } throw error; } - const { entry, persistedTagKeys, persistedRatedFactors } = - transactionOutcome.result; + const { + entry, + persistedTagKeys, + persistedRatedFactors, + persistedContext, + contextOutcome, + } = transactionOutcome.result; await auditLog("moodEntry.update", { userId: user.id, @@ -278,6 +306,7 @@ export const PUT = apiHandler( ...(data.tags !== undefined ? ["tags"] : []), ...(data.tagKeys !== undefined ? ["tagKeys"] : []), ...(data.ratedFactors !== undefined ? ["ratedFactors"] : []), + ...(data.context !== undefined ? ["context"] : []), ], }), }, @@ -285,7 +314,7 @@ export const PUT = apiHandler( annotate({ action: { name: "mood-entries.update" }, - meta: { moodEntryId: id }, + meta: { moodEntryId: id, mood_context: contextOutcome }, }); // v1.4.34 IW-G — bust per-user mood + achievements + analytics caches. @@ -328,6 +357,8 @@ export const PUT = apiHandler( // refetch (shape-matches the list GET). tagKeys: persistedTagKeys, ratedFactors: persistedRatedFactors, + // v1.38 — the stored context after the edit, or null when there is none. + context: persistedContext ? contextForWire(persistedContext) : null, }); }, ); diff --git a/src/app/api/mood-entries/__tests__/route.test.ts b/src/app/api/mood-entries/__tests__/route.test.ts index f0cfc624f..383671464 100644 --- a/src/app/api/mood-entries/__tests__/route.test.ts +++ b/src/app/api/mood-entries/__tests__/route.test.ts @@ -19,6 +19,16 @@ const txClient = { moodEntryTagLink: { findMany: vi.fn().mockResolvedValue([]), }, + // v1.38 — the day context is written inside the same transaction, and the + // route reads it back for the response. A fake that answered a row here + // regardless of what was written would prove nothing, so `findUnique` + // answers null and the context-carrying claims live in the integration + // suite (`tests/integration/mood-context.test.ts`) against real Postgres. + moodContext: { + deleteMany: vi.fn().mockResolvedValue({ count: 0 }), + upsert: vi.fn(), + findUnique: vi.fn().mockResolvedValue(null), + }, }; vi.mock("@/lib/db", () => ({ diff --git a/src/app/api/mood-entries/route.ts b/src/app/api/mood-entries/route.ts index cc3ef97c8..350095e60 100644 --- a/src/app/api/mood-entries/route.ts +++ b/src/app/api/mood-entries/route.ts @@ -25,6 +25,7 @@ import { withIdempotency } from "@/lib/idempotency"; import { encryptNote, shapeMoodNote } from "@/lib/crypto/note-cipher"; import { moodDateKey, DEFAULT_TIMEZONE } from "@/lib/mood/date-key"; import { levelAForWrite, shapeLevelA } from "@/lib/mood/level-a"; +import { contextForWire, persistMoodContext } from "@/lib/mood/context"; import { invalidateUserMood } from "@/lib/cache/invalidate"; import { recomputeMoodBucketsForEntry } from "@/lib/rollups/mood-rollups"; import { @@ -114,6 +115,10 @@ export const GET = apiHandler(async (request: NextRequest) => { moodTag: { select: { key: true, kind: true } }, }, }, + // v1.38 — the day context, so the edit dialog can pre-populate the + // sections without a second round trip per row. Sparse by design: most + // rows carry none and the join costs nothing on those. + context: true, }, }), prisma.moodEntry.count({ where }), @@ -124,7 +129,7 @@ export const GET = apiHandler(async (request: NextRequest) => { meta: { total, limit, offset }, }); - const entriesWithParsedTags = entries.map(({ tagLinks, ...e }) => ({ + const entriesWithParsedTags = entries.map(({ tagLinks, context, ...e }) => ({ // v1.23 — decrypt `noteEncrypted` onto `note`, strip the ciphertext. // v1.37 — the five level-A values under the keys the create path takes, // so the edit form reads back what it wrote instead of mapping column @@ -145,6 +150,9 @@ export const GET = apiHandler(async (request: NextRequest) => { key: link.moodTag.key, rating: link.rating as number, })), + // v1.38 — the day context, or null. The ciphertext never leaves the + // server: `contextForWire` reads the note and drops the column. + context: context ? contextForWire(context) : null, })); return apiSuccess({ @@ -207,6 +215,7 @@ async function postMoodEntry(request: NextRequest) { tagKeys, ratedFactors, note, + context, moodLoggedAt, source, externalId, @@ -257,118 +266,141 @@ async function postMoodEntry(request: NextRequest) { // tag-link failure must roll the entry back too — otherwise a client // retry on the 5xx mints a duplicate entry. The tx client is threaded // through the helper so both writes commit (or abort) together. - const { entry, persistedTagKeys, persistedRatedFactors } = - await prisma.$transaction(async (tx) => { - // v1.12.1 — when the client supplies a source-stable `externalId`, - // upsert on the NULL-distinct `(userId, source, externalId)` key so - // a re-post with the same id updates the existing row in place - // (idempotent re-import) instead of minting a duplicate or 409-ing. - // Without an `externalId`, fall back to the legacy first-write - // `create` exactly as before — a same-tuple re-post then trips the - // `(userId, date, moodLoggedAt)` unique and surfaces as a 409 below. - const created = externalId - ? await tx.moodEntry.upsert({ - where: { - userId_source_externalId: { - userId: user.id, - source: resolvedSource, - externalId, - }, - }, - create: { + const { + entry, + persistedTagKeys, + persistedRatedFactors, + persistedContext, + contextOutcome, + } = await prisma.$transaction(async (tx) => { + // v1.12.1 — when the client supplies a source-stable `externalId`, + // upsert on the NULL-distinct `(userId, source, externalId)` key so + // a re-post with the same id updates the existing row in place + // (idempotent re-import) instead of minting a duplicate or 409-ing. + // Without an `externalId`, fall back to the legacy first-write + // `create` exactly as before — a same-tuple re-post then trips the + // `(userId, date, moodLoggedAt)` unique and surfaces as a 409 below. + const created = externalId + ? await tx.moodEntry.upsert({ + where: { + userId_source_externalId: { userId: user.id, - date, - tz, - mood, - score, - ...levelA, - tags: tags ? JSON.stringify(tags) : null, - note: null, - noteEncrypted: encryptNote(note ?? null), source: resolvedSource, externalId, - moodLoggedAt, }, - // A re-post restates pleasantness, because the label it carries - // always implies one and a stale A1 beside a changed mood would - // contradict the entry's own face. It does NOT restate the other - // four: `levelA` carries them only when this request did, so a - // client re-posting without sliders — the phone, whose build has - // none — leaves the stress and energy somebody answered on the - // web exactly where they were. An explicit null clears one. - update: { - date, - tz, - mood, - score, - ...levelA, - tags: tags ? JSON.stringify(tags) : null, - note: null, - noteEncrypted: encryptNote(note ?? null), - moodLoggedAt, - }, - }) - : await tx.moodEntry.create({ - data: { - userId: user.id, - date, - tz, - mood, - score, - // A fresh row: an omitted dimension lands as NULL, which is - // the same thing the upsert's arms mean by omitting it. - ...levelA, - tags: tags ? JSON.stringify(tags) : null, - note: null, - noteEncrypted: encryptNote(note ?? null), - source: resolvedSource, - moodLoggedAt, - }, - }); + }, + create: { + userId: user.id, + date, + tz, + mood, + score, + ...levelA, + tags: tags ? JSON.stringify(tags) : null, + note: null, + noteEncrypted: encryptNote(note ?? null), + source: resolvedSource, + externalId, + moodLoggedAt, + }, + // A re-post restates pleasantness, because the label it carries + // always implies one and a stale A1 beside a changed mood would + // contradict the entry's own face. It does NOT restate the other + // four: `levelA` carries them only when this request did, so a + // client re-posting without sliders — the phone, whose build has + // none — leaves the stress and energy somebody answered on the + // web exactly where they were. An explicit null clears one. + update: { + date, + tz, + mood, + score, + ...levelA, + tags: tags ? JSON.stringify(tags) : null, + note: null, + noteEncrypted: encryptNote(note ?? null), + moodLoggedAt, + }, + }) + : await tx.moodEntry.create({ + data: { + userId: user.id, + date, + tz, + mood, + score, + // A fresh row: an omitted dimension lands as NULL, which is + // the same thing the upsert's arms mean by omitting it. + ...levelA, + tags: tags ? JSON.stringify(tags) : null, + note: null, + noteEncrypted: encryptNote(note ?? null), + source: resolvedSource, + moodLoggedAt, + }, + }); - if ( - (tagKeys && tagKeys.length > 0) || - (ratedFactors && ratedFactors.length > 0) - ) { - // Unknown / non-RATED keys are dropped inside the helper (the - // catalog is the source of truth). An out-of-scale rating - // throws `RatedFactorOutOfRangeError`, rolling the tx back. - await createTagLinks( - created.id, - user.id, - tagKeys ?? [], - tx, - ratedFactors ?? [], - ); - } + if ( + (tagKeys && tagKeys.length > 0) || + (ratedFactors && ratedFactors.length > 0) + ) { + // Unknown / non-RATED keys are dropped inside the helper (the + // catalog is the source of truth). An out-of-scale rating + // throws `RatedFactorOutOfRangeError`, rolling the tx back. + await createTagLinks( + created.id, + user.id, + tagKeys ?? [], + tx, + ratedFactors ?? [], + ); + } - // v1.8.5 / v1.12.0 — read the persisted links back so the create - // response mirrors the list GET shape exactly: binary keys and - // rated factors split by `kind` (unknown keys already filtered). - const links = await tx.moodEntryTagLink.findMany({ - where: { moodEntryId: created.id }, - select: { - rating: true, - moodTag: { select: { key: true, kind: true } }, - }, - }); - - return { - entry: created, - persistedTagKeys: links - .filter((link) => link.moodTag.kind !== "RATED") - .map((link) => link.moodTag.key), - persistedRatedFactors: links - .filter( - (link) => link.moodTag.kind === "RATED" && link.rating !== null, - ) - .map((link) => ({ - key: link.moodTag.key, - rating: link.rating as number, - })), - }; + // v1.38 — the day context, in the SAME transaction as the entry and + // its links, for the same reason they are: it is user-intended + // content, not a cache, so a context write that fails has to roll the + // entry back rather than leave a client retrying a 5xx into a + // duplicate entry. An absent `context` key leaves a stored one alone; + // a context that says nothing removes the row. + const contextOutcome = await persistMoodContext( + tx, + created.id, + user.id, + context, + ); + const storedContext = await tx.moodContext.findUnique({ + where: { moodEntryId: created.id }, }); + // v1.8.5 / v1.12.0 — read the persisted links back so the create + // response mirrors the list GET shape exactly: binary keys and + // rated factors split by `kind` (unknown keys already filtered). + const links = await tx.moodEntryTagLink.findMany({ + where: { moodEntryId: created.id }, + select: { + rating: true, + moodTag: { select: { key: true, kind: true } }, + }, + }); + + return { + entry: created, + contextOutcome, + persistedContext: storedContext, + persistedTagKeys: links + .filter((link) => link.moodTag.kind !== "RATED") + .map((link) => link.moodTag.key), + persistedRatedFactors: links + .filter( + (link) => link.moodTag.kind === "RATED" && link.rating !== null, + ) + .map((link) => ({ + key: link.moodTag.key, + rating: link.rating as number, + })), + }; + }); + await auditLog("moodEntry.create", { userId: user.id, ipAddress: getClientIp(request), @@ -377,7 +409,11 @@ async function postMoodEntry(request: NextRequest) { annotate({ action: { name: "mood-entries.create" }, - meta: { moodEntryId: entry.id, mood }, + // `mood_context` says written / removed / untouched rather than a + // boolean, because "the request said nothing" and "the request cleared + // it" are different acts and a dashboard that folded them together + // could not tell an abandoned section from a deliberate one. + meta: { moodEntryId: entry.id, mood, mood_context: contextOutcome }, }); // v1.4.34 IW-G — bust per-user mood + achievements + analytics caches. @@ -411,6 +447,10 @@ async function postMoodEntry(request: NextRequest) { tagKeys: persistedTagKeys, // v1.12.0 — rated factors with their per-entry score. ratedFactors: persistedRatedFactors, + // v1.38 — the stored context, lists decoded and note decrypted. Null + // when the entry carries none, which is a different answer from an + // object full of nulls and reads as one. + context: persistedContext ? contextForWire(persistedContext) : null, }, 201, ); diff --git a/src/lib/mood/context.ts b/src/lib/mood/context.ts new file mode 100644 index 000000000..a976428f3 --- /dev/null +++ b/src/lib/mood/context.ts @@ -0,0 +1,218 @@ +/** + * The one place a mood entry's day context crosses between the wire and the + * `mood_contexts` row. + * + * Three call sites need this — the create route, the edit route and the bulk + * route — plus the backup builder and the restore. Left to themselves they + * would each build their own `data` object, and one of them would eventually + * forget a column: exactly how the level-A values shipped twice under two + * spellings before `shapeLevelA` existed. So the mapping lives here, once, in + * both directions. + * + * Everything is built field by field from the parsed body. Nothing is spread + * from a request (`CLAUDE.md`, no mass assignment), and the two multi-select + * lists go through the vocabulary module's encoder rather than a local + * `JSON.stringify`. + */ +import type { Prisma, PrismaClient } from "@/generated/prisma/client"; +import { encryptNote, readNote } from "@/lib/crypto/note-cipher"; +import { decodeKeyList, encodeKeyList } from "@/lib/mood/context-vocabulary"; + +/** Either the plain client or a transaction client, as the tag-link helper takes. */ +type MoodContextDb = PrismaClient | Prisma.TransactionClient; + +/** + * The context as a client sends it. Every field optional; a `null` is an + * explicit "take this back", an absent key says nothing at all. + */ +export interface MoodContextInput { + workStatus?: string | null; + workMinutes?: number | null; + overtimeMinutes?: number | null; + workLoad?: number | null; + workSatisfaction?: number | null; + contactCircles?: string[] | null; + contactForm?: string | null; + contactExtent?: string | null; + contactQuality?: number | null; + contactSupport?: number | null; + leisureCategories?: string[] | null; + leisureMinutes?: number | null; + leisureJoy?: number | null; + leisureRecovery?: number | null; + eventType?: string | null; + eventValence?: number | null; + eventAt?: Date | null; + note?: string | null; +} + +/** The context as it sits on a row, before the note is decrypted. */ +export interface MoodContextRow { + workStatus: string | null; + workMinutes: number | null; + overtimeMinutes: number | null; + workLoad: number | null; + workSatisfaction: number | null; + contactCircles: string | null; + contactForm: string | null; + contactExtent: string | null; + contactQuality: number | null; + contactSupport: number | null; + leisureCategories: string | null; + leisureMinutes: number | null; + leisureJoy: number | null; + leisureRecovery: number | null; + eventType: string | null; + eventValence: number | null; + eventAt: Date | null; + notesEncrypted: Uint8Array | null; +} + +/** The context as a client reads it back. Lists decoded, note decrypted. */ +export interface MoodContextWire { + workStatus: string | null; + workMinutes: number | null; + overtimeMinutes: number | null; + workLoad: number | null; + workSatisfaction: number | null; + contactCircles: string[]; + contactForm: string | null; + contactExtent: string | null; + contactQuality: number | null; + contactSupport: number | null; + leisureCategories: string[]; + leisureMinutes: number | null; + leisureJoy: number | null; + leisureRecovery: number | null; + eventType: string | null; + eventValence: number | null; + eventAt: string | null; + note: string | null; +} + +/** + * Does this sub-object actually say anything? + * + * An entry whose context sections were all left closed must store **no row**. + * A row of nothing but NULLs would record "asked and answered nothing" where + * the truth is "never asked", and every count over the table would then be a + * count of empty forms. An empty list counts as nothing, because a client that + * renders a multi-select and sends back `[]` has said the same thing as one + * that sent nothing at all. + */ +export function isEmptyContextInput(input: MoodContextInput): boolean { + const values: Array = [ + input.workStatus, + input.workMinutes, + input.overtimeMinutes, + input.workLoad, + input.workSatisfaction, + input.contactForm, + input.contactExtent, + input.contactQuality, + input.contactSupport, + input.leisureMinutes, + input.leisureJoy, + input.leisureRecovery, + input.eventType, + input.eventValence, + input.eventAt, + ]; + if (values.some((v) => v !== undefined && v !== null)) return false; + if (input.contactCircles && input.contactCircles.length > 0) return false; + if (input.leisureCategories && input.leisureCategories.length > 0) + return false; + if (input.note !== undefined && input.note !== null && input.note !== "") { + return false; + } + return true; +} + +/** + * The write payload for a context row, whole. + * + * Whole rather than per-field, and that is the contract: a context write + * REPLACES the stored context. The capture surface always sends the sections + * it is showing, so a field the payload omits is a field the person cleared, + * and a preserve-when-absent rule here would make an emptied section + * impossible to save. The entry's own columns work the other way round + * (`levelAForWrite`) because those writers are upserts a phone re-posts + * blind; this object is only ever built from a payload that carried a + * `context` key at all. + */ +export function contextRowData(input: MoodContextInput) { + return { + workStatus: input.workStatus ?? null, + workMinutes: input.workMinutes ?? null, + overtimeMinutes: input.overtimeMinutes ?? null, + workLoad: input.workLoad ?? null, + workSatisfaction: input.workSatisfaction ?? null, + contactCircles: encodeKeyList(input.contactCircles ?? null), + contactForm: input.contactForm ?? null, + contactExtent: input.contactExtent ?? null, + contactQuality: input.contactQuality ?? null, + contactSupport: input.contactSupport ?? null, + leisureCategories: encodeKeyList(input.leisureCategories ?? null), + leisureMinutes: input.leisureMinutes ?? null, + leisureJoy: input.leisureJoy ?? null, + leisureRecovery: input.leisureRecovery ?? null, + eventType: input.eventType ?? null, + eventValence: input.eventValence ?? null, + eventAt: input.eventAt ?? null, + notesEncrypted: encryptNote(input.note ?? null), + }; +} + +/** A stored context row as the client reads it. */ +export function contextForWire(row: MoodContextRow): MoodContextWire { + return { + workStatus: row.workStatus, + workMinutes: row.workMinutes, + overtimeMinutes: row.overtimeMinutes, + workLoad: row.workLoad, + workSatisfaction: row.workSatisfaction, + contactCircles: decodeKeyList(row.contactCircles), + contactForm: row.contactForm, + contactExtent: row.contactExtent, + contactQuality: row.contactQuality, + contactSupport: row.contactSupport, + leisureCategories: decodeKeyList(row.leisureCategories), + leisureMinutes: row.leisureMinutes, + leisureJoy: row.leisureJoy, + leisureRecovery: row.leisureRecovery, + eventType: row.eventType, + eventValence: row.eventValence, + eventAt: row.eventAt ? row.eventAt.toISOString() : null, + // Fail-closed like every other note read: a present ciphertext under an + // unknown key id throws rather than answering an empty note. + note: readNote(row.notesEncrypted, null), + }; +} + +/** + * Write, replace or remove the context of one entry, inside the caller's + * transaction. + * + * `undefined` means the request said nothing about the context and the stored + * one is left alone. A context that says nothing removes the row, which is the + * same rule the create path applies: no row rather than a row of NULLs. + */ +export async function persistMoodContext( + tx: MoodContextDb, + moodEntryId: string, + userId: string, + input: MoodContextInput | null | undefined, +): Promise<"written" | "removed" | "untouched"> { + if (input === undefined) return "untouched"; + if (input === null || isEmptyContextInput(input)) { + await tx.moodContext.deleteMany({ where: { moodEntryId } }); + return "removed"; + } + const data = contextRowData(input); + await tx.moodContext.upsert({ + where: { moodEntryId }, + create: { moodEntryId, userId, ...data }, + update: data, + }); + return "written"; +} diff --git a/src/lib/validations/mood.ts b/src/lib/validations/mood.ts index 37ef68a6f..e31fd37c1 100644 --- a/src/lib/validations/mood.ts +++ b/src/lib/validations/mood.ts @@ -9,6 +9,20 @@ import { z } from "zod/v4"; import { validateEntryInstant } from "@/lib/validations/entry-instant"; import { assertStableExternalId } from "@/lib/validations/external-id"; +import { + CONTACT_CIRCLE_KEYS, + CONTACT_EXTENT_KEYS, + CONTACT_FORM_KEYS, + CONTEXT_MINUTES_MAX, + CONTEXT_MINUTES_MIN, + CONTEXT_RATING_MAX, + CONTEXT_RATING_MIN, + CONTEXT_VALENCE_MAX, + CONTEXT_VALENCE_MIN, + EVENT_TYPE_KEYS, + LEISURE_CATEGORY_KEYS, + WORK_STATUS_KEYS, +} from "@/lib/mood/context-vocabulary"; // --- CRUD schemas for mood entries --- @@ -114,6 +128,77 @@ const ratedFactors = z.array(ratedFactor).max(30); // (`src/lib/mood/dimensions.ts`) carries the anchors the numbers mean. const levelADimension = z.number().int().min(0).max(10); +// v1.38 — the day's context. Every enum below derives from +// `context-vocabulary.ts`, which is what makes "a value the vocabulary does +// not name cannot be stored" structural rather than a convention: adding a key +// to the stored set means adding it to the list the labels and the analytics +// read too, in one edit. +const contextRating = z + .number() + .int() + .min(CONTEXT_RATING_MIN) + .max(CONTEXT_RATING_MAX); +const contextMinutes = z + .number() + .int() + .min(CONTEXT_MINUTES_MIN) + .max(CONTEXT_MINUTES_MAX); + +/** + * Optional on create and on patch, and every field inside it optional too. + * + * The bounds live here rather than as database CHECKs, matching + * `MoodEntryTagLink.rating`: an out-of-range value is a client error and + * answers 422 through `returnAllZodIssues`, and a later change to a scale + * cannot strand rows written under the old one. + * + * Sleep, activity, vitals and body symptoms are deliberately absent. They are + * owned by other modules and are read from them + * (`src/lib/mood/linked-context.ts`); accepting them here would mint a second + * home for the same fact. + */ +export const moodContextSchema = z.object({ + workStatus: z.enum(WORK_STATUS_KEYS).nullable().optional(), + workMinutes: contextMinutes.nullable().optional(), + overtimeMinutes: contextMinutes.nullable().optional(), + workLoad: contextRating.nullable().optional(), + workSatisfaction: contextRating.nullable().optional(), + contactCircles: z + .array(z.enum(CONTACT_CIRCLE_KEYS)) + .max(CONTACT_CIRCLE_KEYS.length) + .nullable() + .optional(), + contactForm: z.enum(CONTACT_FORM_KEYS).nullable().optional(), + contactExtent: z.enum(CONTACT_EXTENT_KEYS).nullable().optional(), + contactQuality: contextRating.nullable().optional(), + contactSupport: contextRating.nullable().optional(), + leisureCategories: z + .array(z.enum(LEISURE_CATEGORY_KEYS)) + .max(LEISURE_CATEGORY_KEYS.length) + .nullable() + .optional(), + leisureMinutes: contextMinutes.nullable().optional(), + leisureJoy: contextRating.nullable().optional(), + leisureRecovery: contextRating.nullable().optional(), + eventType: z.enum(EVENT_TYPE_KEYS).nullable().optional(), + eventValence: z + .number() + .int() + .min(CONTEXT_VALENCE_MIN) + .max(CONTEXT_VALENCE_MAX) + .nullable() + .optional(), + // The same plausibility bound the entry's own instant carries: no future + // instants beyond the clock-skew tolerance, nothing before 1900. + eventAt: validateEntryInstant( + z.iso.datetime({ offset: true }).transform((s) => new Date(s)), + ) + .nullable() + .optional(), + // Capped like the entry note beside it. One note for the whole context. + note: z.string().max(500).nullable().optional(), +}); + export const createMoodEntrySchema = z .object({ mood: moodLevelEnum, @@ -145,6 +230,12 @@ export const createMoodEntrySchema = z // `tags: ["note:"]` workaround. Capped at 500 chars so the // Coach evidence shelf renders cleanly without truncating chips. note: z.string().max(500).optional(), + // v1.38 — the day's context. Absent leaves an existing context alone on + // the `update:` arm of an `externalId` upsert, for the same reason A2 to + // A5 are left alone: a phone re-posting an entry from a build that has no + // context surface must not blank one somebody filled in on the web. A + // context that says nothing removes the row. + context: moodContextSchema.nullable().optional(), // v1.17 W1b — plausibility bound (shared `validateEntryInstant`): no // future instants beyond a 5-min clock-skew tolerance, no instant before // 1900. Mirrors the measurement + medication-intake bound. @@ -186,6 +277,10 @@ export const updateMoodEntrySchema = z.object({ a4: levelADimension.nullable().optional(), a5: levelADimension.nullable().optional(), note: z.string().max(500).nullable().optional(), + // v1.38 — full replacement of the context when present, matching the way + // `tagKeys` and `ratedFactors` work on this path: omit to leave it alone, + // send it to replace it, send `null` (or an empty one) to remove the row. + context: moodContextSchema.nullable().optional(), // v1.17 W1b — same plausibility bound on the edit path. moodLoggedAt: validateEntryInstant( z.iso.datetime({ offset: true }).transform((s) => new Date(s)), @@ -219,3 +314,4 @@ export const listMoodEntriesSchema = z.object({ export type CreateMoodEntryInput = z.infer; export type UpdateMoodEntryInput = z.infer; export type ListMoodEntriesInput = z.infer; +export type MoodContextRequest = z.infer; diff --git a/tests/integration/mood-context.test.ts b/tests/integration/mood-context.test.ts new file mode 100644 index 000000000..3b97bcede --- /dev/null +++ b/tests/integration/mood-context.test.ts @@ -0,0 +1,357 @@ +/** + * v1.38 — the day context on a mood entry, against real Postgres and the real + * handlers. + * + * The claims that matter here are all about ABSENCE, which is the part a unit + * test with a mocked client cannot make honestly: + * + * - an entry whose sections were never opened stores NO context row; + * - one filled field stores one row with that field set and every other + * column NULL, not a row of defaulted middles; + * - re-posting the same `externalId` updates the context in place rather + * than accumulating rows or leaving a stale one behind; + * - the note goes in through `encryptNote` and comes back through + * `readNote`, with no plaintext column holding it; + * - a value outside the vocabulary is a 422 carrying every issue, not a + * silently dropped field. + */ +import { NextRequest } from "next/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { readNote } from "@/lib/crypto/note-cipher"; +import { cookieJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +const TEST_USER_ID = "user-mood-context"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => { + cookieJar.set(name, value); + }, + delete: (name: string) => { + cookieJar.delete(name); + }, + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + await getPrismaClient().user.create({ + data: { + id: TEST_USER_ID, + username: "mood-context", + email: "mood-context@example.test", + timezone: "Europe/Berlin", + }, + }); + const session = await getPrismaClient().session.create({ + data: { + userId: TEST_USER_ID, + expiresAt: new Date(Date.now() + 60 * 60 * 1000), + }, + }); + cookieJar.set("healthlog_session", session.id); +}); + +function postRequest(path: string, body: unknown): NextRequest { + return new NextRequest(`http://localhost${path}`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +function putRequest(path: string, body: unknown): NextRequest { + return new NextRequest(`http://localhost${path}`, { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +describe("mood context write path (real Postgres)", () => { + it("stores no context row for an entry that carries none", async () => { + const { POST } = await import("@/app/api/mood-entries/route"); + const res = await POST( + postRequest("/api/mood-entries", { + mood: "GUT", + moodLoggedAt: "2026-05-16T08:00:00.000Z", + }), + ); + expect(res.status).toBe(201); + expect(await getPrismaClient().moodContext.count()).toBe(0); + }); + + it("stores no context row for an empty context object", async () => { + // A client that renders the sections and sends them back untouched has + // said the same thing as one that sent nothing at all. A row of NULLs + // would record "asked and answered nothing" where the truth is + // "never asked". + const { POST } = await import("@/app/api/mood-entries/route"); + const res = await POST( + postRequest("/api/mood-entries", { + mood: "OKAY", + moodLoggedAt: "2026-05-16T09:00:00.000Z", + context: { contactCircles: [], leisureCategories: [], note: "" }, + }), + ); + expect(res.status).toBe(201); + expect(await getPrismaClient().moodContext.count()).toBe(0); + }); + + it("stores exactly the one field that was filled, everything else NULL", async () => { + const { POST } = await import("@/app/api/mood-entries/route"); + const res = await POST( + postRequest("/api/mood-entries", { + mood: "GUT", + moodLoggedAt: "2026-05-16T10:00:00.000Z", + context: { workStatus: "regular" }, + }), + ); + expect(res.status).toBe(201); + + const rows = await getPrismaClient().moodContext.findMany(); + expect(rows).toHaveLength(1); + const row = rows[0]; + expect(row.userId).toBe(TEST_USER_ID); + expect(row.workStatus).toBe("regular"); + expect(row.workMinutes).toBeNull(); + expect(row.workLoad).toBeNull(); + expect(row.contactCircles).toBeNull(); + expect(row.contactQuality).toBeNull(); + expect(row.leisureCategories).toBeNull(); + expect(row.eventType).toBeNull(); + expect(row.eventValence).toBeNull(); + expect(row.eventAt).toBeNull(); + expect(row.notesEncrypted).toBeNull(); + }); + + it("round-trips every section, with a zero that stays a zero", async () => { + const { POST } = await import("@/app/api/mood-entries/route"); + const res = await POST( + postRequest("/api/mood-entries", { + mood: "SCHLECHT", + moodLoggedAt: "2026-05-16T11:00:00.000Z", + context: { + workStatus: "overtime", + workMinutes: 540, + overtimeMinutes: 90, + workLoad: 9, + // Zero is a real answer. A carrier that treated it as absence would + // round-trip it to NULL and pass every truthy check on the way. + workSatisfaction: 0, + contactCircles: ["partner", "colleagues"], + contactForm: "inPerson", + contactExtent: "brief", + contactQuality: 6, + contactSupport: 4, + leisureCategories: ["reading"], + leisureMinutes: 45, + leisureJoy: 7, + leisureRecovery: 5, + eventType: "conflict", + eventValence: -3, + eventAt: "2026-05-16T16:30:00.000Z", + note: "Long meeting ran over.", + }, + }), + ); + expect(res.status).toBe(201); + + const body = (await res.json()) as { + data: { context: Record }; + }; + expect(body.data.context).toMatchObject({ + workStatus: "overtime", + workSatisfaction: 0, + contactCircles: ["partner", "colleagues"], + leisureCategories: ["reading"], + eventValence: -3, + note: "Long meeting ran over.", + }); + + const row = await getPrismaClient().moodContext.findFirstOrThrow(); + expect(row.workMinutes).toBe(540); + expect(row.overtimeMinutes).toBe(90); + expect(row.workLoad).toBe(9); + expect(row.workSatisfaction).toBe(0); + expect(row.contactCircles).toBe('["partner","colleagues"]'); + expect(row.leisureCategories).toBe('["reading"]'); + expect(row.eventAt?.toISOString()).toBe("2026-05-16T16:30:00.000Z"); + // The note is bytes at rest and decrypts back to the same text; nothing + // on this table holds it in the clear. + expect(row.notesEncrypted).not.toBeNull(); + expect(readNote(row.notesEncrypted, null)).toBe("Long meeting ran over."); + expect(JSON.stringify(row)).not.toContain("Long meeting ran over."); + }); + + it("re-posting the same externalId updates the context in place", async () => { + const { POST } = await import("@/app/api/mood-entries/route"); + const first = await POST( + postRequest("/api/mood-entries", { + mood: "GUT", + moodLoggedAt: "2026-05-17T08:00:00.000Z", + externalId: "ios-row-4711", + context: { workStatus: "regular", workLoad: 5 }, + }), + ); + expect(first.status).toBe(201); + + const second = await POST( + postRequest("/api/mood-entries", { + mood: "GUT", + moodLoggedAt: "2026-05-17T08:00:00.000Z", + externalId: "ios-row-4711", + context: { workStatus: "overtime" }, + }), + ); + expect(second.status).toBe(201); + + const rows = await getPrismaClient().moodContext.findMany(); + expect(rows).toHaveLength(1); + // The write replaces the context whole: the capture surface always sends + // the sections it is showing, so a field the payload omits is one the + // person cleared. + expect(rows[0].workStatus).toBe("overtime"); + expect(rows[0].workLoad).toBeNull(); + }); + + it("leaves a stored context alone when a re-post carries none", async () => { + // The phone. Its build has no context surface, so it re-posts the entry + // without one — and that must not blank what somebody filled in on the + // web. + const { POST } = await import("@/app/api/mood-entries/route"); + await POST( + postRequest("/api/mood-entries", { + mood: "GUT", + moodLoggedAt: "2026-05-18T08:00:00.000Z", + externalId: "ios-row-4712", + context: { leisureCategories: ["music"], leisureJoy: 8 }, + }), + ); + await POST( + postRequest("/api/mood-entries", { + mood: "SUPER_GUT", + moodLoggedAt: "2026-05-18T08:00:00.000Z", + externalId: "ios-row-4712", + }), + ); + + const rows = await getPrismaClient().moodContext.findMany(); + expect(rows).toHaveLength(1); + expect(rows[0].leisureCategories).toBe('["music"]'); + expect(rows[0].leisureJoy).toBe(8); + }); + + it("PUT replaces the context and an explicit null removes the row", async () => { + const { POST } = await import("@/app/api/mood-entries/route"); + const created = await POST( + postRequest("/api/mood-entries", { + mood: "OKAY", + moodLoggedAt: "2026-05-19T08:00:00.000Z", + context: { eventType: "goodNews", eventValence: 4 }, + }), + ); + const { data } = (await created.json()) as { data: { id: string } }; + + const { PUT } = await import("@/app/api/mood-entries/[id]/route"); + const replaced = await PUT( + putRequest(`/api/mood-entries/${data.id}`, { + context: { eventType: "appointment", note: "Dentist." }, + }), + { params: Promise.resolve({ id: data.id }) }, + ); + expect(replaced.status).toBe(200); + const afterReplace = await getPrismaClient().moodContext.findFirstOrThrow(); + expect(afterReplace.eventType).toBe("appointment"); + expect(afterReplace.eventValence).toBeNull(); + expect(readNote(afterReplace.notesEncrypted, null)).toBe("Dentist."); + + const cleared = await PUT( + putRequest(`/api/mood-entries/${data.id}`, { context: null }), + { params: Promise.resolve({ id: data.id }) }, + ); + expect(cleared.status).toBe(200); + expect(await getPrismaClient().moodContext.count()).toBe(0); + }); + + it("the GET surfaces the stored context on the entry", async () => { + const { POST } = await import("@/app/api/mood-entries/route"); + const created = await POST( + postRequest("/api/mood-entries", { + mood: "GUT", + moodLoggedAt: "2026-05-20T08:00:00.000Z", + context: { contactCircles: ["family"], contactQuality: 9 }, + }), + ); + const { data } = (await created.json()) as { data: { id: string } }; + + const { GET } = await import("@/app/api/mood-entries/[id]/route"); + const res = await GET( + new NextRequest(`http://localhost/api/mood-entries/${data.id}`), + { params: Promise.resolve({ id: data.id }) }, + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { context: { contactCircles: string[]; contactQuality: number } }; + }; + expect(body.data.context.contactCircles).toEqual(["family"]); + expect(body.data.context.contactQuality).toBe(9); + }); + + it("refuses a value the vocabulary does not name, reporting every issue", async () => { + const { POST } = await import("@/app/api/mood-entries/route"); + const res = await POST( + postRequest("/api/mood-entries", { + mood: "GUT", + moodLoggedAt: "2026-05-21T08:00:00.000Z", + context: { + workStatus: "sabbatical", + workLoad: 42, + contactCircles: ["pets"], + }, + }), + ); + expect(res.status).toBe(422); + const body = (await res.json()) as { error: string; meta?: unknown }; + expect(body.error).toBeTruthy(); + expect(await getPrismaClient().moodContext.count()).toBe(0); + // The entry rolls back with it: a refused context is a refused request, + // not a half-saved one. + expect(await getPrismaClient().moodEntry.count()).toBe(0); + }); + + it("deleting the entry takes its context with it", async () => { + const { POST } = await import("@/app/api/mood-entries/route"); + const created = await POST( + postRequest("/api/mood-entries", { + mood: "GUT", + moodLoggedAt: "2026-05-22T08:00:00.000Z", + context: { workStatus: "off" }, + }), + ); + const { data } = (await created.json()) as { data: { id: string } }; + expect(await getPrismaClient().moodContext.count()).toBe(1); + + // A user-facing DELETE is a tombstone, so the context stays with the row + // it describes; a hard delete cascades. + await getPrismaClient().moodEntry.delete({ where: { id: data.id } }); + expect(await getPrismaClient().moodContext.count()).toBe(0); + }); +});