fix(coach): move bumped conversation timestamps back to the last message

Earlier rewrites of conversation rows stamped updated_at with the run time,
so the Coach panel listed untouched conversations under Today. Migration
0375 moves updated_at back to the newest message's created_at where it is
more than a minute later. A rename or attachment shortly after the last
turn keeps its stamp, conversations without messages are left alone, and a
second run changes nothing. One set-based statement; the per-conversation
maximum reads from the (conversation_id, created_at) index.
This commit is contained in:
Marc-André Bombeck committed 2026-10-05 12:53:40 +02:00
1 parent 370fa10efa
commit 7836610358
2 files changed
+138

No files matched your search

@@ -0,0 +1,26 @@
-- Move a Coach conversation's updated_at back to its newest message.
--
-- The free-text encryption backfill (v1.39.3) and the memory-summary refresh
-- rewrote conversation rows through a Prisma update, which stamped updated_at
-- with the run time. The Coach panel orders and groups by that column, so
-- conversations nobody had touched in months showed up under "Today".
--
-- A turn stamps updated_at in the same transaction that creates its message,
-- so the newest message's created_at is the conversation's real last
-- activity. Only rows more than a minute later than that move back; a rename
-- or a document attached shortly after the last turn keeps its stamp, and a
-- conversation without messages is left alone. Running it again changes
-- nothing: a repaired row is no longer later than its newest message.
--
-- One set-based statement. The per-conversation maximum is read from
-- "coach_messages_conversation_id_created_at_idx" (conversation_id,
-- created_at), so it does not sort the message table.
UPDATE "coach_conversations" AS c
SET "updated_at" = m."last_at"
FROM (
SELECT "conversation_id", MAX("created_at") AS "last_at"
FROM "coach_messages"
GROUP BY "conversation_id"
) AS m
WHERE m."conversation_id" = c."id"
AND c."updated_at" > m."last_at" + INTERVAL '1 minute';
@@ -0,0 +1,112 @@
/**
* Migration 0375 moves a Coach conversation's updated_at back to its newest
* message (real Postgres).
*
* The migration has already run on the empty test database when the
* container booted, so the test seeds the shapes it has to tell apart and
* runs the same SQL file again: a conversation a backfill bumped, one renamed
* within a minute of its last turn, one without messages, and a second pass
* that must change nothing.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { beforeEach, describe, expect, it } from "vitest";
import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
import { getPrismaClient, truncateAllTables } from "./setup";
const REPAIR_SQL = readFileSync(
join(
process.cwd(),
"prisma/migrations/0375_coach_conversation_updated_at_repair/migration.sql",
),
"utf8",
);
const USER = "user-coach-updated-at-repair";
const LAST_TURN = new Date("2026-03-02T09:15:00.000Z");
const BACKFILL_RUN = new Date("2026-10-01T04:00:00.000Z");
const RENAMED = new Date("2026-03-02T09:15:40.000Z");
const EMPTY_STAMP = new Date("2026-09-30T12:00:00.000Z");
async function setUpdatedAt(id: string, at: Date) {
// A Prisma update would stamp now; set the column as the history left it.
await getPrismaClient().$executeRaw`
UPDATE "coach_conversations" SET "updated_at" = ${at} WHERE "id" = ${id}`;
}
async function stamps(): Promise<Record<string, string>> {
const rows = await getPrismaClient().coachConversation.findMany({
where: { userId: USER },
select: { id: true, updatedAt: true },
});
return Object.fromEntries(rows.map((r) => [r.id, r.updatedAt.toISOString()]));
}
beforeEach(async () => {
const prisma = getPrismaClient();
await truncateAllTables(prisma);
await prisma.user.create({
data: {
id: USER,
username: "coach-repair",
email: "coach-repair@example.test",
timezone: "Europe/Berlin",
},
});
for (const id of ["bumped", "renamed", "empty"]) {
await prisma.coachConversation.create({
data: { id, userId: USER, titleEncrypted: encryptToBytes(id) },
});
}
for (const id of ["bumped", "renamed"]) {
await prisma.coachMessage.createMany({
data: [
{
conversationId: id,
role: "user",
encryptedContent: encryptToBytes("question"),
createdAt: new Date(LAST_TURN.getTime() - 30_000),
},
{
conversationId: id,
role: "assistant",
encryptedContent: encryptToBytes("answer"),
createdAt: LAST_TURN,
},
],
});
}
await setUpdatedAt("bumped", BACKFILL_RUN);
await setUpdatedAt("renamed", RENAMED);
await setUpdatedAt("empty", EMPTY_STAMP);
});
describe("migration 0375 — coach conversation updated_at repair", () => {
it("moves a bumped conversation back and leaves the others alone", async () => {
const changed = await getPrismaClient().$executeRawUnsafe(REPAIR_SQL);
expect(changed).toBe(1);
expect(await stamps()).toEqual({
bumped: LAST_TURN.toISOString(),
renamed: RENAMED.toISOString(),
empty: EMPTY_STAMP.toISOString(),
});
});
it("changes nothing on a second run", async () => {
const prisma = getPrismaClient();
await prisma.$executeRawUnsafe(REPAIR_SQL);
const after = await stamps();
expect(await prisma.$executeRawUnsafe(REPAIR_SQL)).toBe(0);
expect(await stamps()).toEqual(after);
});
it("reads the newest message through the conversation index", async () => {
const rows = await getPrismaClient().$queryRaw<{ indexdef: string }[]>`
SELECT indexdef FROM pg_indexes
WHERE tablename = 'coach_messages'
AND indexname = 'coach_messages_conversation_id_created_at_idx'`;
expect(rows).toHaveLength(1);
expect(rows[0].indexdef).toMatch(/\(conversation_id, created_at\)/);
});
});