feat(mood): add a first-class note column replacing the tag-based workaround

R-E H-5. Up to v0.4.1.1 the iOS app stored mood-entry prose as
`tags: ["note:<text>"]` because the server had no dedicated column.
The workaround contaminated the tags axis with prose the Coach
evidence shelf misread as taxonomic labels.

Schema (migration 0063, additive):
  - mood_entries.note (TEXT, nullable)

Validation + routes:
  - createMoodEntrySchema gains optional note (max 500 chars)
  - updateMoodEntrySchema accepts note + null to clear
  - POST /api/mood-entries persists note alongside tags
  - PUT /api/mood-entries/[id] honours partial note updates
  - POST /api/mood-entries/bulk carries note through the bulk path

Backfill script at scripts/backfill-mood-note-column.ts lifts the
legacy note:<text> tag entries into the new column. Idempotent
dry-run mode is the default; --confirm commits. Helper logic lives
in src/lib/mood/note-backfill.ts for tight unit coverage.

iOS contract: additive. Old iOS payloads (no note field) round-trip
unchanged. New iOS code paths can read or write note without
touching tags.
This commit is contained in:
Marc-André Bombeck committed 2026-05-16 12:08:53 +02:00
1 parent 0ccc00b774
commit 11325e31a0
10 files changed
+411 -4

No files matched your search

@@ -0,0 +1,16 @@
-- v1.4.30 — MoodEntry.note column (R-E H-5)
--
-- Replaces the `tags: ["note:<text>"]` 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;
+8
View File
@@ -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:<text>"]` 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
+102
View File
@@ -0,0 +1,102 @@
#!/usr/bin/env tsx
/**
* scripts/backfill-mood-note-column.ts
*
* One-shot backfill that lifts existing `tags: ["note:<text>"]`
* 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:<text>` entry as
* the new note value, drop the entry from the array.
* 3. UPDATE the row in a transaction: `note = <decoded text>`,
* `tags = <slimmed JSON or null if empty>`.
*
* 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<void> {
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);
});
+3
View File
@@ -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 },
+6 -3
View File
@@ -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<Response> {
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,
},
});
+2 -1
View File
@@ -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,
},
@@ -0,0 +1,60 @@
import { describe, expect, it } from "vitest";
import { extractNoteAndSlimTags } from "../note-backfill";
describe("extractNoteAndSlimTags", () => {
it("extracts the first note:<text> 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,
});
});
});
+40
View File
@@ -0,0 +1,40 @@
/**
* v1.4.30 — pure helper for the `MoodEntry.note` backfill (R-E H-5).
*
* Pulls the first `note:<text>` 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,
};
}
+5
View File
@@ -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:<text>"]` 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))
@@ -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);
});
});