Skip to content
81 changes: 81 additions & 0 deletions app/api/sessions/[sessionId]/chats/[chatId]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { NextRequest, NextResponse } from "next/server";
import { getCorsHeaders } from "@/lib/networking/getCorsHeaders";
import { getSessionChatHandler } from "@/lib/sessions/chats/getSessionChatHandler";
import { patchSessionChatHandler } from "@/lib/sessions/chats/patchSessionChatHandler";
import { deleteSessionChatHandler } from "@/lib/sessions/chats/deleteSessionChatHandler";

/**
* OPTIONS handler for CORS preflight requests.
*
* @returns A NextResponse with CORS headers.
*/
export async function OPTIONS() {
return new NextResponse(null, {
status: 200,
headers: getCorsHeaders(),
});
}

/**
* GET /api/sessions/{sessionId}/chats/{chatId}
*
* Returns the chat's persisted UI message stream plus its current
* streaming state. Authenticates via Privy Bearer token or
* `x-api-key`; 404s when the session or chat is missing (or the chat
* lives in a different session) and 403s when the session is owned by
* a different account.
*
* @param request - The incoming request.
* @param options - Route options containing the async params.
* @param options.params - Route params containing the session id and chat id.
* @returns A NextResponse with `{ chat, isStreaming, messages }` on 200, or an error.
*/
export async function GET(
request: NextRequest,
options: { params: Promise<{ sessionId: string; chatId: string }> },
) {
const { sessionId, chatId } = await options.params;
return getSessionChatHandler(request, sessionId, chatId);
}

/**
* PATCH /api/sessions/{sessionId}/chats/{chatId}
*
* Applies a partial update to the chat (`title` and/or `modelId`).
* Body must include at least one of those non-empty fields.
*
* @param request - The incoming request.
* @param options - Route options containing the async params.
* @param options.params - Route params containing the session id and chat id.
* @returns A NextResponse with `{ chat }` on 200, or an error.
*/
export async function PATCH(
request: NextRequest,
options: { params: Promise<{ sessionId: string; chatId: string }> },
) {
const { sessionId, chatId } = await options.params;
return patchSessionChatHandler(request, sessionId, chatId);
}

/**
* DELETE /api/sessions/{sessionId}/chats/{chatId}
*
* Removes the chat (cascade clears `chat_messages` / `chat_reads`).
* Refuses with 400 if the chat is the only one in its session.
*
* @param request - The incoming request.
* @param options - Route options containing the async params.
* @param options.params - Route params containing the session id and chat id.
* @returns A NextResponse with `{ success: true }` on 200, or an error.
*/
export async function DELETE(
request: NextRequest,
options: { params: Promise<{ sessionId: string; chatId: string }> },
) {
const { sessionId, chatId } = await options.params;
return deleteSessionChatHandler(request, sessionId, chatId);
}

export const dynamic = "force-dynamic";
export const fetchCache = "force-no-store";
export const revalidate = 0;
60 changes: 60 additions & 0 deletions lib/sessions/chats/__tests__/deleteSessionChatHandler.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest, NextResponse } from "next/server";

vi.mock("@/lib/networking/getCorsHeaders", () => ({
getCorsHeaders: () => ({ "Access-Control-Allow-Origin": "*" }),
}));
vi.mock("@/lib/sessions/chats/validateDeleteSessionChatRequest", () => ({
validateDeleteSessionChatRequest: vi.fn(),
}));
vi.mock("@/lib/supabase/chats/deleteChat", () => ({
deleteChat: vi.fn(),
}));

const { validateDeleteSessionChatRequest } = await import(
"@/lib/sessions/chats/validateDeleteSessionChatRequest"
);
const { deleteChat } = await import("@/lib/supabase/chats/deleteChat");
const { deleteSessionChatHandler } = await import("@/lib/sessions/chats/deleteSessionChatHandler");

function makeReq(): NextRequest {
return new NextRequest("https://example.com/api/sessions/sess_1/chats/chat_1", {
method: "DELETE",
});
}

