From a1c78eceef0afb9aeefb764b3aa20c20811f6cd7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 8 Aug 2026 00:49:15 +0200 Subject: [PATCH] feat(mood): derive pleasantness on every write path - one helper resolves the level-A values for a write; the five writers call it instead of carrying their own copy of the derivation - the create schema takes a1 to a5, and a value the user set wins over the derived one; the batch request shape is untouched - both arms of both upserts restate the values, so a re-post cannot leave a stale reading beside a mood that has since changed - the JSON import derives from the row's own label rather than the file's score, which is the one place the two can disagree - correcting an entry's label re-derives pleasantness the way it re-derives the score; an explicit null takes an answer back - a round trip through all five writers against real Postgres asserts the rows agree and that nothing invents the other four values --- src/app/api/import/route.ts | 11 + src/app/api/mood-entries/[id]/route.ts | 22 +- src/app/api/mood-entries/bulk/route.ts | 8 + src/app/api/mood-entries/route.ts | 37 ++ src/lib/mcp/writes.ts | 5 + src/lib/mood/create-from-telegram.ts | 5 + src/lib/mood/level-a.ts | 100 ++++++ src/lib/validations/mood.ts | 25 ++ tests/integration/mood-writers-agree.test.ts | 343 +++++++++++++++++++ 9 files changed, 555 insertions(+), 1 deletion(-) create mode 100644 src/lib/mood/level-a.ts create mode 100644 tests/integration/mood-writers-agree.test.ts diff --git a/src/app/api/import/route.ts b/src/app/api/import/route.ts index f1c32149f..e81f51a54 100644 --- a/src/app/api/import/route.ts +++ b/src/app/api/import/route.ts @@ -39,6 +39,7 @@ import { emitInsertedMeasurementArrivals } from "@/lib/arrivals/measurement-emit import { maybeEnqueueMorningRefresh } from "@/lib/daily/morning-refresh-trigger"; import { isP2002, isP2025 } from "@/lib/prisma-errors"; import { DEFAULT_TIMEZONE, moodDateKey } from "@/lib/mood/date-key"; +import { deriveA1 } from "@/lib/mood/level-a"; import { zonedWallClockToUtc } from "@/lib/tz/wall-clock"; // Derived from canonical enum so round-trip export → import covers every @@ -364,6 +365,12 @@ export const POST = apiHandler(async (request: NextRequest) => { date: e.date, mood: e.mood, score: e.score, + // v1.37 — pleasantness derived from the row's own label, not + // from the file's `score`. This is the one writer whose score + // comes out of the file rather than out of the label, so the + // two can disagree; the column that records a self-assessment + // has to agree with the label the user actually picked. + moodA1: deriveA1(e.mood), tags: e.tags || null, moodLoggedAt: loggedAt, }, @@ -372,6 +379,7 @@ export const POST = apiHandler(async (request: NextRequest) => { date: e.date, mood: e.mood, score: e.score, + moodA1: deriveA1(e.mood), tags: e.tags || null, source: "IMPORT", externalId: e.externalId, @@ -385,6 +393,9 @@ export const POST = apiHandler(async (request: NextRequest) => { date: e.date, mood: e.mood, score: e.score, + // See the upsert arm above: derived from the label, because + // an imported file's `score` is its own field. + moodA1: deriveA1(e.mood), tags: e.tags || null, source: "IMPORT", moodLoggedAt: loggedAt, diff --git a/src/app/api/mood-entries/[id]/route.ts b/src/app/api/mood-entries/[id]/route.ts index b6f1c4412..f6b01ba4d 100644 --- a/src/app/api/mood-entries/[id]/route.ts +++ b/src/app/api/mood-entries/[id]/route.ts @@ -15,6 +15,7 @@ import { Prisma } from "@/generated/prisma/client"; import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { moodDateKey, DEFAULT_TIMEZONE } from "@/lib/mood/date-key"; +import { deriveA1, levelAForWire } from "@/lib/mood/level-a"; import { encryptNote, shapeMoodNote } from "@/lib/crypto/note-cipher"; import { invalidateUserMood } from "@/lib/cache/invalidate"; import { recomputeMoodBucketsForEntry } from "@/lib/rollups/mood-rollups"; @@ -57,7 +58,12 @@ export const GET = apiHandler( meta: { moodEntryId: id }, }); - return apiSuccess({ ...shapeMoodNote(entry), tags: parseTags(entry.tags) }); + return apiSuccess({ + ...shapeMoodNote(entry), + // v1.37 — level-A values under the keys the write path takes. + ...levelAForWire(entry), + tags: parseTags(entry.tags), + }); }, ); @@ -120,7 +126,19 @@ export const PUT = apiHandler( if (data.mood !== undefined) { updateData.mood = data.mood; updateData.score = getScoreForMood(data.mood); + // v1.37 — pleasantness follows the label the same way `score` does, so + // an entry corrected from "okay" to "bad" cannot keep yesterday's A1. + // An explicit `a1` in the same request overrides it below. + updateData.moodA1 = deriveA1(data.mood); } + // v1.37 — level-A values are per-field on this path: omitted keeps the + // stored value, an explicit number replaces it, an explicit null clears + // it. Built one at a time from the parsed body, never spread. + if (data.a1 !== undefined) updateData.moodA1 = data.a1; + if (data.a2 !== undefined) updateData.stressA2 = data.a2; + if (data.a3 !== undefined) updateData.energyA3 = data.a3; + if (data.a4 !== undefined) updateData.connectionA4 = data.a4; + if (data.a5 !== undefined) updateData.stabilityA5 = data.a5; if (data.moodLoggedAt !== undefined) { // v1.4.25 W7b — re-anchor the row's `date` to the user's current // displayTimezone. Also refresh the `tz` column so the row's @@ -291,6 +309,8 @@ export const PUT = apiHandler( return apiSuccess({ ...entry, + // v1.37 — level-A values under the keys the write path takes. + ...levelAForWire(entry), tags: parseTags(entry.tags), // v1.8.5 — surface the persisted structured-tag keys so a client // hydrating from the update response renders the tag set without a diff --git a/src/app/api/mood-entries/bulk/route.ts b/src/app/api/mood-entries/bulk/route.ts index a8f3bd3d2..938be2a60 100644 --- a/src/app/api/mood-entries/bulk/route.ts +++ b/src/app/api/mood-entries/bulk/route.ts @@ -51,6 +51,7 @@ import { type UnstableExternalIdShape, } from "@/lib/validations/external-id"; import { moodDateKey, DEFAULT_TIMEZONE } from "@/lib/mood/date-key"; +import { deriveA1 } from "@/lib/mood/level-a"; import { encryptNote } from "@/lib/crypto/note-cipher"; import { invalidateUserMood } from "@/lib/cache/invalidate"; import { recomputeMoodBucketsForEntry } from "@/lib/rollups/mood-rollups"; @@ -276,6 +277,11 @@ async function postBulk(request: NextRequest): Promise { const date = moodDateKey(entry.moodLoggedAt, tz); const score = getScoreForMood(entry.mood); + // v1.37 — pleasantness derived from the same label the score comes from. + // The batch request shape is unchanged: this path carries no level-A + // input, so A2 to A5 are left alone rather than written as nulls, and a + // row that already carries hand-set values keeps them across a re-import. + const moodA1 = deriveA1(entry.mood); try { // v1.12.1 — when the entry carries a source-stable `externalId`, @@ -347,6 +353,7 @@ async function postBulk(request: NextRequest): Promise { tz, mood: entry.mood, score, + moodA1, tags: entry.tags ? JSON.stringify(entry.tags) : null, note: null, noteEncrypted: encryptNote(entry.note ?? null), @@ -363,6 +370,7 @@ async function postBulk(request: NextRequest): Promise { // the same row. mood: entry.mood, score, + moodA1, tags: entry.tags ? JSON.stringify(entry.tags) : null, note: null, noteEncrypted: encryptNote(entry.note ?? null), diff --git a/src/app/api/mood-entries/route.ts b/src/app/api/mood-entries/route.ts index e92baa7d1..b03b3bb04 100644 --- a/src/app/api/mood-entries/route.ts +++ b/src/app/api/mood-entries/route.ts @@ -24,6 +24,7 @@ import { annotate } from "@/lib/logging/context"; import { withIdempotency } from "@/lib/idempotency"; import { encryptNote, shapeMoodNote } from "@/lib/crypto/note-cipher"; import { moodDateKey, DEFAULT_TIMEZONE } from "@/lib/mood/date-key"; +import { levelAForWire, levelAForWrite } from "@/lib/mood/level-a"; import { invalidateUserMood } from "@/lib/cache/invalidate"; import { recomputeMoodBucketsForEntry } from "@/lib/rollups/mood-rollups"; import { @@ -126,6 +127,10 @@ export const GET = apiHandler(async (request: NextRequest) => { const entriesWithParsedTags = entries.map(({ tagLinks, ...e }) => ({ // v1.23 — decrypt `noteEncrypted` onto `note`, strip the ciphertext. ...shapeMoodNote(e), + // 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 + // names to wire names by hand. + ...levelAForWire(e), tags: parseTags(e.tags), // v1.8.5 — flat list of binary structured-tag keys attached to the // entry (rated factors are surfaced separately below). @@ -206,6 +211,17 @@ async function postMoodEntry(request: NextRequest) { source, externalId, } = parsed.data; + // v1.37 — the five level-A values, built field by field from the parsed + // body (never spread) and resolved once for every branch below. A1 falls + // back to the derivation from `mood`; the other four are whatever the user + // set, or nothing. + const levelA = levelAForWrite(mood, { + a1: parsed.data.a1, + a2: parsed.data.a2, + a3: parsed.data.a3, + a4: parsed.data.a4, + a5: parsed.data.a5, + }); // v1.4.25 W7b (Decision A) — anchor the `date` string to the user's // current displayTimezone and store the resolved zone on the row. // Legacy rows with `tz IS NULL` continue to read as Europe/Berlin @@ -265,6 +281,11 @@ async function postMoodEntry(request: NextRequest) { tz, mood, score, + moodA1: levelA.moodA1, + stressA2: levelA.stressA2, + energyA3: levelA.energyA3, + connectionA4: levelA.connectionA4, + stabilityA5: levelA.stabilityA5, tags: tags ? JSON.stringify(tags) : null, note: null, noteEncrypted: encryptNote(note ?? null), @@ -272,11 +293,20 @@ async function postMoodEntry(request: NextRequest) { externalId, moodLoggedAt, }, + // A re-post replaces the row, so the level-A values are restated + // here too. Omitting them would leave a stress reading from an + // earlier post sitting beside a mood the user has since changed + // — the same staleness the `score` restatement already avoids. update: { date, tz, mood, score, + moodA1: levelA.moodA1, + stressA2: levelA.stressA2, + energyA3: levelA.energyA3, + connectionA4: levelA.connectionA4, + stabilityA5: levelA.stabilityA5, tags: tags ? JSON.stringify(tags) : null, note: null, noteEncrypted: encryptNote(note ?? null), @@ -290,6 +320,11 @@ async function postMoodEntry(request: NextRequest) { tz, mood, score, + moodA1: levelA.moodA1, + stressA2: levelA.stressA2, + energyA3: levelA.energyA3, + connectionA4: levelA.connectionA4, + stabilityA5: levelA.stabilityA5, tags: tags ? JSON.stringify(tags) : null, note: null, noteEncrypted: encryptNote(note ?? null), @@ -375,6 +410,8 @@ async function postMoodEntry(request: NextRequest) { { // v1.23 — decrypt `noteEncrypted` onto `note`, strip the ciphertext. ...shapeMoodNote(entry), + // v1.37 — level-A values under their wire keys (see the list read). + ...levelAForWire(entry), tags: parseTags(entry.tags), // v1.8.5 — surface the persisted structured-tag keys so a client // hydrating from the create response renders the tag set without a diff --git a/src/lib/mcp/writes.ts b/src/lib/mcp/writes.ts index 83183d2db..a25903f19 100644 --- a/src/lib/mcp/writes.ts +++ b/src/lib/mcp/writes.ts @@ -41,6 +41,7 @@ import { recomputeMoodBucketsForEntry } from "@/lib/rollups/mood-rollups"; import { MOOD_ENUM_BY_SCORE } from "@/lib/mood/labels"; import { getScoreForMood } from "@/lib/validations/mood"; import { moodDateKey, DEFAULT_TIMEZONE } from "@/lib/mood/date-key"; +import { deriveA1 } from "@/lib/mood/level-a"; import { classifyExternalId, unstableExternalIdMessage, @@ -648,6 +649,10 @@ export async function logMcpMood(input: { tz, mood, score, + // v1.37 — pleasantness derived from the same label the score comes + // from. The tool still takes a 1-5 score; that is its input and it is + // unchanged. + moodA1: deriveA1(mood), note: null, noteEncrypted: encryptNote(note), source: MCP_SOURCE, diff --git a/src/lib/mood/create-from-telegram.ts b/src/lib/mood/create-from-telegram.ts index 1e1c9371d..3b5c4d3eb 100644 --- a/src/lib/mood/create-from-telegram.ts +++ b/src/lib/mood/create-from-telegram.ts @@ -17,6 +17,7 @@ import type { Prisma, PrismaClient } from "@/generated/prisma/client"; import { prisma as defaultPrisma } from "@/lib/db"; import { MOOD_ENUM_BY_SCORE } from "@/lib/mood/labels"; import { getScoreForMood } from "@/lib/validations/mood"; +import { deriveA1 } from "@/lib/mood/level-a"; import { moodDateKey, DEFAULT_TIMEZONE } from "@/lib/mood/date-key"; import { encryptNote } from "@/lib/crypto/note-cipher"; import { invalidateUserMood } from "@/lib/cache/invalidate"; @@ -79,6 +80,10 @@ export async function logTelegramMood(input: { tz, mood, score, + // v1.37 — pleasantness derived from the same label the score comes + // from, so a mood tapped in the chat carries the value a mood typed + // into the web form would. Nothing here can answer the other four. + moodA1: deriveA1(mood), // v1.23 — encrypt the note at rest; legacy plaintext column nulled. note: null, noteEncrypted: encryptNote(input.note ?? null), diff --git a/src/lib/mood/level-a.ts b/src/lib/mood/level-a.ts new file mode 100644 index 000000000..15505eb75 --- /dev/null +++ b/src/lib/mood/level-a.ts @@ -0,0 +1,100 @@ +/** + * The one place a mood write resolves its five level-A values. + * + * Five writers reach `MoodEntry`, and every one of them sends exactly what it + * sent before this existed: a five-point label and nothing else. The server + * derives pleasantness (A1) from that label so a quick check-in is a full + * entry rather than a degraded one, and so a Telegram row, an imported row and + * a row typed into the web form all carry the same value for the same day. + * + * The other four are never derived. There is nothing in a five-point label to + * derive stress, energy, connectedness or stability from, and a fabricated + * value would read as an answer nobody gave — it would poison the trend and + * any later model trained on these columns. They stay NULL until somebody + * moves the slider. + */ +import { getA1ForMood } from "@/lib/validations/mood"; + +/** Level-A values as a client may send them, all optional. */ +export interface LevelAInput { + a1?: number | null; + a2?: number | null; + a3?: number | null; + a4?: number | null; + a5?: number | null; +} + +/** Level-A values as they are written to `MoodEntry`. */ +export interface LevelAColumns { + moodA1: number | null; + stressA2: number | null; + energyA3: number | null; + connectionA4: number | null; + stabilityA5: number | null; +} + +/** + * The five columns for a write of `mood`, with any explicit client value + * winning over the derivation. + * + * A client that sends only a label gets the derived A1 and four NULLs. A + * client that sends `a1` gets its own number — that is what makes the detail + * sliders a real capture surface rather than a suggestion the server overrides + * on the way to the database. + * + * Every field is stated explicitly, including the NULLs, because the callers + * are upserts: on the `update:` arm an omitted field keeps a value from an + * earlier post, and a stale stress reading sitting beside a mood the user just + * changed is exactly the mismatch these columns exist to avoid. Callers whose + * write semantics are per-field rather than whole-row (the PATCH route) build + * their own object and use `deriveA1` directly. + */ +export function levelAForWrite( + mood: string, + explicit?: LevelAInput, +): LevelAColumns { + return { + moodA1: explicit?.a1 ?? getA1ForMood(mood), + stressA2: explicit?.a2 ?? null, + energyA3: explicit?.a3 ?? null, + connectionA4: explicit?.a4 ?? null, + stabilityA5: explicit?.a5 ?? null, + }; +} + +/** A1 for a five-point label — the derivation on its own, for writers that carry no level-A input. */ +export function deriveA1(mood: string): number { + return getA1ForMood(mood); +} + +/** The five columns as a client reads them back. */ +export interface LevelAWire { + a1: number | null; + a2: number | null; + a3: number | null; + a4: number | null; + a5: number | null; +} + +/** + * Turn a stored row's five columns into the wire keys the client sends. + * + * A read that answered `moodA1` while the write took `a1` would leave every + * form mapping the pair by hand, and one of them would eventually map it + * wrong. The names match in both directions. + */ +export function levelAForWire(row: { + moodA1: number | null; + stressA2: number | null; + energyA3: number | null; + connectionA4: number | null; + stabilityA5: number | null; +}): LevelAWire { + return { + a1: row.moodA1, + a2: row.stressA2, + a3: row.energyA3, + a4: row.connectionA4, + a5: row.stabilityA5, + }; +} diff --git a/src/lib/validations/mood.ts b/src/lib/validations/mood.ts index c5d88c745..6c58ac819 100644 --- a/src/lib/validations/mood.ts +++ b/src/lib/validations/mood.ts @@ -106,6 +106,14 @@ const ratedFactor = z.object({ }); const ratedFactors = z.array(ratedFactor).max(30); +// v1.37 — the five level-A self-state values, each 0-10. Optional on every +// path: a client that sends only a five-point label still writes a complete +// entry, because the server derives A1 from the label. A2 to A5 are only ever +// what the user set, so an omitted one stays absent rather than defaulting to +// the middle of the scale. The bound is the scale itself; `MOOD_DIMENSIONS` +// (`src/lib/mood/dimensions.ts`) carries the anchors the numbers mean. +const levelADimension = z.number().int().min(0).max(10); + export const createMoodEntrySchema = z .object({ mood: moodLevelEnum, @@ -120,6 +128,14 @@ export const createMoodEntrySchema = z // `MoodEntryTagLink.rating`. Out-of-scale or non-RATED keys are // rejected (422) / dropped server-side per the catalog. ratedFactors: ratedFactors.optional(), + // v1.37 — the five level-A values the detail sliders capture. Absent + // means the user did not answer; `a1` absent means the server derives it + // from `mood`, which keeps the one-tap check-in a full entry. + a1: levelADimension.optional(), + a2: levelADimension.optional(), + a3: levelADimension.optional(), + a4: levelADimension.optional(), + a5: levelADimension.optional(), // v1.4.30 H-5 — first-class free-text note. Replaces the // `tags: ["note:"]` workaround. Capped at 500 chars so the // Coach evidence shelf renders cleanly without truncating chips. @@ -155,6 +171,15 @@ export const updateMoodEntrySchema = z.object({ // v1.12.0 — full replacement of the rated-factor set when present. // `null` clears every rated link; omit to leave them untouched. ratedFactors: ratedFactors.nullable().optional(), + // v1.37 — level-A values on the edit path. Omitted leaves the stored value + // alone; an explicit `null` clears it, which is how a user takes back an + // answer they did not mean to give. Editing the five-point label without + // sending `a1` re-derives A1, the same way it re-derives `score`. + a1: levelADimension.nullable().optional(), + a2: levelADimension.nullable().optional(), + a3: levelADimension.nullable().optional(), + a4: levelADimension.nullable().optional(), + a5: levelADimension.nullable().optional(), note: z.string().max(500).nullable().optional(), // v1.17 W1b — same plausibility bound on the edit path. moodLoggedAt: validateEntryInstant( diff --git a/tests/integration/mood-writers-agree.test.ts b/tests/integration/mood-writers-agree.test.ts new file mode 100644 index 000000000..feb797bd3 --- /dev/null +++ b/tests/integration/mood-writers-agree.test.ts @@ -0,0 +1,343 @@ +/** + * v1.37 — every mood writer resolves the same pleasantness value. + * + * Five paths write a `MoodEntry`, and only one of them is the web form. If + * the derivation lived at the call site, a mood tapped in a chat and the same + * mood typed into the form would drift apart the first time one of the five + * was edited without the others. This test posts the identical mood through + * all five and asserts the rows agree, against real Postgres and the real + * handlers: + * + * 1. POST /api/mood-entries (web + iOS single) + * 2. POST /api/mood-entries/bulk (iOS backfill) + * 3. logTelegramMood (the bot) + * 4. POST /api/import (JSON restore) + * 5. logMcpMood (the assistant tool) + * + * It also pins the three rules the columns exist for: a re-post refreshes the + * value rather than leaving a stale one beside a fresh mood, an explicit + * client value beats the derivation, and nothing ever invents A2 to A5. + */ +import { NextRequest } from "next/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { cookieJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +const USER = "user-mood-writers-agree"; + +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), +})); + +async function createUserSession() { + await getPrismaClient().user.create({ + data: { + id: USER, + username: "mood-writers-agree", + email: "mood-writers-agree@example.test", + timezone: "Europe/Berlin", + }, + }); + const session = await getPrismaClient().session.create({ + data: { + userId: USER, + expiresAt: new Date(Date.now() + 60 * 60 * 1000), + }, + }); + cookieJar.set("healthlog_session", session.id); +} + +function jsonRequest(path: string, body: unknown): NextRequest { + return new NextRequest(`http://localhost${path}`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); +}); + +describe("mood writers agree on the derived level-A value", () => { + it("writes the same moodA1 through all five writers for the same mood", async () => { + await createUserSession(); + + const { POST: postSingle } = await import("@/app/api/mood-entries/route"); + const { POST: postBulk } = + await import("@/app/api/mood-entries/bulk/route"); + const { POST: postImport } = await import("@/app/api/import/route"); + const { logTelegramMood } = await import("@/lib/mood/create-from-telegram"); + const { logMcpMood } = await import("@/lib/mcp/writes"); + + // 1 — single create. + const single = await postSingle( + jsonRequest("/api/mood-entries", { + mood: "GUT", + moodLoggedAt: "2026-05-16T08:00:00.000Z", + externalId: "writer-single", + }), + ); + expect(single.status).toBe(201); + + // 2 — bulk backfill. The request shape carries no level-A field; the + // server derives it, which is the whole point of this phase being + // invisible on the wire. + const bulk = await postBulk( + jsonRequest("/api/mood-entries/bulk", { + entries: [ + { + mood: "GUT", + moodLoggedAt: "2026-05-16T09:00:00.000Z", + externalId: "writer-bulk", + }, + ], + }), + ); + expect(bulk.status).toBe(200); + + // 3 — the bot. Its input is the 1-5 score; it resolves the label itself. + const telegram = await logTelegramMood({ + userId: USER, + score: 4, + tz: "Europe/Berlin", + externalId: "telegram:mood:1:2:4", + }); + expect(telegram.created).toBe(true); + + // 4 — the JSON import. Deliberately carries a `score` that disagrees with + // its own label: this is the one writer whose score comes out of the file + // rather than out of the label, and the self-assessment column has to + // follow the label the user picked, not the number the file asserts. + const imported = await postImport( + jsonRequest("/api/import", { + moodEntries: [ + { + date: "2026-05-16", + mood: "GUT", + score: 2, + loggedAt: "2026-05-16T10:00:00.000Z", + externalId: "writer-import", + }, + ], + }), + ); + expect(imported.status).toBe(200); + + // 5 — the assistant tool. + const mcp = await logMcpMood({ + userId: USER, + score: 4, + tz: "Europe/Berlin", + idempotencyKey: "writer-mcp-1", + }); + expect(mcp.status).toBe("written"); + + const rows = await getPrismaClient().moodEntry.findMany({ + where: { userId: USER }, + orderBy: { source: "asc" }, + select: { + source: true, + mood: true, + score: true, + moodA1: true, + stressA2: true, + energyA3: true, + connectionA4: true, + stabilityA5: true, + }, + }); + + // Five writers, five rows — and one of them is the bulk path, which must + // not have been folded into the single path's row. + expect(rows).toHaveLength(5); + expect(rows.map((r) => r.source).sort()).toEqual([ + "IMPORT", + "MANUAL", + "MANUAL", + "MCP", + "TELEGRAM", + ]); + + for (const row of rows) { + expect(row.mood).toBe("GUT"); + // GUT sits at 7 on the level-A scale. Asserted by value on every row, + // not by comparing the rows to each other: five rows agreeing on a + // wrong number would pass a same-value assertion. + expect(row.moodA1).toBe(7); + // Nothing derives the other four, on any path. + expect(row.stressA2).toBeNull(); + expect(row.energyA3).toBeNull(); + expect(row.connectionA4).toBeNull(); + expect(row.stabilityA5).toBeNull(); + } + + // The imported row keeps the file's own score while its level-A value + // follows the label — the disagreement the file carried is not copied + // into the new column. + const importedRow = rows.find((r) => r.source === "IMPORT"); + expect(importedRow?.score).toBe(2); + expect(importedRow?.moodA1).toBe(7); + }); + + it("refreshes moodA1 when an externalId is re-posted with a different mood", async () => { + await createUserSession(); + const { POST: postSingle } = await import("@/app/api/mood-entries/route"); + const { POST: postBulk } = + await import("@/app/api/mood-entries/bulk/route"); + + await postSingle( + jsonRequest("/api/mood-entries", { + mood: "LAUSIG", + moodLoggedAt: "2026-05-16T08:00:00.000Z", + externalId: "repost-single", + }), + ); + await postSingle( + jsonRequest("/api/mood-entries", { + mood: "SUPER_GUT", + moodLoggedAt: "2026-05-16T08:10:00.000Z", + externalId: "repost-single", + }), + ); + + await postBulk( + jsonRequest("/api/mood-entries/bulk", { + entries: [ + { + mood: "LAUSIG", + moodLoggedAt: "2026-05-16T11:00:00.000Z", + externalId: "repost-bulk", + }, + ], + }), + ); + await postBulk( + jsonRequest("/api/mood-entries/bulk", { + entries: [ + { + mood: "SUPER_GUT", + moodLoggedAt: "2026-05-16T11:10:00.000Z", + externalId: "repost-bulk", + }, + ], + }), + ); + + const rows = await getPrismaClient().moodEntry.findMany({ + where: { userId: USER }, + orderBy: { externalId: "asc" }, + select: { externalId: true, mood: true, score: true, moodA1: true }, + }); + + // One row per externalId — the re-post updated in place, as before. + expect(rows).toHaveLength(2); + for (const row of rows) { + expect(row.mood).toBe("SUPER_GUT"); + expect(row.score).toBe(5); + // The update arm of both upserts restates the value. Without it the row + // would carry LAUSIG's 1 beside SUPER_GUT's 5. + expect(row.moodA1).toBe(9); + } + }); + + it("lets an explicit client value win over the derivation, and stores the other four literally", async () => { + await createUserSession(); + const { POST: postSingle } = await import("@/app/api/mood-entries/route"); + + const res = await postSingle( + jsonRequest("/api/mood-entries", { + mood: "OKAY", + moodLoggedAt: "2026-05-16T12:00:00.000Z", + // A day that felt middling overall but was genuinely stressful: the + // whole reason five values beat one. + a1: 4, + a2: 9, + a3: 2, + }), + ); + expect(res.status).toBe(201); + const json = (await res.json()) as { + data: { a1: number; a2: number; a3: number; a4: null; a5: null }; + }; + // The response echoes the values under the keys the request used. + expect(json.data.a1).toBe(4); + expect(json.data.a2).toBe(9); + expect(json.data.a5).toBeNull(); + + const row = await getPrismaClient().moodEntry.findFirstOrThrow({ + where: { userId: USER }, + }); + // Not 5 — OKAY's derived value lost to the number the user set. + expect(row.moodA1).toBe(4); + // Stored literally, higher meaning more stress. A value flipped on the + // way in could never be recovered. + expect(row.stressA2).toBe(9); + expect(row.energyA3).toBe(2); + // Untouched sliders write nothing at all. + expect(row.connectionA4).toBeNull(); + expect(row.stabilityA5).toBeNull(); + // The five-point axis is untouched by any of this. + expect(row.score).toBe(3); + }); + + it("re-derives A1 when an entry's mood is corrected, and clears a value on an explicit null", async () => { + await createUserSession(); + const { POST: postSingle } = await import("@/app/api/mood-entries/route"); + const { PUT } = await import("@/app/api/mood-entries/[id]/route"); + + const created = await postSingle( + jsonRequest("/api/mood-entries", { + mood: "SCHLECHT", + moodLoggedAt: "2026-05-16T13:00:00.000Z", + a2: 8, + }), + ); + const createdJson = (await created.json()) as { data: { id: string } }; + const id = createdJson.data.id; + + const corrected = await PUT( + new NextRequest(`http://localhost/api/mood-entries/${id}`, { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ mood: "GUT", a2: null }), + }), + { params: Promise.resolve({ id }) }, + ); + expect(corrected.status).toBe(200); + + const row = await getPrismaClient().moodEntry.findUniqueOrThrow({ + where: { id }, + }); + expect(row.mood).toBe("GUT"); + expect(row.score).toBe(4); + // The label moved, so pleasantness moved with it — the same way `score` + // does. A stale 3 here would contradict the entry's own label. + expect(row.moodA1).toBe(7); + // An explicit null takes back an answer. + expect(row.stressA2).toBeNull(); + }); +});