Skip to content
Open
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);
}
90 changes: 90 additions & 0 deletions lib/chats/__tests__/deleteTrailingChatMessagesHandler.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
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 deleteMemoriesByRoomIdAfterTimestamp from "@/lib/supabase/memories/deleteMemoriesByRoomIdAfterTimestamp";

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/deleteMemoriesByRoomIdAfterTimestamp", () => ({
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(deleteMemoriesByRoomIdAfterTimestamp).mockResolvedValue(null);

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 with deleted count", async () => {
vi.mocked(validateDeleteTrailingMessagesQuery).mockResolvedValue({
chatId,
fromMessageId,
fromTimestamp: "2026-03-31T00:00:00.000Z",
});
vi.mocked(deleteMemoriesByRoomIdAfterTimestamp).mockResolvedValue(3);

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,
deleted_count: 3,
});
});

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",
});
});
});
135 changes: 135 additions & 0 deletions lib/chats/__tests__/validateDeleteTrailingMessagesQuery.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest, NextResponse } from "next/server";
import { validateDeleteTrailingMessagesQuery } from "@/lib/chats/validateDeleteTrailingMessagesQuery";
import { validateChatAccess } from "@/lib/chats/validateChatAccess";
import selectMemoryById from "@/lib/supabase/memories/selectMemoryById";

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/selectMemoryById", () => ({
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(selectMemoryById).mockResolvedValue(null);

const result = await validateDeleteTrailingMessagesQuery(
createRequest(`?from_message_id=${fromMessageId}`),
chatId,
);
expect(result).toBeInstanceOf(NextResponse);
expect((result as NextResponse).status).toBe(404);
});

it("returns 400 when message does not belong to chat", 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(selectMemoryById).mockResolvedValue({
id: fromMessageId,
room_id: "123e4567-e89b-42d3-a456-426614174999",
updated_at: "2026-03-31T00:00:00.000Z",
});

const result = await validateDeleteTrailingMessagesQuery(
createRequest(`?from_message_id=${fromMessageId}`),
chatId,
);
expect(result).toBeInstanceOf(NextResponse);
expect((result as NextResponse).status).toBe(400);
});

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(selectMemoryById).mockResolvedValue({
id: fromMessageId,
room_id: chatId,
updated_at: "2026-03-31T00:00:00.000Z",
});

const result = await validateDeleteTrailingMessagesQuery(
createRequest(`?from_message_id=${fromMessageId}`),
chatId,
);

expect(result).toEqual({
chatId,
fromMessageId,
fromTimestamp: "2026-03-31T00:00:00.000Z",
});
});
});
52 changes: 52 additions & 0 deletions lib/chats/deleteTrailingChatMessagesHandler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { getCorsHeaders } from "@/lib/networking/getCorsHeaders";
import { validateDeleteTrailingMessagesQuery } from "@/lib/chats/validateDeleteTrailingMessagesQuery";
import deleteMemoriesByRoomIdAfterTimestamp from "@/lib/supabase/memories/deleteMemoriesByRoomIdAfterTimestamp";

/**
* 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 deletedCount = await deleteMemoriesByRoomIdAfterTimestamp(
validated.chatId,
validated.fromTimestamp,
);

if (deletedCount === null) {
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,
deleted_count: deletedCount,
},
{ 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() },
);
}
}
66 changes: 66 additions & 0 deletions lib/chats/validateDeleteTrailingMessagesQuery.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
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 selectMemoryById from "@/lib/supabase/memories/selectMemoryById";

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.
*/
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 selectMemoryById(parsedQuery.data.from_message_id);
if (!memory) {
return NextResponse.json(
{ status: "error", error: "Message not found" },
{ status: 404, headers: getCorsHeaders() },
);
}

if (memory.room_id !== roomResult.room.id) {
return NextResponse.json(
{ status: "error", error: "from_message_id does not belong to this chat" },
{ status: 400, headers: getCorsHeaders() },
);
}

return {
chatId: roomResult.room.id,
fromMessageId: parsedQuery.data.from_message_id,
fromTimestamp: memory.updated_at,
};
}
Loading
Loading