From 8782f5ffd964013abcbfee98cc9272c8054a037c Mon Sep 17 00:00:00 2001 From: Arpit Gupta Date: Mon, 1 Jun 2026 03:07:57 +0530 Subject: [PATCH] feat(chats): exclude archived sessions from GET /api/chats MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Archive is the soft-delete path for sessions (`PATCH /api/sessions/{id} { status: "archived" }` — already wired up to `stopSandboxOnArchive` and the lifecycle state machine). The chats list was still surfacing chats from archived sessions, which made archive feel like a no-op from the user's perspective and blocks the chat sidebar from using archive as the "delete a chat thread" action. - `selectChatsWithSessions`: add `.neq("session.status", "archived")` and project `session.status` so the filter has something to gate on. - `getChatsHandler` + `get_chats` MCP tool JSDoc: note the exclusion so callers know archived rows won't appear. - Tests: assert the filter is always applied (with or without account / artist scoping) and that the SELECT now projects `status`. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/chats/getChatsHandler.ts | 4 +++- lib/mcp/tools/chats/registerGetChatsTool.ts | 1 + .../chats/__tests__/selectChatsWithSessions.test.ts | 13 ++++++++++++- lib/supabase/chats/selectChatsWithSessions.ts | 9 ++++++--- 4 files changed, 22 insertions(+), 5 deletions(-) diff --git a/lib/chats/getChatsHandler.ts b/lib/chats/getChatsHandler.ts index 0f7b254a..1a0c31cd 100644 --- a/lib/chats/getChatsHandler.ts +++ b/lib/chats/getChatsHandler.ts @@ -11,7 +11,9 @@ import { selectChatsWithSessions } from "@/lib/supabase/chats/selectChatsWithSes * Returns chats joined with their owning session so the response carries the * `sessionId`, owning `accountId`, and `artistId` per row, enabling clients * to render canonical `/sessions/{sid}/chats/{cid}` URLs and filter by - * artist context. + * artist context. Chats whose owning session is archived + * (`sessions.status === "archived"`) are excluded — archive is the + * soft-delete path for sessions. * * Scope: * - Personal/org key: chats belonging to the caller's account. diff --git a/lib/mcp/tools/chats/registerGetChatsTool.ts b/lib/mcp/tools/chats/registerGetChatsTool.ts index d667964b..0230d5c8 100644 --- a/lib/mcp/tools/chats/registerGetChatsTool.ts +++ b/lib/mcp/tools/chats/registerGetChatsTool.ts @@ -30,6 +30,7 @@ export type GetChatsArgs = z.infer; * GET /api/chats: personal/org → caller's account; Recoup admin → all; * or a specific account when `account_id` is supplied and the caller * can access it. `artist_account_id` further scopes by artist context. + * Chats whose owning session is archived are excluded. * * Admin status is derived from `account_organization_ids` membership so * that Bearer-authed callers get the same admin scope as x-api-key diff --git a/lib/supabase/chats/__tests__/selectChatsWithSessions.test.ts b/lib/supabase/chats/__tests__/selectChatsWithSessions.test.ts index 2d9de9af..0c88d411 100644 --- a/lib/supabase/chats/__tests__/selectChatsWithSessions.test.ts +++ b/lib/supabase/chats/__tests__/selectChatsWithSessions.test.ts @@ -3,6 +3,7 @@ import { selectChatsWithSessions } from "@/lib/supabase/chats/selectChatsWithSes const fromMock = vi.fn(); const selectMock = vi.fn(); +const neqMock = vi.fn(); const inMock = vi.fn(); const eqMock = vi.fn(); const orderMock = vi.fn(); @@ -15,15 +16,17 @@ vi.mock("@/lib/supabase/serverClient", () => ({ beforeEach(() => { vi.clearAllMocks(); - // Default chain: from().select().in().eq().order() -> resolves to { data, error } + // Default chain: from().select().neq().in().eq().order() -> resolves to { data, error } const builder = { select: selectMock, + neq: neqMock, in: inMock, eq: eqMock, order: orderMock, }; fromMock.mockReturnValue(builder); selectMock.mockReturnValue(builder); + neqMock.mockReturnValue(builder); inMock.mockReturnValue(builder); eqMock.mockReturnValue(builder); orderMock.mockResolvedValue({ data: [], error: null }); @@ -51,13 +54,21 @@ describe("selectChatsWithSessions", () => { expect(String(selectArg)).toContain("session:sessions!inner"); expect(String(selectArg)).toContain("account_id"); expect(String(selectArg)).toContain("artist_id"); + expect(String(selectArg)).toContain("status"); + expect(neqMock).toHaveBeenCalledWith("session.status", "archived"); expect(inMock).toHaveBeenCalledWith("session.account_id", ["acc-1", "acc-2"]); expect(eqMock).not.toHaveBeenCalled(); expect(orderMock).toHaveBeenCalledWith("updated_at", { ascending: false }); expect(result).toEqual(rows); }); + it("always excludes archived sessions, even with no other filters", async () => { + await selectChatsWithSessions({}); + + expect(neqMock).toHaveBeenCalledWith("session.status", "archived"); + }); + it("composes accountIds + artistAccountId — both filters applied", async () => { orderMock.mockResolvedValueOnce({ data: [], error: null }); diff --git a/lib/supabase/chats/selectChatsWithSessions.ts b/lib/supabase/chats/selectChatsWithSessions.ts index 932341ff..ee3d204d 100644 --- a/lib/supabase/chats/selectChatsWithSessions.ts +++ b/lib/supabase/chats/selectChatsWithSessions.ts @@ -18,13 +18,16 @@ export interface SelectChatsWithSessionsParams { const SELECT = ` *, - session:sessions!inner ( id, account_id, artist_id ) + session:sessions!inner ( id, account_id, artist_id, status ) ` as const; /** * Reads chats joined to their owning session, optionally scoped to a set of * account IDs through `sessions.account_id` and/or an artist context through - * `sessions.artist_id`. Ordered by `chats.updated_at` descending so newest + * `sessions.artist_id`. Chats whose session is archived + * (`sessions.status === "archived"`) are excluded — archive is the + * soft-delete path, and archived sessions should not surface in chat + * listings. Results are ordered by `chats.updated_at` descending so newest * activity surfaces first. * * Returns `null` when Supabase reports an error so callers can distinguish a @@ -39,7 +42,7 @@ export async function selectChatsWithSessions(params: SelectChatsWithSessionsPar return []; } - let query = supabase.from("chats").select(SELECT); + let query = supabase.from("chats").select(SELECT).neq("session.status", "archived"); if (accountIds !== undefined) { query = query.in("session.account_id", accountIds); }