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>;