describe("deleteSessionChatHandler", () => {
beforeEach(() => {
vi.clearAllMocks();
});

it("forwards the NextResponse from the validator as-is", async () => {
const failure = NextResponse.json(
{ error: "Cannot delete the only chat in a session" },
{ status: 400 },
);
vi.mocked(validateDeleteSessionChatRequest).mockResolvedValue(failure);

const res = await deleteSessionChatHandler(makeReq(), "sess_1", "chat_1");
expect(res).toBe(failure);
expect(deleteChat).not.toHaveBeenCalled();
});

it("returns { success: true } on the happy path", async () => {
vi.mocked(validateDeleteSessionChatRequest).mockResolvedValue(null);
vi.mocked(deleteChat).mockResolvedValue(true);

const res = await deleteSessionChatHandler(makeReq(), "sess_1", "chat_1");
expect(res.status).toBe(200);
expect(deleteChat).toHaveBeenCalledWith("chat_1");
expect(await res.json()).toEqual({ success: true });
});

it("returns 500 when deleteChat reports failure", async () => {
vi.mocked(validateDeleteSessionChatRequest).mockResolvedValue(null);
vi.mocked(deleteChat).mockResolvedValue(false);

const res = await deleteSessionChatHandler(makeReq(), "sess_1", "chat_1");
expect(res.status).toBe(500);
});
});
116 changes: 116 additions & 0 deletions lib/sessions/chats/__tests__/getSessionChatHandler.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest, NextResponse } from "next/server";
import { baseChatRow } from "@/lib/sessions/__tests__/baseChatRow";

vi.mock("@/lib/networking/getCorsHeaders", () => ({
getCorsHeaders: () => ({ "Access-Control-Allow-Origin": "*" }),
}));
vi.mock("@/lib/sessions/chats/validateGetSessionChatRequest", () => ({
validateGetSessionChatRequest: vi.fn(),
}));
vi.mock("@/lib/supabase/chat_messages/selectChatMessages", () => ({
selectChatMessages: vi.fn(),
}));

const { validateGetSessionChatRequest } = await import(
"@/lib/sessions/chats/validateGetSessionChatRequest"
);
const { selectChatMessages } = await import("@/lib/supabase/chat_messages/selectChatMessages");
const { getSessionChatHandler } = await import("@/lib/sessions/chats/getSessionChatHandler");

function makeReq(): NextRequest {
return new NextRequest("https://example.com/api/sessions/sess_1/chats/chat_1");
}

function mockValidated(chatOverrides: Parameters<typeof baseChatRow>[0] = {}) {
vi.mocked(validateGetSessionChatRequest).mockResolvedValue(
baseChatRow({ id: "chat_1", session_id: "sess_1", ...chatOverrides }),
);
}

describe("getSessionChatHandler", () => {
beforeEach(() => {
vi.clearAllMocks();
});

it("forwards the NextResponse from validateGetSessionChatRequest as-is", async () => {
const failure = NextResponse.json({ error: "Forbidden" }, { status: 403 });
vi.mocked(validateGetSessionChatRequest).mockResolvedValue(failure);

const res = await getSessionChatHandler(makeReq(), "sess_1", "chat_1");
expect(res).toBe(failure);
expect(selectChatMessages).not.toHaveBeenCalled();
});

it("returns 200 with chat, isStreaming=false, and message parts", async () => {
mockValidated({ active_stream_id: null, model_id: "openai/gpt-5-mini" });
vi.mocked(selectChatMessages).mockResolvedValue([
{
id: "msg_1",
chat_id: "chat_1",
role: "user",
parts: [{ type: "text", text: "hi" }],
created_at: "2026-05-01T00:00:00.000Z",
},
{
id: "msg_2",
chat_id: "chat_1",
role: "assistant",
parts: [{ type: "text", text: "hello" }],
created_at: "2026-05-01T00:00:01.000Z",
},
]);

const res = await getSessionChatHandler(makeReq(), "sess_1", "chat_1");
expect(res.status).toBe(200);
const body = (await res.json()) as {
chat: {
id: string;
sessionId: string;
title: string;
modelId: string | null;
activeStreamId: string | null;
lastAssistantMessageAt: string | null;
createdAt: string;
updatedAt: string;
};
isStreaming: boolean;
messages: unknown[];
};
expect(body.chat).toEqual({
id: "chat_1",
sessionId: "sess_1",
title: "New chat",
modelId: "openai/gpt-5-mini",
activeStreamId: null,
lastAssistantMessageAt: null,
createdAt: "2026-05-04T00:00:00.000Z",
updatedAt: "2026-05-04T00:00:00.000Z",
});
expect(body.isStreaming).toBe(false);
expect(body.messages).toEqual([
[{ type: "text", text: "hi" }],
[{ type: "text", text: "hello" }],
]);
expect(selectChatMessages).toHaveBeenCalledWith({
chatId: "chat_1",
orderBy: { createdAt: "asc" },
});
});

it("derives isStreaming=true from active_stream_id", async () => {
mockValidated({ active_stream_id: "stream-xyz" });
vi.mocked(selectChatMessages).mockResolvedValue([]);

const res = await getSessionChatHandler(makeReq(), "sess_1", "chat_1");
expect(res.status).toBe(200);
const body = (await res.json()) as {
chat: { activeStreamId: string | null };
isStreaming: boolean;
messages: unknown[];
};
expect(body.isStreaming).toBe(true);
expect(body.chat.activeStreamId).toBe("stream-xyz");
expect(body.messages).toEqual([]);
});
});
102 changes: 102 additions & 0 deletions lib/sessions/chats/__tests__/patchSessionChatHandler.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest, NextResponse } from "next/server";
import { baseChatRow } from "@/lib/sessions/__tests__/baseChatRow";

