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