diff --git a/app/api/sessions/[sessionId]/chats/route.ts b/app/api/sessions/[sessionId]/chats/route.ts new file mode 100644 index 000000000..24b965f52 --- /dev/null +++ b/app/api/sessions/[sessionId]/chats/route.ts @@ -0,0 +1,62 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getSessionChatsHandler } from "@/lib/sessions/chats/getSessionChatsHandler"; +import { createSessionChatHandler } from "@/lib/sessions/chats/createSessionChatHandler"; + +/** + * 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 + * + * Lists every chat that belongs to the session, plus the caller's + * default model id. Authenticates via Privy Bearer token or + * `x-api-key`; 404s when the session is missing and 403s when it + * exists but 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. + * @returns A NextResponse with `{ chats, defaultModelId }` on 200, or an error. + */ +export async function GET( + request: NextRequest, + options: { params: Promise<{ sessionId: string }> }, +) { + const { sessionId } = await options.params; + return getSessionChatsHandler(request, sessionId); +} + +/** + * POST /api/sessions/{sessionId}/chats + * + * Creates a new chat in the session. Callers may pass `{ id }` to + * claim a deterministic chat id; the call is idempotent when the id + * already belongs to the same session, and 409s when it belongs to a + * different session. + * + * @param request - The incoming request. + * @param options - Route options containing the async params. + * @param options.params - Route params containing the session id. + * @returns A NextResponse with `{ chat }` on 200, or an error. + */ +export async function POST( + request: NextRequest, + options: { params: Promise<{ sessionId: string }> }, +) { + const { sessionId } = await options.params; + return createSessionChatHandler(request, sessionId); +} + +export const dynamic = "force-dynamic"; +export const fetchCache = "force-no-store"; +export const revalidate = 0; diff --git a/lib/sessions/chats/__tests__/createSessionChatHandler.test.ts b/lib/sessions/chats/__tests__/createSessionChatHandler.test.ts new file mode 100644 index 000000000..efc6797cd --- /dev/null +++ b/lib/sessions/chats/__tests__/createSessionChatHandler.test.ts @@ -0,0 +1,121 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; +import { baseSessionRow } from "@/lib/sessions/__tests__/baseSessionRow"; +import { baseChatRow } from "@/lib/sessions/__tests__/baseChatRow"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: () => ({ "Access-Control-Allow-Origin": "*" }), +})); +vi.mock("@/lib/sessions/chats/validateCreateSessionChatRequest", () => ({ + validateCreateSessionChatRequest: vi.fn(), +})); +vi.mock("@/lib/supabase/chats/selectChats", () => ({ + selectChats: vi.fn(), +})); +vi.mock("@/lib/supabase/chats/insertChat", () => ({ + insertChat: vi.fn(), +})); +vi.mock("@/lib/uuid/generateUUID", () => ({ + generateUUID: vi.fn(() => "generated-uuid"), +})); + +const { validateCreateSessionChatRequest } = await import( + "@/lib/sessions/chats/validateCreateSessionChatRequest" +); +const { selectChats } = await import("@/lib/supabase/chats/selectChats"); +const { insertChat } = await import("@/lib/supabase/chats/insertChat"); +const { createSessionChatHandler } = await import("@/lib/sessions/chats/createSessionChatHandler"); + +const accountId = "acc-uuid-1"; + +function makeReq(): NextRequest { + return new NextRequest("https://example.com/api/sessions/sess_1/chats", { + method: "POST", + }); +} + +function mockValidated(body: { id?: string } = {}) { + vi.mocked(validateCreateSessionChatRequest).mockResolvedValue({ + auth: { accountId, orgId: null, authToken: "tok" }, + session: baseSessionRow({ id: "sess_1", account_id: accountId }), + body, + }); +} + +describe("createSessionChatHandler", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("forwards the NextResponse from validateCreateSessionChatRequest as-is", async () => { + const failure = NextResponse.json({ error: "Invalid chat id" }, { status: 400 }); + vi.mocked(validateCreateSessionChatRequest).mockResolvedValue(failure); + + const res = await createSessionChatHandler(makeReq(), "sess_1"); + expect(res).toBe(failure); + expect(insertChat).not.toHaveBeenCalled(); + }); + + it("returns existing chat when requested id already exists in the same session", async () => { + mockValidated({ id: "chat_existing" }); + vi.mocked(selectChats).mockResolvedValue([ + baseChatRow({ id: "chat_existing", session_id: "sess_1", title: "Existing" }), + ]); + + const res = await createSessionChatHandler(makeReq(), "sess_1"); + expect(res.status).toBe(200); + const body = (await res.json()) as { chat: { id: string; title: string } }; + expect(body.chat.id).toBe("chat_existing"); + expect(body.chat.title).toBe("Existing"); + expect(insertChat).not.toHaveBeenCalled(); + }); + + it("returns 409 when requested id exists in a different session", async () => { + mockValidated({ id: "chat_existing" }); + vi.mocked(selectChats).mockResolvedValue([ + baseChatRow({ id: "chat_existing", session_id: "sess_OTHER" }), + ]); + + const res = await createSessionChatHandler(makeReq(), "sess_1"); + expect(res.status).toBe(409); + expect(await res.json()).toEqual({ error: "Chat ID conflict" }); + expect(insertChat).not.toHaveBeenCalled(); + }); + + it("creates a chat with the requested id when none exists yet", async () => { + mockValidated({ id: "chat_requested" }); + vi.mocked(selectChats).mockResolvedValue([]); + vi.mocked(insertChat).mockResolvedValue( + baseChatRow({ id: "chat_requested", session_id: "sess_1" }), + ); + + const res = await createSessionChatHandler(makeReq(), "sess_1"); + expect(res.status).toBe(200); + const insertArgs = vi.mocked(insertChat).mock.calls[0][0]; + expect(insertArgs.id).toBe("chat_requested"); + expect(insertArgs.session_id).toBe("sess_1"); + expect(insertArgs.title).toBe("New chat"); + }); + + it("creates a chat with a generated id when no id is provided", async () => { + mockValidated({}); + vi.mocked(insertChat).mockResolvedValue( + baseChatRow({ id: "generated-uuid", session_id: "sess_1" }), + ); + + const res = await createSessionChatHandler(makeReq(), "sess_1"); + expect(res.status).toBe(200); + const insertArgs = vi.mocked(insertChat).mock.calls[0][0]; + expect(insertArgs.id).toBe("generated-uuid"); + expect(insertArgs.title).toBe("New chat"); + expect(selectChats).not.toHaveBeenCalled(); + }); + + it("returns 500 when insertChat fails", async () => { + mockValidated({}); + vi.mocked(insertChat).mockResolvedValue(null); + + const res = await createSessionChatHandler(makeReq(), "sess_1"); + expect(res.status).toBe(500); + }); +}); diff --git a/lib/sessions/chats/__tests__/getSessionChatsHandler.test.ts b/lib/sessions/chats/__tests__/getSessionChatsHandler.test.ts new file mode 100644 index 000000000..9cc00e9e3 --- /dev/null +++ b/lib/sessions/chats/__tests__/getSessionChatsHandler.test.ts @@ -0,0 +1,79 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; +import { baseSessionRow } from "@/lib/sessions/__tests__/baseSessionRow"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: () => ({ "Access-Control-Allow-Origin": "*" }), +})); +vi.mock("@/lib/sessions/chats/validateGetSessionChatsRequest", () => ({ + validateGetSessionChatsRequest: vi.fn(), +})); +vi.mock("@/lib/sessions/chats/getChatSummaries", () => ({ + getChatSummaries: vi.fn(), +})); + +const { validateGetSessionChatsRequest } = await import( + "@/lib/sessions/chats/validateGetSessionChatsRequest" +); +const { getChatSummaries } = await import("@/lib/sessions/chats/getChatSummaries"); +const { getSessionChatsHandler } = await import("@/lib/sessions/chats/getSessionChatsHandler"); + +const accountId = "acc-uuid-1"; + +function makeReq(): NextRequest { + return new NextRequest("https://example.com/api/sessions/sess_1/chats"); +} + +function mockValidated() { + vi.mocked(validateGetSessionChatsRequest).mockResolvedValue({ + auth: { accountId, orgId: null, authToken: "tok" }, + session: baseSessionRow({ id: "sess_1", account_id: accountId }), + }); +} + +describe("getSessionChatsHandler", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("forwards the NextResponse from validateGetSessionChatsRequest as-is", async () => { + const failure = NextResponse.json({ error: "Forbidden" }, { status: 403 }); + vi.mocked(validateGetSessionChatsRequest).mockResolvedValue(failure); + + const res = await getSessionChatsHandler(makeReq(), "sess_1"); + expect(res).toBe(failure); + expect(getChatSummaries).not.toHaveBeenCalled(); + }); + + it("returns 200 with summaries from the db and DEFAULT_MODEL", async () => { + mockValidated(); + const summaries = [ + { + id: "chat_1", + sessionId: "sess_1", + title: "First", + modelId: null, + activeStreamId: null, + lastAssistantMessageAt: null, + createdAt: "2026-05-01T00:00:00.000Z", + updatedAt: "2026-05-01T00:00:00.000Z", + hasUnread: false, + isStreaming: false, + }, + ]; + vi.mocked(getChatSummaries).mockResolvedValue(summaries); + + const res = await getSessionChatsHandler(makeReq(), "sess_1"); + expect(res.status).toBe(200); + const body = (await res.json()) as { + chats: typeof summaries; + defaultModelId: string; + }; + expect(body.chats).toEqual(summaries); + expect(body.defaultModelId).toBe("openai/gpt-5-mini"); + expect(getChatSummaries).toHaveBeenCalledWith({ + sessionId: "sess_1", + accountId, + }); + }); +}); diff --git a/lib/sessions/chats/__tests__/validateCreateSessionChatRequest.test.ts b/lib/sessions/chats/__tests__/validateCreateSessionChatRequest.test.ts new file mode 100644 index 000000000..8f77e349b --- /dev/null +++ b/lib/sessions/chats/__tests__/validateCreateSessionChatRequest.test.ts @@ -0,0 +1,156 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; +import { baseSessionRow } from "@/lib/sessions/__tests__/baseSessionRow"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: () => ({ "Access-Control-Allow-Origin": "*" }), +})); +vi.mock("@/lib/auth/validateAuthContext", () => ({ + validateAuthContext: vi.fn(), +})); +vi.mock("@/lib/supabase/sessions/selectSessions", () => ({ + selectSessions: vi.fn(), +})); + +const { validateAuthContext } = await import("@/lib/auth/validateAuthContext"); +const { selectSessions } = await import("@/lib/supabase/sessions/selectSessions"); +const { validateCreateSessionChatRequest } = await import( + "@/lib/sessions/chats/validateCreateSessionChatRequest" +); + +const accountId = "acc-uuid-1"; + +function makeReq(body: unknown): NextRequest { + return new NextRequest("https://example.com/api/sessions/sess_1/chats", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: typeof body === "string" ? body : JSON.stringify(body), + }); +} + +function authedSession() { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId, + orgId: null, + authToken: "tok", + }); + vi.mocked(selectSessions).mockResolvedValue([ + baseSessionRow({ id: "sess_1", account_id: accountId }), + ]); +} + +describe("validateCreateSessionChatRequest", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns 401 when auth fails", async () => { + vi.mocked(validateAuthContext).mockResolvedValue( + NextResponse.json({ error: "Unauthorized" }, { status: 401 }), + ); + + const res = await validateCreateSessionChatRequest(makeReq({}), "sess_1"); + expect(res).toBeInstanceOf(NextResponse); + if (res instanceof NextResponse) { + expect(res.status).toBe(401); + } + }); + + it("returns 404 when session does not exist", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId, + orgId: null, + authToken: "tok", + }); + vi.mocked(selectSessions).mockResolvedValue([]); + + const res = await validateCreateSessionChatRequest(makeReq({}), "sess_missing"); + expect(res).toBeInstanceOf(NextResponse); + if (res instanceof NextResponse) { + expect(res.status).toBe(404); + } + }); + + it("returns 403 when session is owned by a different account", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId, + orgId: null, + authToken: "tok", + }); + vi.mocked(selectSessions).mockResolvedValue([ + baseSessionRow({ id: "sess_1", account_id: "acc-OTHER" }), + ]); + + const res = await validateCreateSessionChatRequest(makeReq({}), "sess_1"); + expect(res).toBeInstanceOf(NextResponse); + if (res instanceof NextResponse) { + expect(res.status).toBe(403); + } + }); + + it("returns 400 with parity error when id is an empty string", async () => { + authedSession(); + + const res = await validateCreateSessionChatRequest(makeReq({ id: "" }), "sess_1"); + expect(res).toBeInstanceOf(NextResponse); + if (res instanceof NextResponse) { + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ error: "Invalid chat id" }); + } + }); + + it("returns 400 when id is not a string", async () => { + authedSession(); + + const res = await validateCreateSessionChatRequest(makeReq({ id: 42 }), "sess_1"); + expect(res).toBeInstanceOf(NextResponse); + if (res instanceof NextResponse) { + expect(res.status).toBe(400); + } + }); + + it("returns { auth, session, body } on an empty body", async () => { + authedSession(); + + const res = await validateCreateSessionChatRequest(makeReq({}), "sess_1"); + expect(res).not.toBeInstanceOf(NextResponse); + if (!(res instanceof NextResponse)) { + expect(res.auth.accountId).toBe(accountId); + expect(res.session.id).toBe("sess_1"); + expect(res.body).toEqual({}); + } + }); + + it("returns { auth, session, body } with a valid id", async () => { + authedSession(); + + const res = await validateCreateSessionChatRequest(makeReq({ id: "chat_abc" }), "sess_1"); + expect(res).not.toBeInstanceOf(NextResponse); + if (!(res instanceof NextResponse)) { + expect(res.body).toEqual({ id: "chat_abc" }); + } + }); + + it("treats malformed JSON like an empty body", async () => { + authedSession(); + + const res = await validateCreateSessionChatRequest(makeReq("{not json"), "sess_1"); + expect(res).not.toBeInstanceOf(NextResponse); + if (!(res instanceof NextResponse)) { + expect(res.body).toEqual({}); + } + }); + + it("ignores unknown fields on the body", async () => { + authedSession(); + + const res = await validateCreateSessionChatRequest( + makeReq({ id: "chat_abc", junk: 1 }), + "sess_1", + ); + expect(res).not.toBeInstanceOf(NextResponse); + if (!(res instanceof NextResponse)) { + expect(res.body).toEqual({ id: "chat_abc" }); + } + }); +}); diff --git a/lib/sessions/chats/__tests__/validateGetSessionChatsRequest.test.ts b/lib/sessions/chats/__tests__/validateGetSessionChatsRequest.test.ts new file mode 100644 index 000000000..b316c54a8 --- /dev/null +++ b/lib/sessions/chats/__tests__/validateGetSessionChatsRequest.test.ts @@ -0,0 +1,96 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; +import { baseSessionRow } from "@/lib/sessions/__tests__/baseSessionRow"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: () => ({ "Access-Control-Allow-Origin": "*" }), +})); +vi.mock("@/lib/auth/validateAuthContext", () => ({ + validateAuthContext: vi.fn(), +})); +vi.mock("@/lib/supabase/sessions/selectSessions", () => ({ + selectSessions: vi.fn(), +})); + +const { validateAuthContext } = await import("@/lib/auth/validateAuthContext"); +const { selectSessions } = await import("@/lib/supabase/sessions/selectSessions"); +const { validateGetSessionChatsRequest } = await import( + "@/lib/sessions/chats/validateGetSessionChatsRequest" +); + +const accountId = "acc-uuid-1"; + +function makeReq(): NextRequest { + return new NextRequest("https://example.com/api/sessions/sess_1/chats"); +} + +describe("validateGetSessionChatsRequest", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("forwards the auth NextResponse when validateAuthContext rejects", async () => { + const failure = NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + vi.mocked(validateAuthContext).mockResolvedValue(failure); + + const res = await validateGetSessionChatsRequest(makeReq(), "sess_1"); + expect(res).toBe(failure); + expect(selectSessions).not.toHaveBeenCalled(); + }); + + it("returns 404 when the session is missing", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId, + orgId: null, + authToken: "tok", + }); + vi.mocked(selectSessions).mockResolvedValue([]); + + const res = await validateGetSessionChatsRequest(makeReq(), "sess_missing"); + expect(res).toBeInstanceOf(NextResponse); + if (res instanceof NextResponse) { + expect(res.status).toBe(404); + expect(await res.json()).toEqual({ + status: "error", + error: "Session not found", + }); + } + }); + + it("returns 403 when the session belongs to a different account", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId, + orgId: null, + authToken: "tok", + }); + vi.mocked(selectSessions).mockResolvedValue([ + baseSessionRow({ id: "sess_1", account_id: "acc-OTHER" }), + ]); + + const res = await validateGetSessionChatsRequest(makeReq(), "sess_1"); + expect(res).toBeInstanceOf(NextResponse); + if (res instanceof NextResponse) { + expect(res.status).toBe(403); + expect(await res.json()).toEqual({ status: "error", error: "Forbidden" }); + } + }); + + it("returns { auth, session } on the happy path", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId, + orgId: null, + authToken: "tok", + }); + vi.mocked(selectSessions).mockResolvedValue([ + baseSessionRow({ id: "sess_1", account_id: accountId }), + ]); + + const res = await validateGetSessionChatsRequest(makeReq(), "sess_1"); + expect(res).not.toBeInstanceOf(NextResponse); + if (!(res instanceof NextResponse)) { + expect(res.auth.accountId).toBe(accountId); + expect(res.session.id).toBe("sess_1"); + } + expect(selectSessions).toHaveBeenCalledWith({ id: "sess_1" }); + }); +}); diff --git a/lib/sessions/chats/createSessionChatHandler.ts b/lib/sessions/chats/createSessionChatHandler.ts new file mode 100644 index 000000000..868691246 --- /dev/null +++ b/lib/sessions/chats/createSessionChatHandler.ts @@ -0,0 +1,69 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { generateUUID } from "@/lib/uuid/generateUUID"; +import { validateCreateSessionChatRequest } from "@/lib/sessions/chats/validateCreateSessionChatRequest"; +import { selectChats } from "@/lib/supabase/chats/selectChats"; +import { insertChat } from "@/lib/supabase/chats/insertChat"; +import { toChatResponse } from "@/lib/sessions/toChatResponse"; + +const INITIAL_CHAT_TITLE = "New chat"; + +/** + * Handles `POST /api/sessions/{sessionId}/chats`. + * + * Callers may pass `{ id }` to claim a deterministic chat id: if a row + * already exists with that id and belongs to this session, it is + * returned as-is (idempotent retry); if it exists on a different + * session, 409 is returned. Otherwise a new chat is inserted with + * title "New chat". + * + * @param request - The incoming request. + * @param sessionId - The id of the parent session. + * @returns A NextResponse with `{ chat }` on 200, `{ error }` on 4xx, or an error. + */ +export async function createSessionChatHandler( + request: NextRequest, + sessionId: string, +): Promise { + const validated = await validateCreateSessionChatRequest(request, sessionId); + if (validated instanceof NextResponse) { + return validated; + } + const { body } = validated; + + const requestedChatId = body.id ?? null; + + if (requestedChatId) { + const existing = (await selectChats({ id: requestedChatId }))[0] ?? null; + if (existing) { + if (existing.session_id !== sessionId) { + return NextResponse.json( + { error: "Chat ID conflict" }, + { status: 409, headers: getCorsHeaders() }, + ); + } + return NextResponse.json( + { chat: toChatResponse(existing) }, + { status: 200, headers: getCorsHeaders() }, + ); + } + } + + const chatRow = await insertChat({ + id: requestedChatId ?? generateUUID(), + session_id: sessionId, + title: INITIAL_CHAT_TITLE, + }); + + if (!chatRow) { + return NextResponse.json( + { status: "error", error: "Failed to create chat" }, + { status: 500, headers: getCorsHeaders() }, + ); + } + + return NextResponse.json( + { chat: toChatResponse(chatRow) }, + { status: 200, headers: getCorsHeaders() }, + ); +} diff --git a/lib/sessions/chats/getChatSummaries.ts b/lib/sessions/chats/getChatSummaries.ts new file mode 100644 index 000000000..bddd1963a --- /dev/null +++ b/lib/sessions/chats/getChatSummaries.ts @@ -0,0 +1,70 @@ +import { selectChats } from "@/lib/supabase/chats/selectChats"; +import { selectChatReads } from "@/lib/supabase/chat_reads/selectChatReads"; + +export interface ChatSummary { + id: string; + sessionId: string; + title: string; + modelId: string | null; + activeStreamId: string | null; + lastAssistantMessageAt: string | null; + createdAt: string; + updatedAt: string; + hasUnread: boolean; + isStreaming: boolean; +} + +/** + * Returns chats in the given session as camelCase `ChatSummary` rows. + * `hasUnread` is derived from the caller's `chat_reads` row (if any); + * `isStreaming` is derived from `active_stream_id`. + * + * @param params - The session id to list and the account id to scope reads to. + * @returns Chat summaries sorted by `createdAt` ascending. + */ +export async function getChatSummaries({ + sessionId, + accountId, +}: { + sessionId: string; + accountId: string; +}): Promise { + const chats = await selectChats({ sessionId }); + if (chats.length === 0) { + return []; + } + + const reads = await selectChatReads({ + accountId, + chatIds: chats.map(row => row.id), + }); + + const lastReadByChatId = new Map(); + for (const read of reads) { + lastReadByChatId.set(read.chat_id, read.last_read_at); + } + + return [...chats] + .sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime()) + .map(row => { + const lastReadAt = lastReadByChatId.get(row.id) ?? null; + const lastAssistantMessageAt = row.last_assistant_message_at; + const hasUnread = + lastAssistantMessageAt !== null && + (lastReadAt === null || + new Date(lastAssistantMessageAt).getTime() > new Date(lastReadAt).getTime()); + + return { + id: row.id, + sessionId: row.session_id, + title: row.title, + modelId: row.model_id, + activeStreamId: row.active_stream_id, + lastAssistantMessageAt, + createdAt: row.created_at, + updatedAt: row.updated_at, + hasUnread, + isStreaming: row.active_stream_id !== null, + }; + }); +} diff --git a/lib/sessions/chats/getSessionChatsHandler.ts b/lib/sessions/chats/getSessionChatsHandler.ts new file mode 100644 index 000000000..f291be55a --- /dev/null +++ b/lib/sessions/chats/getSessionChatsHandler.ts @@ -0,0 +1,34 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { DEFAULT_MODEL } from "@/lib/const"; +import { validateGetSessionChatsRequest } from "@/lib/sessions/chats/validateGetSessionChatsRequest"; +import { getChatSummaries } from "@/lib/sessions/chats/getChatSummaries"; + +/** + * Handles `GET /api/sessions/{sessionId}/chats`. Lists chats in the + * session as camelCase summaries with per-account unread state, plus + * the caller's default model id. + * + * @param request - The incoming request. + * @param sessionId - The id of the parent session. + * @returns A NextResponse with `{ chats, defaultModelId }` on 200, or an error. + */ +export async function getSessionChatsHandler( + request: NextRequest, + sessionId: string, +): Promise { + const validated = await validateGetSessionChatsRequest(request, sessionId); + if (validated instanceof NextResponse) { + return validated; + } + + const chats = await getChatSummaries({ + sessionId, + accountId: validated.auth.accountId, + }); + + return NextResponse.json( + { chats, defaultModelId: DEFAULT_MODEL }, + { status: 200, headers: getCorsHeaders() }, + ); +} diff --git a/lib/sessions/chats/validateCreateSessionChatRequest.ts b/lib/sessions/chats/validateCreateSessionChatRequest.ts new file mode 100644 index 000000000..94e07ca36 --- /dev/null +++ b/lib/sessions/chats/validateCreateSessionChatRequest.ts @@ -0,0 +1,76 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { safeParseJson } from "@/lib/networking/safeParseJson"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import type { AuthContext } from "@/lib/auth/validateAuthContext"; +import { selectSessions } from "@/lib/supabase/sessions/selectSessions"; +import type { Tables } from "@/types/database.types"; + +export const createSessionChatBodySchema = z.object({ + id: z.string({ error: "Invalid chat id" }).min(1, "Invalid chat id").optional(), +}); + +export type CreateSessionChatBody = z.infer; + +export interface ValidatedCreateSessionChatRequest { + auth: AuthContext; + session: Tables<"sessions">; + body: CreateSessionChatBody; +} + +/** + * Validates a `POST /api/sessions/{sessionId}/chats` request end-to-end: + * 1. Authenticates the caller via Privy Bearer / x-api-key + * 2. Loads the session row at the given id + * 3. Confirms the authenticated account owns it + * 4. Parses the JSON body (malformed JSON is treated as empty) + * 5. Validates the optional `{ id }` field + * + * An explicitly empty or non-string id is rejected with a 400 + * `{ error: "Invalid chat id" }`. + * + * @param request - The incoming request. + * @param sessionId - The id of the parent session. + * @returns A NextResponse on failure, or the validated auth + session + body. + */ +export async function validateCreateSessionChatRequest( + request: NextRequest, + sessionId: string, +): Promise { + const auth = await validateAuthContext(request); + if (auth instanceof NextResponse) { + return auth; + } + + const rows = await selectSessions({ id: sessionId }); + const session = rows[0] ?? null; + + if (!session) { + return NextResponse.json( + { status: "error", error: "Session not found" }, + { status: 404, headers: getCorsHeaders() }, + ); + } + + if (session.account_id !== auth.accountId) { + return NextResponse.json( + { status: "error", error: "Forbidden" }, + { status: 403, headers: getCorsHeaders() }, + ); + } + + const rawBody = await safeParseJson(request); + const candidate = + rawBody && typeof rawBody === "object" && !Array.isArray(rawBody) ? rawBody : {}; + + const result = createSessionChatBodySchema.safeParse(candidate); + if (!result.success) { + return NextResponse.json( + { error: "Invalid chat id" }, + { status: 400, headers: getCorsHeaders() }, + ); + } + + return { auth, session, body: result.data }; +} diff --git a/lib/sessions/chats/validateGetSessionChatsRequest.ts b/lib/sessions/chats/validateGetSessionChatsRequest.ts new file mode 100644 index 000000000..593291f22 --- /dev/null +++ b/lib/sessions/chats/validateGetSessionChatsRequest.ts @@ -0,0 +1,53 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import type { AuthContext } from "@/lib/auth/validateAuthContext"; +import { selectSessions } from "@/lib/supabase/sessions/selectSessions"; +import type { Tables } from "@/types/database.types"; + +export interface ValidatedGetSessionChatsRequest { + auth: AuthContext; + session: Tables<"sessions">; +} + +/** + * Validates a `GET /api/sessions/{sessionId}/chats` request end-to-end: + * 1. Authenticates the caller via Privy Bearer / x-api-key + * 2. Loads the session row at the given id + * 3. Confirms the authenticated account owns it + * + * Returns either a 401/403/404 NextResponse describing the first + * failure, or the resolved `{ auth, session }` for the handler. + * + * @param request - The incoming request. + * @param sessionId - The id of the parent session. + * @returns A NextResponse on failure, or the validated auth + session row. + */ +export async function validateGetSessionChatsRequest( + request: NextRequest, + sessionId: string, +): Promise { + const auth = await validateAuthContext(request); + if (auth instanceof NextResponse) { + return auth; + } + + const rows = await selectSessions({ id: sessionId }); + const session = rows[0] ?? null; + + if (!session) { + return NextResponse.json( + { status: "error", error: "Session not found" }, + { status: 404, headers: getCorsHeaders() }, + ); + } + + if (session.account_id !== auth.accountId) { + return NextResponse.json( + { status: "error", error: "Forbidden" }, + { status: 403, headers: getCorsHeaders() }, + ); + } + + return { auth, session }; +} diff --git a/lib/supabase/chat_reads/selectChatReads.ts b/lib/supabase/chat_reads/selectChatReads.ts new file mode 100644 index 000000000..3cb1b3372 --- /dev/null +++ b/lib/supabase/chat_reads/selectChatReads.ts @@ -0,0 +1,34 @@ +import supabase from "@/lib/supabase/serverClient"; +import type { Tables } from "@/types/database.types"; + +interface SelectChatReadsFilter { + /** Required account filter — reads are always scoped to a single account. */ + accountId: string; + /** Optional list of chat ids to narrow the result set. */ + chatIds?: string[]; +} + +/** + * Reads rows from `chat_reads` for a single account. Used to derive + * per-account unread state on chat lists. Returns `[]` on error + * after logging. + * + * @param filter - Required account, optional chat-id list. + * @returns Matching rows, or `[]` on error / no match. + */ +export async function selectChatReads( + filter: SelectChatReadsFilter, +): Promise[]> { + let query = supabase.from("chat_reads").select("*").eq("account_id", filter.accountId); + + if (filter.chatIds && filter.chatIds.length > 0) { + query = query.in("chat_id", filter.chatIds); + } + + const { data, error } = await query; + if (error) { + console.error("[selectChatReads] error:", error); + return []; + } + return data ?? []; +}