diff --git a/scripts/backfill-mood-note-column.ts b/scripts/backfill-mood-note-column.ts index 093d8d2d8..ebdeb514d 100644 --- a/scripts/backfill-mood-note-column.ts +++ b/scripts/backfill-mood-note-column.ts @@ -52,6 +52,7 @@ interface RowSnapshot { id: string; tags: string | null; note: string | null; + updatedAt: Date; } async function main(): Promise { @@ -74,7 +75,7 @@ async function main(): Promise { tags: { not: null }, note: null, }, - select: { id: true, tags: true, note: true }, + select: { id: true, tags: true, note: true, updatedAt: true }, }); console.log(`[backfill-note] scanned ${candidates.length} candidate rows`); @@ -87,9 +88,11 @@ async function main(): Promise { updated += 1; if (!dryRun) { + // The entry did not change for its reader; it keeps its `updatedAt` + // (the sync cursor) rather than taking the script's run time. await prisma.moodEntry.update({ where: { id: row.id }, - data: { note, tags: newTags }, + data: { note, tags: newTags, updatedAt: row.updatedAt }, }); } } diff --git a/src/__tests__/backfill-preserves-updated-at-guard.test.ts b/src/__tests__/backfill-preserves-updated-at-guard.test.ts new file mode 100644 index 000000000..41a39bdf4 --- /dev/null +++ b/src/__tests__/backfill-preserves-updated-at-guard.test.ts @@ -0,0 +1,191 @@ +/** + * A write that is not activity keeps the row's `updatedAt`. + * + * Prisma's `@updatedAt` stamps "now" on every `update` / `updateMany` / + * `upsert` that does not name the column. That is right for an edit and + * wrong for a one-shot rewrite of how a value is stored: the title + * encryption backfill bumped every Coach conversation it touched, and the + * Coach panel, which groups and orders conversations by `updatedAt`, then + * showed months-old threads under "Today". The same column is the sync + * cursor for measurements and mood entries, so a storage-only rewrite there + * made every client pull rows that had not changed. + * + * Two freezes: + * + * 1. Every Prisma write in a backfill (`lib/jobs/*-backfill.ts`, + * `scripts/backfill-*.ts`) on a model with an `@updatedAt` column names + * `updatedAt` in the call (carrying the row's own value forward), or is + * listed below with the reason the stamp is wanted. + * 2. Every `coachConversation` write anywhere names `updatedAt`, or is + * listed as something the person did in the conversation. Background + * bookkeeping (the memory summary) writes through raw SQL, which Prisma + * does not stamp. + * + * A tripwire, not a proof: it cannot tell whether a listed reason is still + * true, only that the set did not change without someone editing this file. + */ +import { describe, it, expect } from "vitest"; +import { readFileSync, readdirSync } from "node:fs"; +import { join } from "node:path"; + +import { walkSourceFiles } from "./helpers/source-files"; + +const ROOT = process.cwd(); +const SRC = join(ROOT, "src"); + +/** Models (as Prisma client accessors) that carry an `@updatedAt` column. */ +function modelsWithUpdatedAt(): Set { + const schema = readFileSync(join(ROOT, "prisma/schema.prisma"), "utf8"); + const out = new Set(); + for (const m of schema.matchAll(/^model\s+(\w+)\s*\{([\s\S]*?)^\}/gm)) { + if (/@updatedAt\b/.test(m[2])) { + out.add(m[1][0].toLowerCase() + m[1].slice(1)); + } + } + return out; +} + +const WRITE_RE = /\.\s*([a-zA-Z]+)\s*\.\s*(update|updateMany|upsert)\s*\(/g; + +/** The argument text of the call whose `(` sits at `open`. */ +function callArgs(source: string, open: number): string { + let depth = 0; + for (let i = open; i < source.length; i++) { + const c = source[i]; + if (c === "(") depth++; + else if (c === ")") { + depth--; + if (depth === 0) return source.slice(open, i + 1); + } + } + return source.slice(open); +} + +interface Write { + file: string; + model: string; + namesUpdatedAt: boolean; +} + +function writesIn(file: string, source: string): Write[] { + const out: Write[] = []; + for (const m of source.matchAll(WRITE_RE)) { + const open = (m.index ?? 0) + m[0].length - 1; + out.push({ + file, + model: m[1], + namesUpdatedAt: /\bupdatedAt\s*:/.test(callArgs(source, open)), + }); + } + return out; +} + +function backfillFiles(): string[] { + const jobs = readdirSync(join(SRC, "lib/jobs")) + .filter((f) => /-backfill\.ts$/.test(f)) + .map((f) => `src/lib/jobs/${f}`); + const scripts = readdirSync(join(ROOT, "scripts")) + .filter((f) => /^backfill-.*\.ts$/.test(f)) + .map((f) => `scripts/${f}`); + return [...jobs, ...scripts].sort(); +} + +/** + * Backfill writes that stamp `updatedAt` on purpose, keyed `file > model`. + */ +const BACKFILL_STAMPS: Record = { + "src/lib/jobs/fitbit-backfill.ts > fitbitConnection": + "the job's own progress on the connection row; not a listed record", + "src/lib/jobs/google-health-backfill.ts > googleHealthConnection": + "the job's own progress on the connection row; not a listed record", + "src/lib/jobs/whoop-backfill.ts > whoopConnection": + "the job's own progress on the connection row; not a listed record", + "src/lib/jobs/sleep-timeline-backfill.ts > whoopConnection": + "marks the connection's one-shot as done; not a listed record", + "src/lib/jobs/sleep-timeline-backfill.ts > withingsConnection": + "marks the connection's one-shot as done; not a listed record", + "src/lib/jobs/strava-backfill.ts > user": + "stamps the account's backfill-completed marker", + "src/lib/jobs/document-content-index-backfill.ts > documentContentIndex": + "the index row is rebuilt content; its updatedAt is the rebuild time", + "src/lib/jobs/lab-biomarker-backfill.ts > biomarker": + "shared catalogue row created or refreshed by the backfill", + "src/lib/jobs/lab-biomarker-backfill.ts > labResult": + "bulk link to a catalogue id; no lab surface orders by updatedAt and a bulk write cannot carry each row's own value", + "src/lib/jobs/free-text-encryption-backfill.ts > measurementReminder": + "bulk clear of a retired readable copy; reminders are listed by schedule, not by updatedAt", +}; + +/** + * `coachConversation` writes outside backfills that stamp `updatedAt` + * because the person did something in the conversation, keyed by file. + */ +const COACH_ACTIVITY: Record = { + "src/lib/ai/coach/persistence.ts": + "a turn sets updatedAt to now explicitly; a rename and a document attach are the person acting on the thread", +}; + +describe("backfills keep updatedAt", () => { + const stamped = modelsWithUpdatedAt(); + const files = backfillFiles(); + const writes = files.flatMap((f) => + writesIn(f, readFileSync(join(ROOT, f), "utf8")), + ); + + it("reads the schema and the backfills at all", () => { + // An empty match set would agree with any allowlist. + expect(stamped.size).toBeGreaterThanOrEqual(40); + expect(stamped.has("coachConversation")).toBe(true); + expect(files.length).toBeGreaterThanOrEqual(10); + expect(writes.length).toBeGreaterThanOrEqual(15); + }); + + it("carries updatedAt through every storage-only rewrite", () => { + const offenders = writes + .filter((w) => stamped.has(w.model) && !w.namesUpdatedAt) + .map((w) => `${w.file} > ${w.model}`) + .filter((key) => !(key in BACKFILL_STAMPS)); + expect( + [...new Set(offenders)], + "a backfill write bumps updatedAt: carry the row's own value (`updatedAt: fresh.updatedAt`), or list it with the reason the stamp is wanted", + ).toEqual([]); + }); + + it("has no stale entries", () => { + const seen = new Set( + writes + .filter((w) => stamped.has(w.model) && !w.namesUpdatedAt) + .map((w) => `${w.file} > ${w.model}`), + ); + expect(Object.keys(BACKFILL_STAMPS).filter((k) => !seen.has(k))).toEqual( + [], + ); + }); +}); + +describe("coach conversations move only on activity", () => { + const files = walkSourceFiles(SRC, { floor: 3000 }) + .filter((p) => !p.startsWith("generated/")) + .filter((p) => !p.includes("__tests__")) + .map((p) => `src/${p}`); + const writes = [ + ...files.flatMap((f) => writesIn(f, readFileSync(join(ROOT, f), "utf8"))), + ...backfillFiles() + .filter((f) => f.startsWith("scripts/")) + .flatMap((f) => writesIn(f, readFileSync(join(ROOT, f), "utf8"))), + ].filter((w) => w.model === "coachConversation"); + + it("finds the conversation writes at all", () => { + expect(writes.length).toBeGreaterThanOrEqual(3); + }); + + it("names updatedAt in every write that is not the person's own action", () => { + const offenders = writes + .filter((w) => !w.namesUpdatedAt && !(w.file in COACH_ACTIVITY)) + .map((w) => w.file); + expect( + [...new Set(offenders)], + "a coachConversation write that is not activity must keep updatedAt (carry it, or write through raw SQL)", + ).toEqual([]); + }); +}); diff --git a/src/lib/ai/coach/__tests__/conversation-summary.test.ts b/src/lib/ai/coach/__tests__/conversation-summary.test.ts index 5adc81ee6..06ec84811 100644 --- a/src/lib/ai/coach/__tests__/conversation-summary.test.ts +++ b/src/lib/ai/coach/__tests__/conversation-summary.test.ts @@ -53,7 +53,8 @@ function makePrisma(conversation: unknown) { update, findFirst, client: { - coachConversation: { findFirst, update }, + coachConversation: { findFirst }, + $executeRaw: update, } as never, }; } @@ -161,7 +162,7 @@ describe("refreshConversationSummary", () => { } }); - it("(e) success path encrypts and persists summaryEncrypted + turn count + updatedAt", async () => { + it("(e) success path persists the summary and turn count, and leaves updatedAt alone", async () => { const now = new Date("2026-06-04T08:00:00.000Z"); const { client, update } = makePrisma( makeConversation(30, { summaryTurnCount: 0 }), @@ -177,12 +178,19 @@ describe("refreshConversationSummary", () => { expect(result.status).toBe("generated"); expect(encryptToBytes).toHaveBeenCalledWith(okCompletion.content); expect(update).toHaveBeenCalledTimes(1); - const updateArg = update.mock.calls[0]![0]; - expect(updateArg.where).toEqual({ id: "conv-1" }); - expect(updateArg.data.summaryEncrypted).toBeDefined(); - expect(updateArg.data.summaryUpdatedAt).toBe(now); + const [strings, ...values] = update.mock.calls[0]! as [ + TemplateStringsArray, + ...unknown[], + ]; + const sql = strings.join("?"); + expect(sql).toContain('UPDATE "coach_conversations"'); + expect(sql).toContain('"summary_encrypted"'); + // A memory rewrite is not activity: the panel orders by updated_at. + expect(sql).not.toMatch(/"updated_at"/); + expect(values[1]).toBe(now); // foldHighWater = 30 - 18 = 12. - expect(updateArg.data.summaryTurnCount).toBe(12); + expect(values[2]).toBe(12); + expect(values[3]).toBe("conv-1"); }); }); diff --git a/src/lib/ai/coach/conversation-summary.ts b/src/lib/ai/coach/conversation-summary.ts index 9bdc03b4a..1896f778c 100644 --- a/src/lib/ai/coach/conversation-summary.ts +++ b/src/lib/ai/coach/conversation-summary.ts @@ -56,7 +56,7 @@ type RunCompletion = typeof runStatusCompletion; export interface RefreshConversationSummaryOptions { now?: Date; runCompletion?: RunCompletion; - prisma?: Pick; + prisma?: Pick; locale?: "de" | "en"; } @@ -227,14 +227,16 @@ export async function refreshConversationSummary( // 6. Encrypt + persist. Field-by-field data object (no spread). const summaryEncrypted = encryptToBytes(text); - await prisma.coachConversation.update({ - where: { id: conversationId }, - data: { - summaryEncrypted, - summaryUpdatedAt: now, - summaryTurnCount: foldHighWater, - }, - }); + // Raw SQL on purpose: a Prisma `update` stamps `updatedAt`, and the Coach + // panel orders and groups conversations by it. A memory rewrite is + // bookkeeping, not activity, so it must not move the thread to "Today". + // Every value is a bound parameter. + await prisma.$executeRaw` + UPDATE "coach_conversations" + SET "summary_encrypted" = ${summaryEncrypted}, + "summary_updated_at" = ${now}, + "summary_turn_count" = ${foldHighWater} + WHERE "id" = ${conversationId}`; // 7. Counts + ids only — never the summary text. annotate({ diff --git a/src/lib/jobs/__tests__/free-text-encryption-backfill.test.ts b/src/lib/jobs/__tests__/free-text-encryption-backfill.test.ts index c75aa4b8e..045e171c4 100644 --- a/src/lib/jobs/__tests__/free-text-encryption-backfill.test.ts +++ b/src/lib/jobs/__tests__/free-text-encryption-backfill.test.ts @@ -12,6 +12,7 @@ interface ConversationRow { userId: string; title: string | null; titleEncrypted: Uint8Array | null; + updatedAt?: Date; } interface EntryRow { id: string; @@ -26,6 +27,7 @@ interface PractitionerRow { location: string | null; phoneEncrypted: Uint8Array | null; locationEncrypted: Uint8Array | null; + updatedAt?: Date; } interface RouteRow { id: string; @@ -67,22 +69,49 @@ vi.mock("@/lib/db", () => { }, findUnique: async (args: { where: { id: string } }) => rows().find((x) => x.id === args.where.id) ?? null, - update: async (args: { where: { id: string }; data: Partial }) => { - const r = rows().find((x) => x.id === args.where.id)!; - // Prisma's DbNull sentinel stores SQL NULL. - const data = Object.fromEntries( - Object.entries(args.data).map(([k, v]) => [ - k, - v !== null && typeof v === "object" && !(v instanceof Uint8Array) - ? null - : v, - ]), - ); - Object.assign(r, data); - return r; + update: async (args: { where: { id: string }; data: Partial }) => + write(rows, args.where.id, args.data), + // The storage-only rewrites match on the row's own `updatedAt` and + // carry it forward; a row whose stamp moved meanwhile is not written. + updateMany: async (args: { + where: { id: string; updatedAt?: Date }; + data: Partial; + }) => { + const r = rows().find((x) => x.id === args.where.id) as + (R & { updatedAt?: Date }) | undefined; + if (!r) return { count: 0 }; + if ( + args.where.updatedAt !== undefined && + r.updatedAt?.getTime() !== args.where.updatedAt?.getTime() + ) { + return { count: 0 }; + } + write(rows, args.where.id, args.data); + return { count: 1 }; }, }; } + function write( + rows: () => R[], + id: string, + patch: Partial, + ): R { + const r = rows().find((x) => x.id === id)!; + // Prisma's DbNull sentinel stores SQL NULL; a Date is a real value. + const data = Object.fromEntries( + Object.entries(patch).map(([k, v]) => [ + k, + v !== null && + typeof v === "object" && + !(v instanceof Uint8Array) && + !(v instanceof Date) + ? null + : v, + ]), + ); + Object.assign(r, data); + return r; + } const delegates = { coachConversation: delegate(() => store.conversations, ["title"]), customMetricEntry: delegate(() => store.entries, ["note"]), @@ -126,6 +155,7 @@ beforeEach(() => { userId: "u1", title: "Why is my pressure up after the new tablets?", titleEncrypted: null, + updatedAt: new Date("2026-03-02T09:15:00.000Z"), }, // Already migrated: left alone. { @@ -218,6 +248,9 @@ describe("runFreeTextEncryptionBackfillForUser", () => { expect(readNote(c1.titleEncrypted, null)).toBe( "Why is my pressure up after the new tablets?", ); + // Sealing a title is not activity: the Coach panel orders and groups by + // updatedAt, so the conversation keeps the stamp it had. + expect(c1.updatedAt).toEqual(new Date("2026-03-02T09:15:00.000Z")); const e1 = store.entries.find((r) => r.id === "e1")!; expect(e1.note).toBeNull(); diff --git a/src/lib/jobs/__tests__/med-notes-encryption-backfill.test.ts b/src/lib/jobs/__tests__/med-notes-encryption-backfill.test.ts index 23f5a9119..275c52677 100644 --- a/src/lib/jobs/__tests__/med-notes-encryption-backfill.test.ts +++ b/src/lib/jobs/__tests__/med-notes-encryption-backfill.test.ts @@ -20,6 +20,7 @@ interface InventoryRow { userId: string; notes: string | null; notesEncrypted: Uint8Array | null; + updatedAt?: Date; } const store = vi.hoisted(() => ({ @@ -99,16 +100,31 @@ vi.mock("@/lib/db", () => { .map((r) => ({ id: r.id })), findUnique: async (args: { where: { id: string } }) => { const r = store.inventory.find((x) => x.id === args.where.id); - return r ? { notes: r.notes, notesEncrypted: r.notesEncrypted } : null; + return r + ? { + notes: r.notes, + notesEncrypted: r.notesEncrypted, + updatedAt: r.updatedAt, + } + : null; }, - update: async (args: { - where: { id: string }; - data: { notes: string | null; notesEncrypted: Uint8Array | null }; + updateMany: async (args: { + where: { id: string; updatedAt?: Date }; + data: { + notes: string | null; + notesEncrypted: Uint8Array | null; + updatedAt?: Date; + }; }) => { - const r = store.inventory.find((x) => x.id === args.where.id)!; + const r = store.inventory.find((x) => x.id === args.where.id); + if (!r || r.updatedAt?.getTime() !== args.where.updatedAt?.getTime()) { + return { count: 0 }; + } r.notes = args.data.notes; r.notesEncrypted = args.data.notesEncrypted; - return r; + // Prisma stamps now unless the write names the column. + r.updatedAt = args.data.updatedAt ?? new Date(); + return { count: 1 }; }, }, }; @@ -148,7 +164,13 @@ beforeEach(() => { }, ]; store.inventory = [ - { id: "i1", userId: "u1", notes: "opened pen #2", notesEncrypted: null }, + { + id: "i1", + userId: "u1", + notes: "opened pen #2", + notesEncrypted: null, + updatedAt: new Date("2026-01-12T10:00:00.000Z"), + }, { id: "i2", userId: "u1", @@ -181,6 +203,8 @@ describe("runMedNotesEncryptionBackfillForUser", () => { const i1 = store.inventory.find((r) => r.id === "i1")!; expect(i1.notes).toBeNull(); expect(readNote(i1.notesEncrypted, null)).toBe("opened pen #2"); + // A storage-only rewrite keeps the row's own updatedAt. + expect(i1.updatedAt).toEqual(new Date("2026-01-12T10:00:00.000Z")); }); it("leaves note-less rows untouched and never writes a both-null content row", async () => { diff --git a/src/lib/jobs/__tests__/note-encryption-backfill.test.ts b/src/lib/jobs/__tests__/note-encryption-backfill.test.ts index eba8c418d..12996db17 100644 --- a/src/lib/jobs/__tests__/note-encryption-backfill.test.ts +++ b/src/lib/jobs/__tests__/note-encryption-backfill.test.ts @@ -7,12 +7,14 @@ interface MeasRow { userId: string; notes: string | null; notesEncrypted: Uint8Array | null; + updatedAt?: Date; } interface MoodRow { id: string; userId: string; note: string | null; noteEncrypted: Uint8Array | null; + updatedAt?: Date; } const store = vi.hoisted(() => ({ @@ -44,16 +46,31 @@ vi.mock("@/lib/db", () => { .map((r) => ({ id: r.id })), findUnique: async (args: { where: { id: string } }) => { const r = store.measurements.find((x) => x.id === args.where.id); - return r ? { notes: r.notes, notesEncrypted: r.notesEncrypted } : null; + return r + ? { + notes: r.notes, + notesEncrypted: r.notesEncrypted, + updatedAt: r.updatedAt, + } + : null; }, - update: async (args: { - where: { id: string }; - data: { notes: string | null; notesEncrypted: Uint8Array | null }; + updateMany: async (args: { + where: { id: string; updatedAt?: Date }; + data: { + notes: string | null; + notesEncrypted: Uint8Array | null; + updatedAt?: Date; + }; }) => { - const r = store.measurements.find((x) => x.id === args.where.id)!; + const r = store.measurements.find((x) => x.id === args.where.id); + if (!r || r.updatedAt?.getTime() !== args.where.updatedAt?.getTime()) { + return { count: 0 }; + } r.notes = args.data.notes; r.notesEncrypted = args.data.notesEncrypted; - return r; + // Prisma stamps now unless the write names the column. + r.updatedAt = args.data.updatedAt ?? new Date(); + return { count: 1 }; }, }, moodEntry: { @@ -69,16 +86,30 @@ vi.mock("@/lib/db", () => { .map((r) => ({ id: r.id })), findUnique: async (args: { where: { id: string } }) => { const r = store.mood.find((x) => x.id === args.where.id); - return r ? { note: r.note, noteEncrypted: r.noteEncrypted } : null; + return r + ? { + note: r.note, + noteEncrypted: r.noteEncrypted, + updatedAt: r.updatedAt, + } + : null; }, - update: async (args: { - where: { id: string }; - data: { note: string | null; noteEncrypted: Uint8Array | null }; + updateMany: async (args: { + where: { id: string; updatedAt?: Date }; + data: { + note: string | null; + noteEncrypted: Uint8Array | null; + updatedAt?: Date; + }; }) => { - const r = store.mood.find((x) => x.id === args.where.id)!; + const r = store.mood.find((x) => x.id === args.where.id); + if (!r || r.updatedAt?.getTime() !== args.where.updatedAt?.getTime()) { + return { count: 0 }; + } r.note = args.data.note; r.noteEncrypted = args.data.noteEncrypted; - return r; + r.updatedAt = args.data.updatedAt ?? new Date(); + return { count: 1 }; }, }, }; @@ -99,7 +130,13 @@ beforeEach(() => { vi.stubEnv("ENCRYPTION_ACTIVE_KEY_ID", ""); vi.stubEnv("ENCRYPTION_KEY", KEY); store.measurements = [ - { id: "m1", userId: "u1", notes: "felt dizzy", notesEncrypted: null }, + { + id: "m1", + userId: "u1", + notes: "felt dizzy", + notesEncrypted: null, + updatedAt: new Date("2026-02-01T07:00:00.000Z"), + }, { id: "m2", userId: "u1", notes: null, notesEncrypted: null }, // no note { id: "m3", @@ -109,7 +146,13 @@ beforeEach(() => { }, ]; store.mood = [ - { id: "x1", userId: "u1", note: "rough night", noteEncrypted: null }, + { + id: "x1", + userId: "u1", + note: "rough night", + noteEncrypted: null, + updatedAt: new Date("2026-02-03T22:00:00.000Z"), + }, { id: "x2", userId: "u2", note: "other user", noteEncrypted: null }, ]; fail.encryptThrows = false; @@ -135,6 +178,10 @@ describe("runNoteEncryptionBackfillForUser", () => { const x1 = store.mood.find((r) => r.id === "x1")!; expect(x1.note).toBeNull(); expect(readNote(x1.noteEncrypted, null)).toBe("rough night"); + + // updatedAt is the sync cursor: a storage-only rewrite keeps it. + expect(m1.updatedAt).toEqual(new Date("2026-02-01T07:00:00.000Z")); + expect(x1.updatedAt).toEqual(new Date("2026-02-03T22:00:00.000Z")); }); it("leaves note-less rows untouched and never writes a both-null content row", async () => { diff --git a/src/lib/jobs/free-text-encryption-backfill.ts b/src/lib/jobs/free-text-encryption-backfill.ts index 60d769060..b375360c6 100644 --- a/src/lib/jobs/free-text-encryption-backfill.ts +++ b/src/lib/jobs/free-text-encryption-backfill.ts @@ -183,15 +183,23 @@ async function migrateConversationTitle(id: string): Promise { return prisma.$transaction(async (tx) => { const fresh = await tx.coachConversation.findUnique({ where: { id }, - select: { title: true }, + select: { title: true, updatedAt: true }, }); if (!fresh || fresh.title === null) return false; // FAIL-CLOSED: a key error throws here and rolls the tx back. - await tx.coachConversation.update({ - where: { id }, - data: { titleEncrypted: encryptToBytes(fresh.title), title: null }, + // `updatedAt` is carried, not stamped: the Coach panel orders and groups + // by it, and moving a title into ciphertext is not activity. Matching on + // it too means a turn that lands meanwhile wins; the row is retried on + // the next pass. + const { count } = await tx.coachConversation.updateMany({ + where: { id, updatedAt: fresh.updatedAt }, + data: { + titleEncrypted: encryptToBytes(fresh.title), + title: null, + updatedAt: fresh.updatedAt, + }, }); - return true; + return count === 1; }); } @@ -224,14 +232,16 @@ async function migratePractitionerContact(id: string): Promise { return prisma.$transaction(async (tx) => { const fresh = await tx.practitioner.findUnique({ where: { id }, - select: { phone: true, location: true }, + select: { phone: true, location: true, updatedAt: true }, }); if (!fresh || (fresh.phone === null && fresh.location === null)) { return false; } - await tx.practitioner.update({ - where: { id }, + // Storage-only rewrite: the row keeps its own `updatedAt`. + const { count } = await tx.practitioner.updateMany({ + where: { id, updatedAt: fresh.updatedAt }, data: { + updatedAt: fresh.updatedAt, ...(fresh.phone !== null ? { phoneEncrypted: encryptNote(fresh.phone), phone: null } : {}), @@ -240,7 +250,7 @@ async function migratePractitionerContact(id: string): Promise { : {}), }, }); - return true; + return count === 1; }); } diff --git a/src/lib/jobs/med-notes-encryption-backfill.ts b/src/lib/jobs/med-notes-encryption-backfill.ts index a6cd35e29..05151b84e 100644 --- a/src/lib/jobs/med-notes-encryption-backfill.ts +++ b/src/lib/jobs/med-notes-encryption-backfill.ts @@ -120,15 +120,20 @@ async function migrateInventoryItemRow(id: string): Promise { return prisma.$transaction(async (tx) => { const fresh = await tx.medicationInventoryItem.findUnique({ where: { id }, - select: { notes: true, notesEncrypted: true }, + select: { notes: true, notesEncrypted: true, updatedAt: true }, }); if (!fresh || fresh.notesEncrypted || fresh.notes === null) return false; const encrypted = encryptNote(fresh.notes); - await tx.medicationInventoryItem.update({ - where: { id }, - data: { notesEncrypted: encrypted, notes: null }, + // Storage-only rewrite: the row keeps its own `updatedAt`. + const { count } = await tx.medicationInventoryItem.updateMany({ + where: { id, updatedAt: fresh.updatedAt }, + data: { + notesEncrypted: encrypted, + notes: null, + updatedAt: fresh.updatedAt, + }, }); - return true; + return count === 1; }); } diff --git a/src/lib/jobs/note-encryption-backfill.ts b/src/lib/jobs/note-encryption-backfill.ts index 2b018862f..e677e29a1 100644 --- a/src/lib/jobs/note-encryption-backfill.ts +++ b/src/lib/jobs/note-encryption-backfill.ts @@ -68,18 +68,24 @@ async function migrateMeasurementRow(id: string): Promise { return prisma.$transaction(async (tx) => { const fresh = await tx.measurement.findUnique({ where: { id }, - select: { notes: true, notesEncrypted: true }, + select: { notes: true, notesEncrypted: true, updatedAt: true }, }); // Already migrated (ciphertext present) or nothing to migrate. if (!fresh || fresh.notesEncrypted || fresh.notes === null) return false; // FAIL-CLOSED: a key error throws here and rolls the tx back, leaving the // plaintext row untouched. const encrypted = encryptNote(fresh.notes); - await tx.measurement.update({ - where: { id }, - data: { notesEncrypted: encrypted, notes: null }, + // `updatedAt` is the sync cursor; a storage-only rewrite carries it so no + // client re-pulls a reading that did not change. + const { count } = await tx.measurement.updateMany({ + where: { id, updatedAt: fresh.updatedAt }, + data: { + notesEncrypted: encrypted, + notes: null, + updatedAt: fresh.updatedAt, + }, }); - return true; + return count === 1; }); } @@ -91,15 +97,19 @@ async function migrateMoodRow(id: string): Promise { return prisma.$transaction(async (tx) => { const fresh = await tx.moodEntry.findUnique({ where: { id }, - select: { note: true, noteEncrypted: true }, + select: { note: true, noteEncrypted: true, updatedAt: true }, }); if (!fresh || fresh.noteEncrypted || fresh.note === null) return false; const encrypted = encryptNote(fresh.note); - await tx.moodEntry.update({ - where: { id }, - data: { noteEncrypted: encrypted, note: null }, + const { count } = await tx.moodEntry.updateMany({ + where: { id, updatedAt: fresh.updatedAt }, + data: { + noteEncrypted: encrypted, + note: null, + updatedAt: fresh.updatedAt, + }, }); - return true; + return count === 1; }); } diff --git a/tests/integration/coach-summary-keeps-updated-at.test.ts b/tests/integration/coach-summary-keeps-updated-at.test.ts new file mode 100644 index 000000000..c6d70f7b8 --- /dev/null +++ b/tests/integration/coach-summary-keeps-updated-at.test.ts @@ -0,0 +1,77 @@ +/** + * The Coach memory summary is bookkeeping, not activity (real Postgres). + * + * The Coach panel orders and groups conversations by `updatedAt`. The summary + * refresh rewrites a conversation row in the background, and as a Prisma + * `update` it stamped `updatedAt` with the run time, so a thread nobody had + * touched could surface under "Today". The write now goes through raw SQL; + * this pins that the summary lands (the ciphertext binds as `bytea`) and that + * the stamp stays where the last turn left it. + */ +import { beforeEach, describe, expect, it } from "vitest"; + +import { decryptFromBytes, encryptToBytes } from "@/lib/ai/coach/bytes-codec"; +import { refreshConversationSummary } from "@/lib/ai/coach/conversation-summary"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +const USER = "user-coach-summary"; +const LAST_TURN = new Date("2026-04-10T19:30:00.000Z"); + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + await getPrismaClient().user.create({ + data: { + id: USER, + username: "coach-summary", + email: "coach-summary@example.test", + timezone: "Europe/Berlin", + }, + }); +}); + +describe("coach memory summary", () => { + it("writes the summary and leaves updatedAt alone", async () => { + const prisma = getPrismaClient(); + const conversation = await prisma.coachConversation.create({ + data: { + userId: USER, + titleEncrypted: encryptToBytes("Training plan"), + }, + }); + await prisma.coachMessage.createMany({ + data: Array.from({ length: 30 }, (_, i) => ({ + conversationId: conversation.id, + role: i % 2 === 0 ? "user" : "assistant", + encryptedContent: encryptToBytes(`turn ${i}`), + createdAt: new Date(LAST_TURN.getTime() - (30 - i) * 60_000), + })), + }); + // Created rows stamp now; set the stamp a last turn would have left. + await prisma.$executeRaw` + UPDATE "coach_conversations" SET "updated_at" = ${LAST_TURN} + WHERE "id" = ${conversation.id}`; + + const now = new Date("2026-10-05T06:00:00.000Z"); + const result = await refreshConversationSummary(conversation.id, USER, { + now, + runCompletion: async () => ({ + kind: "ok", + content: "Training for a 10k, prefers morning runs.", + providerType: "admin-openai", + model: "test", + tokensUsed: 10, + }), + }); + expect(result.status).toBe("generated"); + + const row = await prisma.coachConversation.findUniqueOrThrow({ + where: { id: conversation.id }, + }); + expect(decryptFromBytes(row.summaryEncrypted!)).toBe( + "Training for a 10k, prefers morning runs.", + ); + expect(row.summaryUpdatedAt).toEqual(now); + expect(row.summaryTurnCount).toBe(12); + expect(row.updatedAt).toEqual(LAST_TURN); + }); +}); diff --git a/tests/integration/free-text-encryption-backfill.test.ts b/tests/integration/free-text-encryption-backfill.test.ts index 5b76a751f..761a7a32b 100644 --- a/tests/integration/free-text-encryption-backfill.test.ts +++ b/tests/integration/free-text-encryption-backfill.test.ts @@ -71,8 +71,15 @@ describe("free-text encryption backfill (real Postgres)", () => { await Promise.all([seedUser("ft-a"), seedUser("ft-b"), seedUser("ft-c")]); // ft-a: a legacy readable title and a sealed one. + // Last active months ago: the backfill must not make it look recent. + const lastActive = new Date("2026-03-02T09:15:00.000Z"); await prisma.coachConversation.create({ - data: { id: "conv-legacy", userId: "ft-a", title: "Legacy title" }, + data: { + id: "conv-legacy", + userId: "ft-a", + title: "Legacy title", + updatedAt: lastActive, + }, }); await prisma.coachConversation.create({ data: { @@ -150,6 +157,9 @@ describe("free-text encryption backfill (real Postgres)", () => { { id: "conv-legacy", readable: null, text: "Legacy title" }, { id: "conv-sealed", readable: null, text: "Sealed title" }, ]); + // The Coach panel orders and groups by updatedAt; sealing a title is not + // activity, so the stamp is the one the conversation already had. + expect(conversations[0].updatedAt).toEqual(lastActive); const entries = await prisma.customMetricEntry.findMany({ where: { userId: "ft-b" }, orderBy: { id: "asc" }, diff --git a/tests/integration/note-encryption-backfill.test.ts b/tests/integration/note-encryption-backfill.test.ts index 0246c8e17..8a696b1f1 100644 --- a/tests/integration/note-encryption-backfill.test.ts +++ b/tests/integration/note-encryption-backfill.test.ts @@ -41,6 +41,7 @@ describe("note-encryption backfill (real Postgres)", () => { unit: "kg", measuredAt: new Date("2026-05-16T08:00:00.000Z"), notes: "felt dizzy after", + updatedAt: new Date("2026-05-16T08:05:00.000Z"), }, }); const noNote = await prisma.measurement.create({ @@ -62,6 +63,7 @@ describe("note-encryption backfill (real Postgres)", () => { score: 3, moodLoggedAt: new Date("2026-05-16T08:00:00.000Z"), note: "rough night", + updatedAt: new Date("2026-05-16T08:06:00.000Z"), }, }); @@ -85,6 +87,11 @@ describe("note-encryption backfill (real Postgres)", () => { const x = await prisma.moodEntry.findUnique({ where: { id: mood.id } }); expect(x?.note).toBeNull(); expect(readNote(x?.noteEncrypted ?? null, null)).toBe("rough night"); + + // updatedAt is the sync cursor: moving a note into ciphertext changes + // nothing a client reads, so neither row takes a new stamp. + expect(m?.updatedAt).toEqual(new Date("2026-05-16T08:05:00.000Z")); + expect(x?.updatedAt).toEqual(new Date("2026-05-16T08:06:00.000Z")); }); it("is idempotent: a second run migrates zero rows and preserves the data", async () => {