diff --git a/app/api/chats/[id]/messages/trailing/route.ts b/app/api/chats/[id]/messages/trailing/route.ts new file mode 100644 index 00000000..afe9b673 --- /dev/null +++ b/app/api/chats/[id]/messages/trailing/route.ts @@ -0,0 +1,27 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { deleteTrailingChatMessagesHandler } from "@/lib/chats/deleteTrailingChatMessagesHandler"; + +/** + * OPTIONS handler for CORS preflight requests. + */ +export async function OPTIONS() { + return new NextResponse(null, { + status: 200, + headers: getCorsHeaders(), + }); +} + +/** + * DELETE /api/chats/[id]/messages/trailing + * + * Deletes all messages in chat `id` from `from_message_id` onward. + */ +export async function DELETE( + request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +): Promise { + const { id } = await params; + return deleteTrailingChatMessagesHandler(request, id); +} diff --git a/lib/chats/__tests__/deleteTrailingChatMessagesHandler.test.ts b/lib/chats/__tests__/deleteTrailingChatMessagesHandler.test.ts new file mode 100644 index 00000000..ab636d66 --- /dev/null +++ b/lib/chats/__tests__/deleteTrailingChatMessagesHandler.test.ts @@ -0,0 +1,89 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; +import { deleteTrailingChatMessagesHandler } from "@/lib/chats/deleteTrailingChatMessagesHandler"; +import { validateDeleteTrailingMessagesQuery } from "@/lib/chats/validateDeleteTrailingMessagesQuery"; +import deleteMemories from "@/lib/supabase/memories/deleteMemories"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), +})); + +vi.mock("@/lib/chats/validateDeleteTrailingMessagesQuery", () => ({ + validateDeleteTrailingMessagesQuery: vi.fn(), +})); + +vi.mock("@/lib/supabase/memories/deleteMemories", () => ({ + default: vi.fn(), +})); + +const chatId = "123e4567-e89b-42d3-a456-426614174000"; +const fromMessageId = "123e4567-e89b-42d3-a456-426614174001"; +const request = new NextRequest( + `http://localhost/api/chats/${chatId}/messages/trailing?from_message_id=${fromMessageId}`, + { method: "DELETE" }, +); + +describe("deleteTrailingChatMessagesHandler", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("passes through validation errors", async () => { + vi.mocked(validateDeleteTrailingMessagesQuery).mockResolvedValue( + NextResponse.json({ status: "error", error: "Bad request" }, { status: 400 }), + ); + + const response = await deleteTrailingChatMessagesHandler(request, chatId); + expect(response.status).toBe(400); + }); + + it("returns 500 when delete fails", async () => { + vi.mocked(validateDeleteTrailingMessagesQuery).mockResolvedValue({ + chatId, + fromMessageId, + fromTimestamp: "2026-03-31T00:00:00.000Z", + }); + vi.mocked(deleteMemories).mockResolvedValue(false); + + const response = await deleteTrailingChatMessagesHandler(request, chatId); + const body = await response.json(); + + expect(response.status).toBe(500); + expect(body).toEqual({ + status: "error", + error: "Failed to delete trailing messages", + }); + }); + + it("returns success when delete succeeds", async () => { + vi.mocked(validateDeleteTrailingMessagesQuery).mockResolvedValue({ + chatId, + fromMessageId, + fromTimestamp: "2026-03-31T00:00:00.000Z", + }); + vi.mocked(deleteMemories).mockResolvedValue(true); + + const response = await deleteTrailingChatMessagesHandler(request, chatId); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body).toEqual({ + status: "success", + chat_id: chatId, + from_message_id: fromMessageId, + }); + }); + + it("returns 500 when validation throws unexpectedly", async () => { + vi.mocked(validateDeleteTrailingMessagesQuery).mockRejectedValue(new Error("boom")); + + const response = await deleteTrailingChatMessagesHandler(request, chatId); + const body = await response.json(); + + expect(response.status).toBe(500); + expect(body).toEqual({ + status: "error", + error: "Failed to delete trailing messages", + }); + }); +}); diff --git a/lib/chats/__tests__/validateDeleteTrailingMessagesQuery.test.ts b/lib/chats/__tests__/validateDeleteTrailingMessagesQuery.test.ts new file mode 100644 index 00000000..17b609e8 --- /dev/null +++ b/lib/chats/__tests__/validateDeleteTrailingMessagesQuery.test.ts @@ -0,0 +1,112 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; +import { Tables } from "@/types/database.types"; +import { validateDeleteTrailingMessagesQuery } from "@/lib/chats/validateDeleteTrailingMessagesQuery"; +import { validateChatAccess } from "@/lib/chats/validateChatAccess"; +import selectMemories from "@/lib/supabase/memories/selectMemories"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), +})); + +vi.mock("@/lib/chats/validateChatAccess", () => ({ + validateChatAccess: vi.fn(), +})); + +vi.mock("@/lib/supabase/memories/selectMemories", () => ({ + default: vi.fn(), +})); + +const chatId = "123e4567-e89b-42d3-a456-426614174000"; +const fromMessageId = "123e4567-e89b-42d3-a456-426614174001"; + +const createRequest = (query = "") => + new NextRequest(`http://localhost/api/chats/${chatId}/messages/trailing${query}`); + +describe("validateDeleteTrailingMessagesQuery", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("passes through chat access errors", async () => { + vi.mocked(validateChatAccess).mockResolvedValue( + NextResponse.json({ status: "error", error: "Unauthorized" }, { status: 401 }), + ); + + const result = await validateDeleteTrailingMessagesQuery(createRequest(), chatId); + expect(result).toBeInstanceOf(NextResponse); + expect((result as NextResponse).status).toBe(401); + }); + + it("returns 400 when from_message_id is missing", async () => { + vi.mocked(validateChatAccess).mockResolvedValue({ + roomId: chatId, + room: { + id: chatId, + account_id: "11111111-1111-1111-1111-111111111111", + artist_id: null, + topic: null, + updated_at: null, + }, + accountId: "11111111-1111-1111-1111-111111111111", + }); + + const result = await validateDeleteTrailingMessagesQuery(createRequest(), chatId); + expect(result).toBeInstanceOf(NextResponse); + expect((result as NextResponse).status).toBe(400); + }); + + it("returns 404 when message does not exist", async () => { + vi.mocked(validateChatAccess).mockResolvedValue({ + roomId: chatId, + room: { + id: chatId, + account_id: "11111111-1111-1111-1111-111111111111", + artist_id: null, + topic: null, + updated_at: null, + }, + accountId: "11111111-1111-1111-1111-111111111111", + }); + vi.mocked(selectMemories).mockResolvedValue([]); + + const result = await validateDeleteTrailingMessagesQuery( + createRequest(`?from_message_id=${fromMessageId}`), + chatId, + ); + expect(result).toBeInstanceOf(NextResponse); + expect((result as NextResponse).status).toBe(404); + }); + + it("returns validated payload when query is valid", async () => { + vi.mocked(validateChatAccess).mockResolvedValue({ + roomId: chatId, + room: { + id: chatId, + account_id: "11111111-1111-1111-1111-111111111111", + artist_id: null, + topic: null, + updated_at: null, + }, + accountId: "11111111-1111-1111-1111-111111111111", + }); + vi.mocked(selectMemories).mockResolvedValue([ + { + id: fromMessageId, + room_id: chatId, + updated_at: "2026-03-31T00:00:00.000Z", + } as Tables<"memories">, + ]); + + const result = await validateDeleteTrailingMessagesQuery( + createRequest(`?from_message_id=${fromMessageId}`), + chatId, + ); + + expect(result).toEqual({ + chatId, + fromMessageId, + fromTimestamp: "2026-03-31T00:00:00.000Z", + }); + }); +}); diff --git a/lib/chats/deleteTrailingChatMessagesHandler.ts b/lib/chats/deleteTrailingChatMessagesHandler.ts new file mode 100644 index 00000000..74435b5a --- /dev/null +++ b/lib/chats/deleteTrailingChatMessagesHandler.ts @@ -0,0 +1,50 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateDeleteTrailingMessagesQuery } from "@/lib/chats/validateDeleteTrailingMessagesQuery"; +import deleteMemories from "@/lib/supabase/memories/deleteMemories"; + +/** + * Handles DELETE /api/chats/[id]/messages/trailing. + * + * @param request - Incoming request object. + * @param chatId - Chat UUID from route params. + * @returns JSON response indicating deletion result or error. + */ +export async function deleteTrailingChatMessagesHandler( + request: NextRequest, + chatId: string, +): Promise { + try { + const validated = await validateDeleteTrailingMessagesQuery(request, chatId); + if (validated instanceof NextResponse) { + return validated; + } + + const deleted = await deleteMemories(validated.chatId, { + fromTimestamp: validated.fromTimestamp, + }); + + if (!deleted) { + return NextResponse.json( + { status: "error", error: "Failed to delete trailing messages" }, + { status: 500, headers: getCorsHeaders() }, + ); + } + + return NextResponse.json( + { + status: "success", + chat_id: validated.chatId, + from_message_id: validated.fromMessageId, + }, + { status: 200, headers: getCorsHeaders() }, + ); + } catch (error) { + console.error("Unexpected error in deleteTrailingChatMessagesHandler:", error); + return NextResponse.json( + { status: "error", error: "Failed to delete trailing messages" }, + { status: 500, headers: getCorsHeaders() }, + ); + } +} diff --git a/lib/chats/validateDeleteTrailingMessagesQuery.ts b/lib/chats/validateDeleteTrailingMessagesQuery.ts new file mode 100644 index 00000000..84ad112c --- /dev/null +++ b/lib/chats/validateDeleteTrailingMessagesQuery.ts @@ -0,0 +1,67 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateChatAccess } from "@/lib/chats/validateChatAccess"; +import selectMemories from "@/lib/supabase/memories/selectMemories"; + +const deleteTrailingQuerySchema = z.object({ + from_message_id: z.string().uuid("from_message_id must be a valid UUID"), +}); + +export interface ValidatedDeleteTrailingMessagesQuery { + chatId: string; + fromMessageId: string; + fromTimestamp: string; +} + +/** + * Validates DELETE /api/chats/[id]/messages/trailing query and chat/message ownership. + * + * @param request - Incoming request containing query parameters. + * @param chatId - Chat UUID from route params. + * @returns Either an error response or a validated payload for deletion. + */ +export async function validateDeleteTrailingMessagesQuery( + request: NextRequest, + chatId: string, +): Promise { + const roomResult = await validateChatAccess(request, chatId); + if (roomResult instanceof NextResponse) { + return roomResult; + } + + const parsedQuery = deleteTrailingQuerySchema.safeParse({ + from_message_id: request.nextUrl.searchParams.get("from_message_id") ?? undefined, + }); + + if (!parsedQuery.success) { + const firstError = parsedQuery.error.issues[0]; + return NextResponse.json( + { + status: "error", + missing_fields: firstError.path, + error: firstError.message, + }, + { status: 400, headers: getCorsHeaders() }, + ); + } + + const [memory] = + (await selectMemories(roomResult.room.id, { + memoryId: parsedQuery.data.from_message_id, + limit: 1, + })) ?? []; + if (!memory) { + return NextResponse.json( + { status: "error", error: "Message not found" }, + { status: 404, headers: getCorsHeaders() }, + ); + } + + return { + chatId: roomResult.room.id, + fromMessageId: parsedQuery.data.from_message_id, + fromTimestamp: memory.updated_at, + }; +} diff --git a/lib/supabase/memories/__tests__/deleteMemories.test.ts b/lib/supabase/memories/__tests__/deleteMemories.test.ts new file mode 100644 index 00000000..9921fff0 --- /dev/null +++ b/lib/supabase/memories/__tests__/deleteMemories.test.ts @@ -0,0 +1,104 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import deleteMemories from "@/lib/supabase/memories/deleteMemories"; + +const mockFrom = vi.fn(); +const mockDelete = vi.fn(); +const mockEq = vi.fn(); +const mockGte = vi.fn(); + +let queryError: { message: string } | null = null; + +/** + * Minimal thenable query mock that mirrors the Supabase delete builder. + */ +class MockDeleteQuery implements PromiseLike<{ error: { message: string } | null }> { + /** + * Captures trailing-boundary filter calls. + * + * @param args - Supabase gte arguments. + * @returns The same query instance for chaining. + */ + gte = (...args: [string, string]): MockDeleteQuery => { + mockGte(...args); + return this; + }; + + /** + * Resolves the mocked Supabase query execution result. + * + * @param onfulfilled - Fulfillment callback. + * @param onrejected - Rejection callback. + * @returns Promise-like query resolution. + */ + then( + onfulfilled?: + | ((value: { error: { message: string } | null }) => TResult1 | PromiseLike) + | null, + onrejected?: ((reason: unknown) => TResult2 | PromiseLike) | null, + ): PromiseLike { + return Promise.resolve({ error: queryError }).then(onfulfilled, onrejected); + } +} + +vi.mock("@/lib/supabase/serverClient", () => ({ + default: { + from: (...args: unknown[]) => { + mockFrom(...args); + return { + delete: (...dArgs: unknown[]) => { + mockDelete(...dArgs); + return { + eq: (...eqArgs: unknown[]) => { + mockEq(...eqArgs); + return new MockDeleteQuery(); + }, + }; + }, + }; + }, + }, +})); + +describe("deleteMemories", () => { + const roomId = "123e4567-e89b-42d3-a456-426614174000"; + + beforeEach(() => { + vi.clearAllMocks(); + queryError = null; + }); + + it("deletes all memories in a room when no boundary is provided", async () => { + const result = await deleteMemories(roomId); + + expect(mockFrom).toHaveBeenCalledWith("memories"); + expect(mockEq).toHaveBeenCalledWith("room_id", roomId); + expect(mockGte).not.toHaveBeenCalled(); + expect(result).toBe(true); + }); + + it("applies updated_at boundary when fromTimestamp is provided", async () => { + const result = await deleteMemories(roomId, { + fromTimestamp: "2026-03-31T00:00:00.000Z", + }); + + expect(mockGte).toHaveBeenCalledWith("updated_at", "2026-03-31T00:00:00.000Z"); + expect(result).toBe(true); + }); + + it("returns false for explicit empty fromTimestamp to avoid broad deletes", async () => { + const result = await deleteMemories(roomId, { fromTimestamp: "" }); + + expect(mockGte).not.toHaveBeenCalled(); + expect(result).toBe(false); + }); + + it("returns false when Supabase delete fails", async () => { + queryError = { message: "delete failed" }; + + const result = await deleteMemories(roomId, { + fromTimestamp: "2026-03-31T00:00:00.000Z", + }); + + expect(result).toBe(false); + }); +}); diff --git a/lib/supabase/memories/deleteMemories.ts b/lib/supabase/memories/deleteMemories.ts index ab5594ee..cc83fa81 100644 --- a/lib/supabase/memories/deleteMemories.ts +++ b/lib/supabase/memories/deleteMemories.ts @@ -1,13 +1,32 @@ import supabase from "@/lib/supabase/serverClient"; +interface DeleteMemoriesOptions { + fromTimestamp?: string; +} + /** * Deletes all memories for the given room. * * @param roomId - The room ID whose memories should be deleted. + * @param options - Optional timestamp filter for trailing deletion use cases. * @returns True when the delete operation succeeds. */ -export default async function deleteMemories(roomId: string): Promise { - const { error } = await supabase.from("memories").delete().eq("room_id", roomId); +export default async function deleteMemories( + roomId: string, + options: DeleteMemoriesOptions = {}, +): Promise { + let query = supabase.from("memories").delete().eq("room_id", roomId); + + if ("fromTimestamp" in options) { + if (typeof options.fromTimestamp !== "string" || options.fromTimestamp.trim().length === 0) { + console.error("Invalid fromTimestamp provided for deleteMemories"); + return false; + } + + query = query.gte("updated_at", options.fromTimestamp); + } + + const { error } = await query; if (error) { console.error("Error deleting memories by room id:", error); diff --git a/lib/supabase/memories/selectMemories.ts b/lib/supabase/memories/selectMemories.ts index 95ea547d..f1168162 100644 --- a/lib/supabase/memories/selectMemories.ts +++ b/lib/supabase/memories/selectMemories.ts @@ -8,6 +8,7 @@ import supabase from "../serverClient"; * @param options - Options for the query (ascending order and limit) * @param options.ascending - Whether to order the results by ascending order * @param options.limit - The limit of the results + * @param options.memoryId - Optional memory ID filter within the room * @returns Supabase query result with memories data */ export default async function selectMemories( @@ -15,10 +16,12 @@ export default async function selectMemories( options?: { ascending?: boolean; limit?: number; + memoryId?: string; }, ): Promise[] | null> { const ascending = options?.ascending ?? false; const limit = options?.limit; + const memoryId = options?.memoryId; let query = supabase .from("memories") @@ -26,6 +29,10 @@ export default async function selectMemories( .eq("room_id", roomId) .order("updated_at", { ascending }); + if (memoryId) { + query = query.eq("id", memoryId); + } + if (limit) { query = query.limit(limit); }