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
This commit is contained in:
Marc-André Bombeck committed 2026-08-08 07:07:03 +02:00
1 parent dbfc0d8145
commit a25ec211cd
7 files changed
+880 -111

No files matched your search

@@ -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"];
+36 -5
View File
@@ -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,
});
},
);
@@ -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", () => ({
+146 -106
View File
@@ -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,
);
+218
View File
@@ -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<unknown> = [
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";
}
+96
View File
@@ -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:<text>"]` 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<typeof createMoodEntrySchema>;
export type UpdateMoodEntryInput = z.infer<typeof updateMoodEntrySchema>;
export type ListMoodEntriesInput = z.infer<typeof listMoodEntriesSchema>;
export type MoodContextRequest = z.infer<typeof moodContextSchema>;
+357
View File
@@ -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<string, unknown> };
};
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);
});
});