diff --git a/prisma/migrations/0063_mood_entry_note_column/migration.sql b/prisma/migrations/0063_mood_entry_note_column/migration.sql new file mode 100644 index 000000000..5752e613a --- /dev/null +++ b/prisma/migrations/0063_mood_entry_note_column/migration.sql @@ -0,0 +1,16 @@ +-- v1.4.30 — MoodEntry.note column (R-E H-5) +-- +-- Replaces the `tags: ["note:"]` workaround the iOS app +-- shipped through v0.4.1.1. The prior shape contaminated the tags +-- axis with prose that the Coach evidence shelf misread as +-- taxonomic labels. The new column is a first-class free-text +-- field; the bulk-backfill migration script (run separately by the +-- operator) pulls existing `note:...` tag entries into this column. +-- +-- Additive-only — the column is nullable, no existing row needs +-- updating for the schema migration itself to apply cleanly. The +-- `IF NOT EXISTS` guard keeps this idempotent against partial +-- historical runs (same posture as 0058 and 0061). + +ALTER TABLE "mood_entries" + ADD COLUMN IF NOT EXISTS "note" TEXT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 338040fd0..61120aee6 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1076,6 +1076,14 @@ model MoodEntry { mood String // SUPER_GUT, GUT, OKAY, SCHLECHT, LAUSIG score Int // 1-5 tags String? // JSON array of tag keys + /// v1.4.30 — first-class free-text note. Replaces the + /// `tags: ["note:"]` workaround the iOS app shipped through + /// v0.4.1.1 (per R-E H-5); the prior pattern contaminated the + /// tags axis with prose the Coach evidence shelf then read as + /// taxonomic labels. Nullable for the additive-only migration + /// path; the bulk-backfill endpoint pulls existing `note:...` tag + /// rows into this column via the migration script. + note String? @db.Text source String @default("MOODLOG") // WEB, TELEGRAM, DAYLIO moodLoggedAt DateTime @map("mood_logged_at") /// v1.4.25 W7b — per-row IANA timezone that the `date` string is diff --git a/scripts/backfill-mood-note-column.ts b/scripts/backfill-mood-note-column.ts new file mode 100644 index 000000000..4704a8dea --- /dev/null +++ b/scripts/backfill-mood-note-column.ts @@ -0,0 +1,102 @@ +#!/usr/bin/env tsx +/** + * scripts/backfill-mood-note-column.ts + * + * One-shot backfill that lifts existing `tags: ["note:"]` + * entries out of `mood_entries.tags` into the new `mood_entries.note` + * column. Operator runs this once after the v1.4.30 schema migration + * applies. + * + * Strategy: + * 1. SELECT every MoodEntry whose `tags` JSON contains an entry + * that begins with `note:`. + * 2. Decode the JSON array, pull the first `note:` entry as + * the new note value, drop the entry from the array. + * 3. UPDATE the row in a transaction: `note = `, + * `tags = `. + * + * Idempotent — a row whose `tags` no longer carries any `note:` + * entries is left alone. Re-running on a clean dataset is a no-op. + * + * Usage: + * pnpm tsx scripts/backfill-mood-note-column.ts # dry-run + * pnpm tsx scripts/backfill-mood-note-column.ts --confirm # commit + */ +import "dotenv/config"; + +import { prisma } from "@/lib/db"; +import { extractNoteAndSlimTags } from "@/lib/mood/note-backfill"; + +interface ParsedArgs { + confirm: boolean; +} + +function parseArgs(argv: readonly string[]): ParsedArgs { + const out: ParsedArgs = { confirm: false }; + for (const arg of argv) { + if (arg === "--confirm") out.confirm = true; + else if (arg === "--help" || arg === "-h") { + console.log( + "Usage: pnpm tsx scripts/backfill-mood-note-column.ts [--confirm]", + ); + process.exit(0); + } else { + console.error(`Unknown argument: ${arg}`); + process.exit(2); + } + } + return out; +} + +interface RowSnapshot { + id: string; + tags: string | null; + note: string | null; +} + +async function main(): Promise { + const args = parseArgs(process.argv.slice(2)); + const dryRun = !args.confirm; + + if (dryRun) { + console.log("[backfill-note] DRY RUN — no rows will be written. Pass --confirm to commit."); + } else { + console.log("[backfill-note] COMMIT MODE — updating rows in place."); + } + + // Naive scan: filter in-memory rather than via JSON predicates so + // the query stays portable across Postgres minor versions. The + // mood_entries table is small (per-user lifetime budget). + const candidates: RowSnapshot[] = await prisma.moodEntry.findMany({ + where: { + tags: { not: null }, + note: null, + }, + select: { id: true, tags: true, note: true }, + }); + + console.log(`[backfill-note] scanned ${candidates.length} candidate rows`); + + let updated = 0; + for (const row of candidates) { + if (!row.tags) continue; + const { note, newTags } = extractNoteAndSlimTags(row.tags); + if (note === null) continue; + + updated += 1; + if (!dryRun) { + await prisma.moodEntry.update({ + where: { id: row.id }, + data: { note, tags: newTags }, + }); + } + } + + console.log(`[backfill-note] ${updated} row${updated === 1 ? "" : "s"} ${dryRun ? "would be" : "were"} updated`); + await prisma.$disconnect(); +} + +main().catch((err: unknown) => { + console.error("[backfill-note] failed:", err); + process.exit(1); +}); diff --git a/src/app/api/mood-entries/[id]/route.ts b/src/app/api/mood-entries/[id]/route.ts index 6359aa275..23ffc98d0 100644 --- a/src/app/api/mood-entries/[id]/route.ts +++ b/src/app/api/mood-entries/[id]/route.ts @@ -88,6 +88,9 @@ export const PUT = apiHandler( if (data.tags !== undefined) { updateData.tags = data.tags ? JSON.stringify(data.tags) : null; } + if (data.note !== undefined) { + updateData.note = data.note; + } const entry = await prisma.moodEntry.update({ where: { id }, diff --git a/src/app/api/mood-entries/bulk/route.ts b/src/app/api/mood-entries/bulk/route.ts index e22cd400a..24fc2abca 100644 --- a/src/app/api/mood-entries/bulk/route.ts +++ b/src/app/api/mood-entries/bulk/route.ts @@ -50,6 +50,7 @@ const BATCH_RATE_LIMIT_WINDOW_MS = 60 * 1000; const bulkEntrySchema = z.object({ mood: moodLevelEnum, tags: z.array(z.string().max(50)).max(20).optional(), + note: z.string().max(500).optional(), moodLoggedAt: z.iso.datetime({ offset: true }).transform((s) => new Date(s)), source: moodSourceEnum.optional().default("MANUAL"), /** @@ -158,16 +159,18 @@ async function postBulk(request: NextRequest): Promise { mood: entry.mood, score, tags: entry.tags ? JSON.stringify(entry.tags) : null, + note: entry.note ?? null, source: entry.source, moodLoggedAt: entry.moodLoggedAt, }, update: { - // Last-writer-wins on the mood + tags pair. The iOS client - // only re-posts an existing entry when it has new data; the - // server trusts that decision. + // Last-writer-wins on the mood + tags + note triple. The + // iOS client only re-posts an existing entry when it has + // new data; the server trusts that decision. mood: entry.mood, score, tags: entry.tags ? JSON.stringify(entry.tags) : null, + note: entry.note ?? null, }, }); diff --git a/src/app/api/mood-entries/route.ts b/src/app/api/mood-entries/route.ts index 92e499327..d56d310ce 100644 --- a/src/app/api/mood-entries/route.ts +++ b/src/app/api/mood-entries/route.ts @@ -90,7 +90,7 @@ async function postMoodEntry(request: NextRequest) { return apiError(parsed.error.issues[0].message, 422); } - const { mood, tags, moodLoggedAt, source } = parsed.data; + const { mood, tags, note, moodLoggedAt, source } = parsed.data; // 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 @@ -108,6 +108,7 @@ async function postMoodEntry(request: NextRequest) { mood, score, tags: tags ? JSON.stringify(tags) : null, + note: note ?? null, source: source ?? "MANUAL", moodLoggedAt, }, diff --git a/src/lib/mood/__tests__/note-backfill.test.ts b/src/lib/mood/__tests__/note-backfill.test.ts new file mode 100644 index 000000000..32eac461f --- /dev/null +++ b/src/lib/mood/__tests__/note-backfill.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest"; + +import { extractNoteAndSlimTags } from "../note-backfill"; + +describe("extractNoteAndSlimTags", () => { + it("extracts the first note: entry and slims the tag array", () => { + expect( + extractNoteAndSlimTags(JSON.stringify(["coffee", "note:slept poorly", "workout"])), + ).toEqual({ + note: "slept poorly", + newTags: JSON.stringify(["coffee", "workout"]), + }); + }); + + it("returns null tags when the slimmed array is empty", () => { + expect(extractNoteAndSlimTags(JSON.stringify(["note:lonely entry"]))).toEqual({ + note: "lonely entry", + newTags: null, + }); + }); + + it("returns note=null when no entry starts with note:", () => { + expect(extractNoteAndSlimTags(JSON.stringify(["coffee", "workout"]))).toEqual({ + note: null, + newTags: JSON.stringify(["coffee", "workout"]), + }); + }); + + it("extracts only the first note:... entry; further note-prefixed entries stay as tags", () => { + expect( + extractNoteAndSlimTags(JSON.stringify(["note:first", "note:second"])), + ).toEqual({ + note: "first", + newTags: JSON.stringify(["note:second"]), + }); + }); + + it("returns the input string when it is not valid JSON", () => { + expect(extractNoteAndSlimTags("not-a-json-array")).toEqual({ + note: null, + newTags: "not-a-json-array", + }); + }); + + it("returns the input string when the JSON is not an array", () => { + expect(extractNoteAndSlimTags(JSON.stringify({ note: "x" }))).toEqual({ + note: null, + newTags: JSON.stringify({ note: "x" }), + }); + }); + + it("preserves the note prefix text exactly (no trim, no decode)", () => { + expect( + extractNoteAndSlimTags(JSON.stringify(["note: with leading space "])), + ).toEqual({ + note: " with leading space ", + newTags: null, + }); + }); +}); diff --git a/src/lib/mood/note-backfill.ts b/src/lib/mood/note-backfill.ts new file mode 100644 index 000000000..e7482e52d --- /dev/null +++ b/src/lib/mood/note-backfill.ts @@ -0,0 +1,40 @@ +/** + * v1.4.30 — pure helper for the `MoodEntry.note` backfill (R-E H-5). + * + * Pulls the first `note:` entry out of a JSON-encoded tags array + * and returns the cleaned tags JSON alongside the extracted note. Used + * by `scripts/backfill-mood-note-column.ts` (CLI) — kept in `src/lib/` + * so unit tests can exercise the bucketing without booting `dotenv` + * + the Prisma client. + */ +export function extractNoteAndSlimTags(tagsJson: string): { + note: string | null; + newTags: string | null; +} { + let parsed: unknown; + try { + parsed = JSON.parse(tagsJson); + } catch { + return { note: null, newTags: tagsJson }; + } + if (!Array.isArray(parsed)) { + return { note: null, newTags: tagsJson }; + } + let extracted: string | null = null; + const remaining: string[] = []; + for (const tag of parsed) { + if (typeof tag !== "string") { + remaining.push(String(tag)); + continue; + } + if (extracted === null && tag.startsWith("note:")) { + extracted = tag.slice("note:".length); + } else { + remaining.push(tag); + } + } + return { + note: extracted, + newTags: remaining.length > 0 ? JSON.stringify(remaining) : null, + }; +} diff --git a/src/lib/validations/moodlog.ts b/src/lib/validations/moodlog.ts index d05eac11c..a6c018acd 100644 --- a/src/lib/validations/moodlog.ts +++ b/src/lib/validations/moodlog.ts @@ -81,6 +81,10 @@ export function getScoreForMood(mood: string): number { export const createMoodEntrySchema = z.object({ mood: moodLevelEnum, tags: z.array(z.string().max(50)).max(20).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. + note: z.string().max(500).optional(), moodLoggedAt: z.iso.datetime({ offset: true }).transform((s) => new Date(s)), source: moodSourceEnum.optional().default("MANUAL"), }); @@ -88,6 +92,7 @@ export const createMoodEntrySchema = z.object({ export const updateMoodEntrySchema = z.object({ mood: moodLevelEnum.optional(), tags: z.array(z.string().max(50)).max(20).nullable().optional(), + note: z.string().max(500).nullable().optional(), moodLoggedAt: z.iso .datetime({ offset: true }) .transform((s) => new Date(s)) diff --git a/tests/integration/mood-entries-note-column.test.ts b/tests/integration/mood-entries-note-column.test.ts new file mode 100644 index 000000000..0e810855f --- /dev/null +++ b/tests/integration/mood-entries-note-column.test.ts @@ -0,0 +1,169 @@ +/** + * v1.4.30 — `MoodEntry.note` column round-trip integration. + * + * Asserts: + * - POST /api/mood-entries accepts the new `note` field + * - PUT /api/mood-entries/[id] updates `note` (and accepts null to clear) + * - POST /api/mood-entries/bulk persists `note` per entry + * - The Zod cap (500 chars) rejects oversize prose + */ +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 TEST_USER_ID = "user-mood-note"; + +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-note", + email: "mood-note@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("MoodEntry.note round-trip (real Postgres)", () => { + it("POST /api/mood-entries persists `note`", 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", + note: "Long run before breakfast.", + }), + ); + expect(res.status).toBe(201); + const stored = await getPrismaClient().moodEntry.findFirst({ + where: { userId: TEST_USER_ID }, + }); + expect(stored?.note).toBe("Long run before breakfast."); + }); + + it("PUT /api/mood-entries/[id] updates `note` and accepts null to clear it", async () => { + const created = await getPrismaClient().moodEntry.create({ + data: { + userId: TEST_USER_ID, + date: "2026-05-16", + tz: "Europe/Berlin", + mood: "OKAY", + score: 3, + moodLoggedAt: new Date("2026-05-16T08:00:00.000Z"), + note: "initial note", + }, + }); + const { PUT } = await import("@/app/api/mood-entries/[id]/route"); + + const res1 = await PUT( + putRequest(`/api/mood-entries/${created.id}`, { note: "revised note" }), + { params: Promise.resolve({ id: created.id }) }, + ); + expect(res1.status).toBe(200); + const after1 = await getPrismaClient().moodEntry.findUnique({ + where: { id: created.id }, + }); + expect(after1?.note).toBe("revised note"); + + const res2 = await PUT( + putRequest(`/api/mood-entries/${created.id}`, { note: null }), + { params: Promise.resolve({ id: created.id }) }, + ); + expect(res2.status).toBe(200); + const after2 = await getPrismaClient().moodEntry.findUnique({ + where: { id: created.id }, + }); + expect(after2?.note).toBeNull(); + }); + + it("POST /api/mood-entries/bulk persists `note` per entry", async () => { + const { POST } = await import("@/app/api/mood-entries/bulk/route"); + const res = await POST( + postRequest("/api/mood-entries/bulk", { + entries: [ + { + mood: "GUT", + moodLoggedAt: "2026-05-16T08:00:00.000Z", + note: "Morning", + }, + { + mood: "OKAY", + moodLoggedAt: "2026-05-16T14:00:00.000Z", + note: "Afternoon dip", + }, + ], + }), + ); + expect(res.status).toBe(200); + const stored = await getPrismaClient().moodEntry.findMany({ + where: { userId: TEST_USER_ID }, + orderBy: { moodLoggedAt: "asc" }, + }); + expect(stored.map((r) => r.note)).toEqual(["Morning", "Afternoon dip"]); + }); + + it("rejects an oversize note (501 chars) with 422", async () => { + const { POST } = await import("@/app/api/mood-entries/route"); + const oversize = "x".repeat(501); + const res = await POST( + postRequest("/api/mood-entries", { + mood: "OKAY", + moodLoggedAt: "2026-05-16T08:00:00.000Z", + note: oversize, + }), + ); + expect(res.status).toBe(422); + }); +});