vi.mock("@/lib/networking/getCorsHeaders", () => ({
getCorsHeaders: () => ({ "Access-Control-Allow-Origin": "*" }),
}));
vi.mock("@/lib/sessions/chats/validatePatchSessionChatRequest", () => ({
validatePatchSessionChatRequest: vi.fn(),
}));
vi.mock("@/lib/supabase/chats/updateChat", () => ({
updateChat: vi.fn(),
}));

const { validatePatchSessionChatRequest } = await import(
"@/lib/sessions/chats/validatePatchSessionChatRequest"
);
const { updateChat } = await import("@/lib/supabase/chats/updateChat");
const { patchSessionChatHandler } = await import("@/lib/sessions/chats/patchSessionChatHandler");

function makeReq(): NextRequest {
return new NextRequest("https://example.com/api/sessions/sess_1/chats/chat_1", {
method: "PATCH",
});
}

function mockValidated(patch: { title?: string; modelId?: string }) {
vi.mocked(validatePatchSessionChatRequest).mockResolvedValue(patch);
}

describe("patchSessionChatHandler", () => {
beforeEach(() => {
vi.clearAllMocks();
});

it("forwards the NextResponse from the validator as-is", async () => {
const failure = NextResponse.json({ error: "Forbidden" }, { status: 403 });
vi.mocked(validatePatchSessionChatRequest).mockResolvedValue(failure);

const res = await patchSessionChatHandler(makeReq(), "sess_1", "chat_1");
expect(res).toBe(failure);
expect(updateChat).not.toHaveBeenCalled();
});

it("maps modelId to model_id and stores title verbatim", async () => {
mockValidated({ title: "Renamed", modelId: "openai/gpt-5-mini" });
vi.mocked(updateChat).mockResolvedValue({
ok: true,
rowsUpdated: 1,
row: baseChatRow({
id: "chat_1",
title: "Renamed",
model_id: "openai/gpt-5-mini",
}),
});

const res = await patchSessionChatHandler(makeReq(), "sess_1", "chat_1");
expect(res.status).toBe(200);
expect(updateChat).toHaveBeenCalledWith(
{ id: "chat_1" },
{ title: "Renamed", model_id: "openai/gpt-5-mini" },
);
const body = (await res.json()) as { chat: { title: string; modelId: string } };
expect(body.chat.title).toBe("Renamed");
expect(body.chat.modelId).toBe("openai/gpt-5-mini");
});

it("only patches the field provided", async () => {
mockValidated({ title: "Just a title" });
vi.mocked(updateChat).mockResolvedValue({
ok: true,
rowsUpdated: 1,
row: baseChatRow({ id: "chat_1", title: "Just a title" }),
});

await patchSessionChatHandler(makeReq(), "sess_1", "chat_1");
expect(updateChat).toHaveBeenCalledWith(
{ id: "chat_1" },
{ title: "Just a title", model_id: undefined },
);
});

it("returns 500 when updateChat fails", async () => {
mockValidated({ title: "Renamed" });
vi.mocked(updateChat).mockResolvedValue({ ok: false, error: "db down" });

const res = await patchSessionChatHandler(makeReq(), "sess_1", "chat_1");
expect(res.status).toBe(500);
});

it("returns 500 when updateChat matches no row", async () => {
mockValidated({ title: "Renamed" });
vi.mocked(updateChat).mockResolvedValue({
ok: true,
rowsUpdated: 0,
row: null,
});

const res = await patchSessionChatHandler(makeReq(), "sess_1", "chat_1");
expect(res.status).toBe(500);
});
});
Loading
Loading