mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
Merge the Coach conversation delete fix into release/v1.42.0
This commit is contained in:
commit
4c58274bed
13 files changed
+883
-102
No files matched your search
@@ -0,0 +1,188 @@
|
||||
import { expect, test } from "./setup/test";
|
||||
import type { Route } from "@playwright/test";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { setTimeout as delay } from "node:timers/promises";
|
||||
import { hash } from "@node-rs/argon2";
|
||||
import pg from "pg";
|
||||
|
||||
import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
import { aiBlockAvailable, serveAiBlock } from "./setup/ai-capabilities";
|
||||
|
||||
const KEEP_ID = "conversation-delete-keep";
|
||||
const DROP_ID = "conversation-delete-drop";
|
||||
|
||||
function row(id: string, title: string) {
|
||||
return {
|
||||
id,
|
||||
title,
|
||||
createdAt: "2026-07-20T10:00:00.000Z",
|
||||
updatedAt: "2026-07-20T10:00:00.000Z",
|
||||
messageCount: 2,
|
||||
fenced: false,
|
||||
attachments: [],
|
||||
documentTitle: null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A deleted conversation must stay deleted. The delete waits out an undo
|
||||
* window before it is sent; a reload inside that window used to drop it, and
|
||||
* once the window ran out the row showed again until the refetch landed.
|
||||
*/
|
||||
test.describe("Coach conversation delete", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await serveAiBlock(page, aiBlockAvailable());
|
||||
});
|
||||
|
||||
test.use({ storageState: STORAGE_STATE_PATH });
|
||||
|
||||
async function serveConversations(
|
||||
page: import("@playwright/test").Page,
|
||||
deleteDelayMs: number,
|
||||
) {
|
||||
const state = {
|
||||
rows: [row(KEEP_ID, "Kept thread"), row(DROP_ID, "Dropped thread")],
|
||||
deletes: [] as string[],
|
||||
};
|
||||
// Context-level, so a `keepalive` request sent while the page unloads is
|
||||
// still answered here.
|
||||
await page
|
||||
.context()
|
||||
.route(
|
||||
/\/api\/insights\/chat(?:\/[^?]+)?(?:\?|$)/,
|
||||
async (route: Route) => {
|
||||
const request = route.request();
|
||||
const url = new URL(request.url());
|
||||
if (request.method() === "DELETE") {
|
||||
const id = decodeURIComponent(url.pathname.split("/").pop() ?? "");
|
||||
state.deletes.push(id);
|
||||
state.rows = state.rows.filter((r) => r.id !== id);
|
||||
if (deleteDelayMs > 0) await delay(deleteDelayMs);
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ data: { deleted: true }, error: null }),
|
||||
});
|
||||
}
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
data: { conversations: state.rows, nextCursor: null },
|
||||
error: null,
|
||||
}),
|
||||
});
|
||||
},
|
||||
);
|
||||
return state;
|
||||
}
|
||||
|
||||
test("the row does not come back when the window runs out", async ({
|
||||
page,
|
||||
}) => {
|
||||
// A slow DELETE holds the refetch back, which is when the row used to
|
||||
// reappear.
|
||||
const state = await serveConversations(page, 2_000);
|
||||
await page.goto("/coach/conversations", { waitUntil: "domcontentloaded" });
|
||||
|
||||
const items = page.locator('[data-slot="coach-conversations-item"]');
|
||||
await expect(items).toHaveCount(2, { timeout: 10_000 });
|
||||
await items
|
||||
.filter({ hasText: "Dropped thread" })
|
||||
.locator('[data-slot="coach-conversations-delete"]')
|
||||
.click();
|
||||
await expect(items).toHaveCount(1);
|
||||
|
||||
await expect
|
||||
.poll(() => state.deletes, { timeout: 15_000 })
|
||||
.toEqual([DROP_ID]);
|
||||
// Sampled while the DELETE is still in flight.
|
||||
for (let i = 0; i < 6; i += 1) {
|
||||
expect(await items.count()).toBe(1);
|
||||
await delay(250);
|
||||
}
|
||||
await expect(items).toContainText("Kept thread");
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The real server and database, not a mock: the request that matters here is
|
||||
* sent with `keepalive` while the page unloads, which Playwright's routing
|
||||
* never sees. The account is the spec's own, so the conversation it seeds
|
||||
* cannot surface in another spec's Coach.
|
||||
*/
|
||||
test.describe("Coach conversation delete against the database", () => {
|
||||
test.use({ storageState: { cookies: [], origins: [] } });
|
||||
|
||||
test("a reload inside the undo window still deletes", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const dbUrl = process.env.DATABASE_URL;
|
||||
test.skip(!dbUrl, "DATABASE_URL is required to seed the conversation");
|
||||
|
||||
const suffix = `${testInfo.project.name}-${randomBytes(4).toString("hex")}`;
|
||||
const username = `e2e-coach-delete-${suffix}`;
|
||||
const password = "Cd5!Vq8nTz3wLp7K";
|
||||
const userId = `e2e-coach-delete-user-${suffix}`;
|
||||
const conversationId = `e2e-coach-delete-${suffix}`;
|
||||
const pool = new pg.Pool({ connectionString: dbUrl });
|
||||
try {
|
||||
await pool.query(
|
||||
`INSERT INTO users
|
||||
(id, username, email, password_hash, role, created_at, updated_at,
|
||||
onboarding_completed_at, onboarding_tour_completed)
|
||||
VALUES ($1, $2, $3, $4, 'USER', now(), now(), now(), true)`,
|
||||
[
|
||||
userId,
|
||||
username,
|
||||
`${username}@healthlog.test`,
|
||||
await hash(password, {
|
||||
memoryCost: 19456,
|
||||
timeCost: 2,
|
||||
outputLen: 32,
|
||||
parallelism: 1,
|
||||
}),
|
||||
],
|
||||
);
|
||||
// A legacy readable title keeps the seed free of the encryption key.
|
||||
await pool.query(
|
||||
`INSERT INTO coach_conversations (id, user_id, title, updated_at)
|
||||
VALUES ($1, $2, 'Dropped thread', now())`,
|
||||
[conversationId, userId],
|
||||
);
|
||||
|
||||
const login = await page.request.post("/api/auth/login", {
|
||||
data: { email: username, password },
|
||||
});
|
||||
expect(login.status()).toBe(200);
|
||||
|
||||
await page.goto("/coach/conversations", {
|
||||
waitUntil: "domcontentloaded",
|
||||
});
|
||||
const items = page.locator('[data-slot="coach-conversations-item"]');
|
||||
await expect(items).toHaveCount(1, { timeout: 15_000 });
|
||||
await items.locator('[data-slot="coach-conversations-delete"]').click();
|
||||
await expect(items).toHaveCount(0);
|
||||
|
||||
// Well inside the undo window.
|
||||
await page.reload({ waitUntil: "domcontentloaded" });
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const { rowCount } = await pool.query(
|
||||
"SELECT 1 FROM coach_conversations WHERE id = $1",
|
||||
[conversationId],
|
||||
);
|
||||
return rowCount;
|
||||
})
|
||||
.toBe(0);
|
||||
await expect(
|
||||
page.locator('[data-slot="coach-conversations-empty"]'),
|
||||
).toBeVisible({ timeout: 15_000 });
|
||||
await expect(items).toHaveCount(0);
|
||||
} finally {
|
||||
await pool.query("DELETE FROM users WHERE id = $1", [userId]);
|
||||
await pool.end();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -2909,6 +2909,7 @@
|
||||
"historyEmpty": "Deine Unterhaltungen erscheinen hier. Stell eine Frage, um zu starten.",
|
||||
"historyDeleteAria": "Unterhaltung löschen",
|
||||
"historyDeleted": "Unterhaltung gelöscht",
|
||||
"historyDeleteFailed": "Die Unterhaltung konnte nicht gelöscht werden.",
|
||||
"rename": {
|
||||
"action": "Unterhaltung umbenennen",
|
||||
"input": "Titel der Unterhaltung",
|
||||
|
||||
@@ -2909,6 +2909,7 @@
|
||||
"historyEmpty": "Your conversations will appear here. Ask anything to start.",
|
||||
"historyDeleteAria": "Delete conversation",
|
||||
"historyDeleted": "Conversation deleted",
|
||||
"historyDeleteFailed": "The conversation could not be deleted.",
|
||||
"rename": {
|
||||
"action": "Rename conversation",
|
||||
"input": "Conversation title",
|
||||
|
||||
@@ -2909,6 +2909,7 @@
|
||||
"historyEmpty": "Tus conversaciones aparecen aquí. Lanza una pregunta para empezar.",
|
||||
"historyDeleteAria": "Eliminar conversación",
|
||||
"historyDeleted": "Conversación eliminada",
|
||||
"historyDeleteFailed": "No se pudo eliminar la conversación.",
|
||||
"rename": {
|
||||
"action": "Renombrar conversación",
|
||||
"input": "Título de la conversación",
|
||||
|
||||
@@ -2909,6 +2909,7 @@
|
||||
"historyEmpty": "Tes conversations s’affichent ici. Lance une question pour commencer.",
|
||||
"historyDeleteAria": "Supprimer la conversation",
|
||||
"historyDeleted": "Conversation supprimée",
|
||||
"historyDeleteFailed": "La conversation n’a pas pu être supprimée.",
|
||||
"rename": {
|
||||
"action": "Renommer la conversation",
|
||||
"input": "Titre de la conversation",
|
||||
|
||||
@@ -2909,6 +2909,7 @@
|
||||
"historyEmpty": "Le tue conversazioni compaiono qui. Fai una domanda per iniziare.",
|
||||
"historyDeleteAria": "Elimina conversazione",
|
||||
"historyDeleted": "Conversazione eliminata",
|
||||
"historyDeleteFailed": "Non è stato possibile eliminare la conversazione.",
|
||||
"rename": {
|
||||
"action": "Rinomina conversazione",
|
||||
"input": "Titolo della conversazione",
|
||||
|
||||
@@ -2909,6 +2909,7 @@
|
||||
"historyEmpty": "대화가 여기에 표시돼요. 무엇이든 물어보며 시작하세요.",
|
||||
"historyDeleteAria": "대화 삭제",
|
||||
"historyDeleted": "대화를 삭제했어요",
|
||||
"historyDeleteFailed": "대화를 삭제하지 못했어요.",
|
||||
"rename": {
|
||||
"action": "대화 이름 바꾸기",
|
||||
"input": "대화 제목",
|
||||
|
||||
@@ -2909,6 +2909,7 @@
|
||||
"historyEmpty": "Twoje rozmowy pojawią się tutaj. Zadaj pytanie, aby zacząć.",
|
||||
"historyDeleteAria": "Usuń rozmowę",
|
||||
"historyDeleted": "Rozmowa usunięta",
|
||||
"historyDeleteFailed": "Nie udało się usunąć rozmowy.",
|
||||
"rename": {
|
||||
"action": "Zmień nazwę rozmowy",
|
||||
"input": "Tytuł rozmowy",
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type {
|
||||
CoachConversationDTO,
|
||||
CoachConversationsPage,
|
||||
} from "@/lib/ai/coach/types";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import {
|
||||
CONVERSATION_DELETE_UNDO_MS,
|
||||
commitCoachConversationDelete,
|
||||
createCoachConversationDeleteQueue,
|
||||
deleteCoachConversationRequest,
|
||||
removeCoachConversationFromCaches,
|
||||
} from "../use-coach";
|
||||
|
||||
function conversation(id: string): CoachConversationDTO {
|
||||
return {
|
||||
id,
|
||||
title: id,
|
||||
createdAt: "2026-07-20T10:00:00.000Z",
|
||||
updatedAt: "2026-07-20T10:00:00.000Z",
|
||||
messageCount: 2,
|
||||
fenced: false,
|
||||
};
|
||||
}
|
||||
|
||||
function page(
|
||||
...conversations: CoachConversationDTO[]
|
||||
): CoachConversationsPage {
|
||||
return { conversations, nextCursor: null };
|
||||
}
|
||||
|
||||
describe("Coach conversation delete queue", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("commits when the undo window runs out", () => {
|
||||
const commit = vi.fn();
|
||||
const queue = createCoachConversationDeleteQueue(commit);
|
||||
queue.request("c1");
|
||||
vi.advanceTimersByTime(CONVERSATION_DELETE_UNDO_MS - 1);
|
||||
expect(commit).not.toHaveBeenCalled();
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(commit).toHaveBeenCalledExactlyOnceWith("c1");
|
||||
});
|
||||
|
||||
it("commits a scheduled delete when the page goes away inside the window", () => {
|
||||
// The defect: a delete followed by a reload, a closed tab or a
|
||||
// backgrounded app inside the window lived only in a timer that never
|
||||
// fired, so no request was sent and the conversation came back.
|
||||
const commit = vi.fn();
|
||||
const queue = createCoachConversationDeleteQueue(commit);
|
||||
queue.request("c1");
|
||||
queue.request("c2");
|
||||
queue.flush();
|
||||
expect(commit.mock.calls).toEqual([["c1"], ["c2"]]);
|
||||
|
||||
// Committed once: neither the timer nor a second flush repeats it.
|
||||
vi.advanceTimersByTime(CONVERSATION_DELETE_UNDO_MS);
|
||||
queue.flush();
|
||||
expect(commit).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("undo cancels inside the window and reports false once committed", () => {
|
||||
const commit = vi.fn();
|
||||
const queue = createCoachConversationDeleteQueue(commit);
|
||||
queue.request("c1");
|
||||
expect(queue.undo("c1")).toBe(true);
|
||||
queue.flush();
|
||||
vi.advanceTimersByTime(CONVERSATION_DELETE_UNDO_MS);
|
||||
expect(commit).not.toHaveBeenCalled();
|
||||
|
||||
queue.request("c2");
|
||||
queue.flush();
|
||||
expect(queue.undo("c2")).toBe(false);
|
||||
expect(commit).toHaveBeenCalledExactlyOnceWith("c2");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Coach conversation delete caches and request", () => {
|
||||
const originalFetch = global.fetch;
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
|
||||
it("removes the row from the head list and every history variant at once", () => {
|
||||
// The hide filter is released only after this, so no cached list may
|
||||
// still hold the row: the paginated history used to keep it until the
|
||||
// refetch, and the row reappeared for that long.
|
||||
const client = new QueryClient();
|
||||
client.setQueryData(
|
||||
queryKeys.coachConversations(),
|
||||
page(conversation("c1"), conversation("c2")),
|
||||
);
|
||||
client.setQueryData(queryKeys.coachConversationHistory(""), {
|
||||
pages: [page(conversation("c2")), page(conversation("c1"))],
|
||||
pageParams: [null, "cursor-1"],
|
||||
});
|
||||
client.setQueryData(queryKeys.coachConversationHistory("c1"), {
|
||||
pages: [page(conversation("c1"))],
|
||||
pageParams: [null],
|
||||
});
|
||||
|
||||
const snapshot = removeCoachConversationFromCaches(client, "c1");
|
||||
|
||||
expect(
|
||||
client
|
||||
.getQueryData<CoachConversationsPage>(queryKeys.coachConversations())
|
||||
?.conversations.map((c) => c.id),
|
||||
).toEqual(["c2"]);
|
||||
for (const search of ["", "c1"]) {
|
||||
const data = client.getQueryData<{ pages: CoachConversationsPage[] }>(
|
||||
queryKeys.coachConversationHistory(search),
|
||||
);
|
||||
expect(
|
||||
data?.pages.flatMap((p) => p.conversations.map((c) => c.id)),
|
||||
).not.toContain("c1");
|
||||
}
|
||||
expect(snapshot).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("sends the DELETE with keepalive so it outlives the page", async () => {
|
||||
const fetchMock = vi.fn(
|
||||
async () =>
|
||||
new Response(JSON.stringify({ data: { deleted: true }, error: null }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
);
|
||||
global.fetch = fetchMock as unknown as typeof fetch;
|
||||
|
||||
await deleteCoachConversationRequest("c 1");
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
const [url, init] = fetchMock.mock.calls[0] as unknown as [
|
||||
string,
|
||||
RequestInit,
|
||||
];
|
||||
expect(url).toBe("/api/insights/chat/c%201");
|
||||
expect(init.method).toBe("DELETE");
|
||||
expect(init.keepalive).toBe(true);
|
||||
});
|
||||
|
||||
function seeded(): QueryClient {
|
||||
const client = new QueryClient();
|
||||
client.setQueryData(
|
||||
queryKeys.coachConversations(),
|
||||
page(conversation("c1"), conversation("c2")),
|
||||
);
|
||||
return client;
|
||||
}
|
||||
|
||||
function respond(status: number) {
|
||||
global.fetch = vi.fn(
|
||||
async () =>
|
||||
new Response(
|
||||
JSON.stringify(
|
||||
status === 200
|
||||
? { data: { deleted: true }, error: null }
|
||||
: { data: null, error: "coach.conversation.notFound" },
|
||||
),
|
||||
{ status, headers: { "Content-Type": "application/json" } },
|
||||
),
|
||||
) as unknown as typeof fetch;
|
||||
}
|
||||
|
||||
const headIds = (client: QueryClient) =>
|
||||
client
|
||||
.getQueryData<CoachConversationsPage>(queryKeys.coachConversations())
|
||||
?.conversations.map((c) => c.id);
|
||||
|
||||
it("treats a 404 as already deleted", async () => {
|
||||
// A delete re-sent after a reload meets a row the first request already
|
||||
// removed.
|
||||
respond(404);
|
||||
const client = seeded();
|
||||
const onFailed = vi.fn();
|
||||
commitCoachConversationDelete(client, "c1", onFailed);
|
||||
await vi.waitFor(() => expect(global.fetch).toHaveBeenCalledOnce());
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(onFailed).not.toHaveBeenCalled();
|
||||
expect(headIds(client)).toEqual(["c2"]);
|
||||
});
|
||||
|
||||
it("restores the lists and reports a failed delete", async () => {
|
||||
respond(500);
|
||||
const client = seeded();
|
||||
const onFailed = vi.fn();
|
||||
commitCoachConversationDelete(client, "c1", onFailed);
|
||||
expect(headIds(client)).toEqual(["c2"]);
|
||||
await vi.waitFor(() => expect(onFailed).toHaveBeenCalledOnce());
|
||||
expect(headIds(client)).toEqual(["c1", "c2"]);
|
||||
});
|
||||
});
|
||||
@@ -121,8 +121,9 @@ export function HistoryRail({
|
||||
action: {
|
||||
label: t("common.undo"),
|
||||
onClick: () => {
|
||||
undoDelete(id);
|
||||
if (wasActive) onUndoDeleteActive?.(id);
|
||||
// A delete already committed (window over, or the page was
|
||||
// hidden meanwhile) has nothing to reopen.
|
||||
if (undoDelete(id) && wasActive) onUndoDeleteActive?.(id);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
useSyncExternalStore,
|
||||
} from "react";
|
||||
import {
|
||||
useInfiniteQuery,
|
||||
useMutation,
|
||||
@@ -31,6 +38,7 @@ import type {
|
||||
import type { CoachSuggestedAction } from "@/lib/ai/coach/suggest-action";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import {
|
||||
ApiError,
|
||||
apiDelete,
|
||||
apiFetchRaw,
|
||||
apiGet,
|
||||
@@ -285,121 +293,307 @@ export function useCoachConversation(id: string | null) {
|
||||
});
|
||||
}
|
||||
|
||||
function removeConversationFromCache(data: unknown, id: string): unknown {
|
||||
if (!data || typeof data !== "object") return data;
|
||||
|
||||
if ("pages" in data && Array.isArray(data.pages)) {
|
||||
let changed = false;
|
||||
const pages = data.pages.map((page) => {
|
||||
const next = removeConversationFromCache(page, id);
|
||||
changed ||= next !== page;
|
||||
return next;
|
||||
});
|
||||
return changed ? { ...data, pages } : data;
|
||||
}
|
||||
|
||||
if ("conversations" in data && Array.isArray(data.conversations)) {
|
||||
const conversations = data.conversations.filter(
|
||||
(conversation) =>
|
||||
!(
|
||||
conversation &&
|
||||
typeof conversation === "object" &&
|
||||
"id" in conversation &&
|
||||
conversation.id === id
|
||||
),
|
||||
);
|
||||
return conversations.length === data.conversations.length
|
||||
? data
|
||||
: { ...data, conversations };
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a conversation with optimistic removal from the rail cache.
|
||||
* Rolls back if the server responds with a non-2xx.
|
||||
* Drop one conversation from every materialised list cache: the head list
|
||||
* the auto-open reads and every search variant of the paginated history
|
||||
* below the same prefix. Synchronous on purpose, so the row is gone from the
|
||||
* caches before any hide filter that covered it is released. Returns the
|
||||
* previous data for a rollback.
|
||||
*/
|
||||
export function useDeleteCoachConversation() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (id: string) => {
|
||||
await apiDelete(`/api/insights/chat/${id}`);
|
||||
return id;
|
||||
},
|
||||
onMutate: async (id: string) => {
|
||||
await queryClient.cancelQueries({ queryKey: QUERY_KEYS.list() });
|
||||
const previous = queryClient.getQueryData<CoachConversationsPage>(
|
||||
QUERY_KEYS.list(),
|
||||
);
|
||||
if (previous) {
|
||||
queryClient.setQueryData<CoachConversationsPage>(QUERY_KEYS.list(), {
|
||||
...previous,
|
||||
conversations: previous.conversations.filter((c) => c.id !== id),
|
||||
});
|
||||
}
|
||||
return { previous };
|
||||
},
|
||||
onError: (_err, _id, ctx) => {
|
||||
if (ctx?.previous) {
|
||||
queryClient.setQueryData(QUERY_KEYS.list(), ctx.previous);
|
||||
}
|
||||
},
|
||||
onSettled: (_data, _err, id) => {
|
||||
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.list() });
|
||||
if (typeof id === "string") {
|
||||
queryClient.removeQueries({ queryKey: QUERY_KEYS.one(id) });
|
||||
}
|
||||
},
|
||||
export function removeCoachConversationFromCaches(
|
||||
queryClient: QueryClient,
|
||||
id: string,
|
||||
): CoachConversationRenameSnapshot {
|
||||
void queryClient.cancelQueries({ queryKey: QUERY_KEYS.list() });
|
||||
const snapshot = queryClient.getQueriesData({ queryKey: QUERY_KEYS.list() });
|
||||
for (const [key, previous] of snapshot) {
|
||||
queryClient.setQueryData(key, removeConversationFromCache(previous, id));
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
/**
|
||||
* The DELETE itself. `keepalive` lets the request outlive the page, which is
|
||||
* what a delete committed from `pagehide` or a backgrounded app needs.
|
||||
*/
|
||||
export function deleteCoachConversationRequest(id: string): Promise<unknown> {
|
||||
return apiDelete(`/api/insights/chat/${encodeURIComponent(id)}`, undefined, {
|
||||
keepalive: true,
|
||||
});
|
||||
}
|
||||
|
||||
// v1.30.1 M5 — delayed-commit delete window, in milliseconds. Long
|
||||
// enough to read + tap Undo, short enough that the eventual DELETE
|
||||
// isn't a surprise days later.
|
||||
const CONVERSATION_DELETE_UNDO_MS = 6000;
|
||||
export const CONVERSATION_DELETE_UNDO_MS = 6000;
|
||||
|
||||
export interface CoachConversationDeleteQueue {
|
||||
/** Schedule the delete; it commits when the window runs out. */
|
||||
request(id: string): void;
|
||||
/** Cancel a scheduled delete. False once it has been committed. */
|
||||
undo(id: string): boolean;
|
||||
/** Commit every scheduled delete now. */
|
||||
flush(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.30.1 M5 — replaces the old "tap once to arm, tap the same row
|
||||
* again to delete" confirm, which never disarmed (a row armed minutes
|
||||
* earlier deleted on a later stray tap) and had no way back once fired.
|
||||
* Mirrors the Documents delete-with-undo pattern instead: a single tap
|
||||
* hides the row immediately and schedules the real
|
||||
* The undo window as plain state, apart from React. A delete that is still
|
||||
* waiting when the page goes away is committed by `flush`, never dropped:
|
||||
* the window exists to offer an Undo, not to make the delete conditional on
|
||||
* the tab staying open for another six seconds.
|
||||
*/
|
||||
export function createCoachConversationDeleteQueue(
|
||||
commit: (id: string) => void,
|
||||
delayMs: number = CONVERSATION_DELETE_UNDO_MS,
|
||||
): CoachConversationDeleteQueue {
|
||||
const timers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
const fire = (id: string) => {
|
||||
const timer = timers.get(id);
|
||||
if (timer === undefined) return;
|
||||
clearTimeout(timer);
|
||||
timers.delete(id);
|
||||
commit(id);
|
||||
};
|
||||
return {
|
||||
request(id) {
|
||||
if (timers.has(id)) return;
|
||||
timers.set(
|
||||
id,
|
||||
setTimeout(() => fire(id), delayMs),
|
||||
);
|
||||
},
|
||||
undo(id) {
|
||||
const timer = timers.get(id);
|
||||
if (timer === undefined) return false;
|
||||
clearTimeout(timer);
|
||||
timers.delete(id);
|
||||
return true;
|
||||
},
|
||||
flush() {
|
||||
for (const id of [...timers.keys()]) fire(id);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Conversations this page has deleted or is deleting, shared by every
|
||||
* surface: a row hidden in the Coach panel is hidden in the settings card
|
||||
* too. An id stays here once its delete went through; the server no longer
|
||||
* has the row, so hiding it costs nothing and closes the gap before the
|
||||
* refetch lands.
|
||||
*/
|
||||
let hiddenConversationIds: ReadonlySet<string> = new Set();
|
||||
const NO_HIDDEN_CONVERSATIONS: ReadonlySet<string> = new Set();
|
||||
const hiddenConversationListeners = new Set<() => void>();
|
||||
|
||||
function setConversationHidden(id: string, hidden: boolean): void {
|
||||
if (hiddenConversationIds.has(id) === hidden) return;
|
||||
const next = new Set(hiddenConversationIds);
|
||||
if (hidden) next.add(id);
|
||||
else next.delete(id);
|
||||
hiddenConversationIds = next;
|
||||
for (const listener of hiddenConversationListeners) listener();
|
||||
}
|
||||
|
||||
function subscribeHiddenConversations(listener: () => void): () => void {
|
||||
hiddenConversationListeners.add(listener);
|
||||
return () => hiddenConversationListeners.delete(listener);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes sent but not yet confirmed, kept in `sessionStorage` so they
|
||||
* outlive a reload. A reload inside the undo window sends the DELETE with
|
||||
* `keepalive` from the old page, and the new page's list read can reach the
|
||||
* server before it: without the journal that read brings the row back. The
|
||||
* new page hides every journaled id and sends its DELETE again; a 404 then
|
||||
* means it is already gone.
|
||||
*/
|
||||
const DELETE_JOURNAL_KEY = "healthlog:coach-conversation-deletes";
|
||||
|
||||
function readDeleteJournal(): string[] {
|
||||
try {
|
||||
const raw = window.sessionStorage.getItem(DELETE_JOURNAL_KEY);
|
||||
const parsed: unknown = raw ? JSON.parse(raw) : [];
|
||||
return Array.isArray(parsed)
|
||||
? parsed.filter((id): id is string => typeof id === "string")
|
||||
: [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function writeDeleteJournal(id: string, present: boolean): void {
|
||||
try {
|
||||
const ids = new Set(readDeleteJournal());
|
||||
if (present) ids.add(id);
|
||||
else ids.delete(id);
|
||||
if (ids.size === 0) {
|
||||
window.sessionStorage.removeItem(DELETE_JOURNAL_KEY);
|
||||
} else {
|
||||
window.sessionStorage.setItem(
|
||||
DELETE_JOURNAL_KEY,
|
||||
JSON.stringify([...ids]),
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// Storage unavailable: the keepalive request still carries the delete.
|
||||
}
|
||||
}
|
||||
|
||||
const deletesInFlight = new Set<string>();
|
||||
|
||||
/**
|
||||
* Send one delete. Synchronous up to the request, so a commit made from
|
||||
* `pagehide` is on the wire before the page is torn down. Every list cache
|
||||
* drops the row first; a failure restores them, shows the row again and
|
||||
* calls `onFailed`. A 404 is success: the row is gone, which is the point.
|
||||
*/
|
||||
export function commitCoachConversationDelete(
|
||||
queryClient: QueryClient,
|
||||
id: string,
|
||||
onFailed: () => void,
|
||||
): void {
|
||||
if (deletesInFlight.has(id)) return;
|
||||
deletesInFlight.add(id);
|
||||
setConversationHidden(id, true);
|
||||
writeDeleteJournal(id, true);
|
||||
const request = deleteCoachConversationRequest(id);
|
||||
const snapshot = removeCoachConversationFromCaches(queryClient, id);
|
||||
request
|
||||
.catch((error: unknown) => {
|
||||
if (error instanceof ApiError && error.status === 404) return;
|
||||
throw error;
|
||||
})
|
||||
.then(
|
||||
() => {
|
||||
queryClient.removeQueries({ queryKey: QUERY_KEYS.one(id) });
|
||||
},
|
||||
() => {
|
||||
restoreCoachConversationRename(queryClient, snapshot);
|
||||
setConversationHidden(id, false);
|
||||
onFailed();
|
||||
},
|
||||
)
|
||||
.finally(() => {
|
||||
deletesInFlight.delete(id);
|
||||
writeDeleteJournal(id, false);
|
||||
void queryClient.invalidateQueries({ queryKey: QUERY_KEYS.list() });
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.30.1 M5 — one tap hides the row and schedules the real
|
||||
* `DELETE /api/insights/chat/[id]` after `CONVERSATION_DELETE_UNDO_MS`;
|
||||
* calling `undoDelete` within that window cancels the network call and
|
||||
* un-hides the row. `pendingDeleteIds` is a client-only hide filter —
|
||||
* consumers subtract it from whatever list they render.
|
||||
* `undoDelete` within that window cancels it. `pendingDeleteIds` is the
|
||||
* hide filter every surface subtracts from what it renders.
|
||||
*
|
||||
* Centralised here (rather than duplicated in the rail + the standalone
|
||||
* `/coach/conversations` page, which previously carried byte-identical
|
||||
* arm/confirm logic) so both surfaces share one implementation.
|
||||
* The delete is never conditional on the page surviving the window. A
|
||||
* scheduled delete commits early when the page is hidden (an installed app
|
||||
* sent to the background, a tab switched away), on `pagehide` (reload,
|
||||
* close, navigation away) and when the owning component unmounts; the
|
||||
* request goes out with `keepalive`, and a delete the page could not confirm
|
||||
* is sent again by the next one (see the journal above). Before that, a
|
||||
* delete followed by a reload inside the window was never sent, and the
|
||||
* conversation came back.
|
||||
*
|
||||
* Shared by every surface that deletes a Coach conversation: the Coach
|
||||
* panel's rail, `/coach/conversations` and the settings memory card.
|
||||
*/
|
||||
export function useDeleteCoachConversationWithUndo() {
|
||||
const deleteMutation = useDeleteCoachConversation();
|
||||
const [pendingDeleteIds, setPendingDeleteIds] = useState<Set<string>>(
|
||||
() => new Set(),
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslations();
|
||||
const pendingDeleteIds = useSyncExternalStore(
|
||||
subscribeHiddenConversations,
|
||||
() => hiddenConversationIds,
|
||||
() => NO_HIDDEN_CONVERSATIONS,
|
||||
);
|
||||
|
||||
// Kept current by an effect, so the queue below is created once and still
|
||||
// commits with the latest translator.
|
||||
const commitRef = useRef<(id: string) => void>(() => {});
|
||||
useEffect(() => {
|
||||
const failedMessage = t("insights.coach.historyDeleteFailed");
|
||||
commitRef.current = (id) =>
|
||||
commitCoachConversationDelete(queryClient, id, () =>
|
||||
toast.error(failedMessage),
|
||||
);
|
||||
});
|
||||
|
||||
// Created on first use, from a handler or an effect, never during render.
|
||||
const queueRef = useRef<CoachConversationDeleteQueue | null>(null);
|
||||
const getQueue = useCallback(
|
||||
() =>
|
||||
(queueRef.current ??= createCoachConversationDeleteQueue((id) =>
|
||||
commitRef.current(id),
|
||||
)),
|
||||
[],
|
||||
);
|
||||
const timers = useRef(new Map<string, ReturnType<typeof setTimeout>>());
|
||||
|
||||
useEffect(() => {
|
||||
// A closed drawer / unmounted page must not silently lose a
|
||||
// scheduled delete: fire any still-pending ones immediately rather
|
||||
// than leaking the timer (or worse, never deleting at all).
|
||||
const timerMap = timers.current;
|
||||
return () => {
|
||||
for (const [id, timer] of timerMap) {
|
||||
clearTimeout(timer);
|
||||
deleteMutation.mutate(id);
|
||||
}
|
||||
timerMap.clear();
|
||||
// Finish what a previous page of this tab could not confirm.
|
||||
for (const id of readDeleteJournal()) commitRef.current(id);
|
||||
|
||||
const onVisibilityChange = () => {
|
||||
if (document.visibilityState === "hidden") getQueue().flush();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- run once on unmount only
|
||||
}, []);
|
||||
const onPageHide = () => getQueue().flush();
|
||||
document.addEventListener("visibilitychange", onVisibilityChange);
|
||||
window.addEventListener("pagehide", onPageHide);
|
||||
return () => {
|
||||
document.removeEventListener("visibilitychange", onVisibilityChange);
|
||||
window.removeEventListener("pagehide", onPageHide);
|
||||
// A closed drawer or a left page commits what it scheduled.
|
||||
getQueue().flush();
|
||||
};
|
||||
}, [getQueue]);
|
||||
|
||||
const requestDelete = useCallback(
|
||||
(id: string) => {
|
||||
setPendingDeleteIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.add(id);
|
||||
return next;
|
||||
});
|
||||
const timer = setTimeout(() => {
|
||||
timers.current.delete(id);
|
||||
setPendingDeleteIds((prev) => {
|
||||
if (!prev.has(id)) return prev;
|
||||
const next = new Set(prev);
|
||||
next.delete(id);
|
||||
return next;
|
||||
});
|
||||
deleteMutation.mutate(id);
|
||||
}, CONVERSATION_DELETE_UNDO_MS);
|
||||
timers.current.set(id, timer);
|
||||
setConversationHidden(id, true);
|
||||
getQueue().request(id);
|
||||
},
|
||||
[deleteMutation],
|
||||
[getQueue],
|
||||
);
|
||||
|
||||
const undoDelete = useCallback((id: string) => {
|
||||
const timer = timers.current.get(id);
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
timers.current.delete(id);
|
||||
}
|
||||
setPendingDeleteIds((prev) => {
|
||||
if (!prev.has(id)) return prev;
|
||||
const next = new Set(prev);
|
||||
next.delete(id);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
const undoDelete = useCallback(
|
||||
(id: string): boolean => {
|
||||
// Too late once committed: the row stays hidden, as the server has it.
|
||||
const undone = getQueue().undo(id);
|
||||
if (undone) setConversationHidden(id, false);
|
||||
return undone;
|
||||
},
|
||||
[getQueue],
|
||||
);
|
||||
|
||||
return { pendingDeleteIds, requestDelete, undoDelete };
|
||||
}
|
||||
|
||||
@@ -1094,21 +1094,24 @@ export async function renameConversation(
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a conversation and every message under it. Returns false when
|
||||
* the conversation does not exist or is not owned by `userId` — the
|
||||
* route should map both to 404.
|
||||
* Delete a conversation and every message under it (the foreign keys
|
||||
* cascade). Returns false when the conversation does not exist or is not
|
||||
* owned by `userId` — the route should map both to 404.
|
||||
*
|
||||
* One owner-scoped statement, like `renameConversation`: a client re-sends a
|
||||
* delete it could not confirm (a reload inside the undo window), so two
|
||||
* requests for the same row are expected. A find-then-delete pair let both
|
||||
* find the row and the slower one fail with a 500; here one deletes it and
|
||||
* the other reads 404.
|
||||
*/
|
||||
export async function deleteConversation(
|
||||
userId: string,
|
||||
conversationId: string,
|
||||
): Promise<boolean> {
|
||||
const row = await prisma.coachConversation.findFirst({
|
||||
const { count } = await prisma.coachConversation.deleteMany({
|
||||
where: { id: conversationId, userId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!row) return false;
|
||||
await prisma.coachConversation.delete({ where: { id: row.id } });
|
||||
return true;
|
||||
return count === 1;
|
||||
}
|
||||
|
||||
// ─── S7: coach-conversation attachments ─────────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* Deleting a Coach conversation against a real Postgres: the row, its
|
||||
* messages and its document attachments go, the document itself stays, the
|
||||
* list no longer carries it, a foreign account gets 404, and two deletes of
|
||||
* the same row (a client re-sending one it could not confirm) answer 200 and
|
||||
* 404 instead of failing.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
process.env.ENCRYPTION_KEY ??=
|
||||
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
|
||||
import { cookieJar, headerJar } from "./mock-next-headers";
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
|
||||
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();
|
||||
headerJar.clear();
|
||||
});
|
||||
|
||||
async function seedUser(username: string): Promise<string> {
|
||||
const user = await getPrismaClient().user.create({
|
||||
data: { username, role: "USER" },
|
||||
});
|
||||
return user.id;
|
||||
}
|
||||
|
||||
async function signIn(userId: string): Promise<void> {
|
||||
const session = await getPrismaClient().session.create({
|
||||
data: { userId, expiresAt: new Date(Date.now() + 60_000) },
|
||||
});
|
||||
cookieJar.set("healthlog_session", session.id);
|
||||
}
|
||||
|
||||
async function seedConversation(userId: string) {
|
||||
const prisma = getPrismaClient();
|
||||
const { encryptDocumentContent } = await import("@/lib/documents/store");
|
||||
const { content, codec } = encryptDocumentContent(Buffer.from("letter"));
|
||||
const document = await prisma.inboundDocument.create({
|
||||
data: {
|
||||
userId,
|
||||
kind: "OTHER",
|
||||
filename: "letter.txt",
|
||||
mimeType: "text/plain",
|
||||
byteSize: 6,
|
||||
status: "STORED",
|
||||
contentEncrypted: content,
|
||||
contentCodec: codec,
|
||||
},
|
||||
});
|
||||
const { appendMessage, createConversation } =
|
||||
await import("@/lib/ai/coach/persistence");
|
||||
const convo = await createConversation({
|
||||
userId,
|
||||
title: "Old thread",
|
||||
documentScoped: true,
|
||||
attachmentIds: [document.id],
|
||||
});
|
||||
await appendMessage({
|
||||
conversationId: convo.id,
|
||||
role: "user",
|
||||
content: "What does the letter say?",
|
||||
});
|
||||
await appendMessage({
|
||||
conversationId: convo.id,
|
||||
role: "assistant",
|
||||
content: "It confirms the appointment.",
|
||||
});
|
||||
return { conversationId: convo.id, documentId: document.id };
|
||||
}
|
||||
|
||||
type IdRoute = (
|
||||
req: Request,
|
||||
ctx: { params: Promise<{ id: string }> },
|
||||
) => Promise<Response>;
|
||||
|
||||
async function deleteThread(id: string): Promise<Response> {
|
||||
const { DELETE } = await import("@/app/api/insights/chat/[id]/route");
|
||||
return (DELETE as unknown as IdRoute)(
|
||||
new Request(`http://localhost/api/insights/chat/${id}`, {
|
||||
method: "DELETE",
|
||||
}),
|
||||
{ params: Promise.resolve({ id }) },
|
||||
);
|
||||
}
|
||||
|
||||
async function listIds(): Promise<string[]> {
|
||||
const { GET } = await import("@/app/api/insights/chat/route");
|
||||
const res = await (GET as unknown as (req: Request) => Promise<Response>)(
|
||||
new Request("http://localhost/api/insights/chat?limit=50"),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: { conversations: { id: string }[] };
|
||||
};
|
||||
return body.data.conversations.map((c) => c.id);
|
||||
}
|
||||
|
||||
describe("DELETE /api/insights/chat/[id]", () => {
|
||||
it("removes the thread, its messages and attachments, and keeps it out of the list", async () => {
|
||||
const userId = await seedUser("delete-owner");
|
||||
await signIn(userId);
|
||||
const kept = await seedConversation(userId);
|
||||
const { conversationId, documentId } = await seedConversation(userId);
|
||||
expect(await listIds()).toContain(conversationId);
|
||||
|
||||
const res = await deleteThread(conversationId);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const prisma = getPrismaClient();
|
||||
expect(
|
||||
await prisma.coachConversation.findUnique({
|
||||
where: { id: conversationId },
|
||||
}),
|
||||
).toBeNull();
|
||||
expect(await prisma.coachMessage.count({ where: { conversationId } })).toBe(
|
||||
0,
|
||||
);
|
||||
expect(
|
||||
await prisma.coachConversationDocument.count({
|
||||
where: { conversationId },
|
||||
}),
|
||||
).toBe(0);
|
||||
expect(
|
||||
await prisma.inboundDocument.findUnique({ where: { id: documentId } }),
|
||||
).not.toBeNull();
|
||||
|
||||
expect(await listIds()).toEqual([kept.conversationId]);
|
||||
});
|
||||
|
||||
it("answers 200 then 404 when the same delete arrives twice at once", async () => {
|
||||
const userId = await seedUser("delete-twice");
|
||||
await signIn(userId);
|
||||
const { conversationId } = await seedConversation(userId);
|
||||
|
||||
const statuses = (
|
||||
await Promise.all([
|
||||
deleteThread(conversationId),
|
||||
deleteThread(conversationId),
|
||||
])
|
||||
)
|
||||
.map((r) => r.status)
|
||||
.sort();
|
||||
|
||||
expect(statuses).toEqual([200, 404]);
|
||||
expect(await listIds()).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not delete another account's thread", async () => {
|
||||
const ownerId = await seedUser("delete-victim");
|
||||
const { conversationId } = await seedConversation(ownerId);
|
||||
const intruderId = await seedUser("delete-intruder");
|
||||
await signIn(intruderId);
|
||||
|
||||
const res = await deleteThread(conversationId);
|
||||
expect(res.status).toBe(404);
|
||||
expect(
|
||||
await getPrismaClient().coachConversation.findUnique({
|
||||
where: { id: conversationId },
|
||||
}),
|
||||
).not.toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user