Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions app/api/chats/[id]/messages/trailing/route.ts
Original file line number Diff line number Diff line change
@@ -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<NextResponse> {
const { id } = await params;
return deleteTrailingChatMessagesHandler(request, id);
}
89 changes: 89 additions & 0 deletions lib/chats/__tests__/deleteTrailingChatMessagesHandler.test.ts
Original file line number Diff line number Diff line change
@@ -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",
});
});
});
112 changes: 112 additions & 0 deletions lib/chats/__tests__/validateDeleteTrailingMessagesQuery.test.ts
Original file line number Diff line number Diff line change
@@ -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",
});
});
});
50 changes: 50 additions & 0 deletions lib/chats/deleteTrailingChatMessagesHandler.ts
Original file line number Diff line number Diff line change
@@ -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<NextResponse> {
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() },
);
}
}
67 changes: 67 additions & 0 deletions lib/chats/validateDeleteTrailingMessagesQuery.ts
Original file line number Diff line number Diff line change
@@ -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<NextResponse | ValidatedDeleteTrailingMessagesQuery> {
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,
};
}
Loading
Loading