mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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:
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"];
|
||||
|
||||
@@ -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
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user