diff --git a/app/api/sessions/[sessionId]/chats/[chatId]/read/route.ts b/app/api/sessions/[sessionId]/chats/[chatId]/read/route.ts new file mode 100644 index 000000000..e67d3fe06 --- /dev/null +++ b/app/api/sessions/[sessionId]/chats/[chatId]/read/route.ts @@ -0,0 +1,38 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { markChatReadHandler } from "@/lib/sessions/chats/markChatReadHandler"; + +/** + * OPTIONS handler for CORS preflight requests. + * + * @returns A NextResponse with CORS headers. + */ +export async function OPTIONS() { + return new NextResponse(null, { + status: 200, + headers: getCorsHeaders(), + }); +} + +/** + * POST /api/sessions/{sessionId}/chats/{chatId}/read + * + * Marks a chat as read for the authenticated account. Authenticates via + * Privy Bearer token or x-api-key header. + * + * @param request - The incoming request. + * @param options - Route options containing the async params. + * @param options.params - Route params containing sessionId and chatId. + * @returns A NextResponse with `{ success: true }` on 200, or an error. + */ +export async function POST( + request: NextRequest, + options: { params: Promise<{ sessionId: string; chatId: string }> }, +) { + const { sessionId, chatId } = await options.params; + return markChatReadHandler(request, sessionId, chatId); +} + +export const dynamic = "force-dynamic"; +export const fetchCache = "force-no-store"; +export const revalidate = 0; diff --git a/lib/sessions/chats/__tests__/markChatReadHandler.test.ts b/lib/sessions/chats/__tests__/markChatReadHandler.test.ts new file mode 100644 index 000000000..457fb95df --- /dev/null +++ b/lib/sessions/chats/__tests__/markChatReadHandler.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"; +import { baseChatRow } from "@/lib/sessions/__tests__/baseChatRow"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: () => ({ "Access-Control-Allow-Origin": "*" }), +})); +vi.mock("@/lib/sessions/chats/validateMarkChatReadRequest", () => ({ + validateMarkChatReadRequest: vi.fn(), +})); +vi.mock("@/lib/supabase/chat_reads/upsertChatRead", () => ({ + upsertChatRead: vi.fn(), +})); + +const { validateMarkChatReadRequest } = await import( + "@/lib/sessions/chats/validateMarkChatReadRequest" +); +const { upsertChatRead } = await import("@/lib/supabase/chat_reads/upsertChatRead"); +const { markChatReadHandler } = await import("@/lib/sessions/chats/markChatReadHandler"); + +const accountId = "acc-uuid-1"; + +function makeReq(): NextRequest { + return new NextRequest("https://example.com/api/sessions/sess_1/chats/chat_1/read", { + method: "POST", + }); +} + +function mockValidated() { + vi.mocked(validateMarkChatReadRequest).mockResolvedValue({ + auth: { accountId, orgId: null, authToken: "tok" }, + session: baseSessionRow({ id: "sess_1", account_id: accountId }), + chat: baseChatRow({ id: "chat_1", session_id: "sess_1" }), + }); +} + +describe("markChatReadHandler", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("forwards the NextResponse from validateMarkChatReadRequest as-is", async () => { + const failure = NextResponse.json({ status: "error", error: "Forbidden" }, { status: 403 }); + vi.mocked(validateMarkChatReadRequest).mockResolvedValue(failure); + + const res = await markChatReadHandler(makeReq(), "sess_1", "chat_1"); + expect(res).toBe(failure); + expect(upsertChatRead).not.toHaveBeenCalled(); + }); + + it("returns { success: true } when upsert succeeds", async () => { + mockValidated(); + vi.mocked(upsertChatRead).mockResolvedValue({ + account_id: accountId, + chat_id: "chat_1", + last_read_at: "2026-05-21T00:00:00.000Z", + created_at: "2026-05-21T00:00:00.000Z", + updated_at: "2026-05-21T00:00:00.000Z", + }); + + const res = await markChatReadHandler(makeReq(), "sess_1", "chat_1"); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ success: true }); + expect(upsertChatRead).toHaveBeenCalledWith(accountId, "chat_1"); + }); + + it("returns 500 when upsertChatRead fails", async () => { + mockValidated(); + vi.mocked(upsertChatRead).mockResolvedValue(null); + + const res = await markChatReadHandler(makeReq(), "sess_1", "chat_1"); + expect(res.status).toBe(500); + expect(await res.json()).toEqual({ + status: "error", + error: "Failed to mark chat as read", + }); + }); +}); diff --git a/lib/sessions/chats/__tests__/validateMarkChatReadRequest.test.ts b/lib/sessions/chats/__tests__/validateMarkChatReadRequest.test.ts new file mode 100644 index 000000000..3149e6e24 --- /dev/null +++ b/lib/sessions/chats/__tests__/validateMarkChatReadRequest.test.ts @@ -0,0 +1,152 @@ +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/auth/validateAuthContext", () => ({ + validateAuthContext: vi.fn(), +})); +vi.mock("@/lib/supabase/sessions/selectSessions", () => ({ + selectSessions: vi.fn(), +})); +vi.mock("@/lib/supabase/chats/selectChats", () => ({ + selectChats: vi.fn(), +})); + +const { validateAuthContext } = await import("@/lib/auth/validateAuthContext"); +const { selectSessions } = await import("@/lib/supabase/sessions/selectSessions"); +const { selectChats } = await import("@/lib/supabase/chats/selectChats"); +const { validateMarkChatReadRequest } = await import( + "@/lib/sessions/chats/validateMarkChatReadRequest" +); + +const accountId = "acc-uuid-1"; + +function makeReq(): NextRequest { + return new NextRequest("https://example.com/api/sessions/sess_1/chats/chat_1/read", { + method: "POST", + }); +} + +describe("validateMarkChatReadRequest", () => { + 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 validateMarkChatReadRequest(makeReq(), "sess_1", "chat_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([]); + vi.mocked(selectChats).mockResolvedValue([baseChatRow({ id: "chat_1" })]); + + const res = await validateMarkChatReadRequest(makeReq(), "sess_missing", "chat_1"); + 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" }), + ]); + vi.mocked(selectChats).mockResolvedValue([baseChatRow({ id: "chat_1", session_id: "sess_1" })]); + + const res = await validateMarkChatReadRequest(makeReq(), "sess_1", "chat_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 404 when the chat is missing", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId, + orgId: null, + authToken: "tok", + }); + vi.mocked(selectSessions).mockResolvedValue([ + baseSessionRow({ id: "sess_1", account_id: accountId }), + ]); + vi.mocked(selectChats).mockResolvedValue([]); + + const res = await validateMarkChatReadRequest(makeReq(), "sess_1", "chat_missing"); + expect(res).toBeInstanceOf(NextResponse); + if (res instanceof NextResponse) { + expect(res.status).toBe(404); + expect(await res.json()).toEqual({ + status: "error", + error: "Chat not found", + }); + } + }); + + it("returns 404 when the chat belongs to a different session", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId, + orgId: null, + authToken: "tok", + }); + vi.mocked(selectSessions).mockResolvedValue([ + baseSessionRow({ id: "sess_1", account_id: accountId }), + ]); + vi.mocked(selectChats).mockResolvedValue([ + baseChatRow({ id: "chat_1", session_id: "sess_OTHER" }), + ]); + + const res = await validateMarkChatReadRequest(makeReq(), "sess_1", "chat_1"); + expect(res).toBeInstanceOf(NextResponse); + if (res instanceof NextResponse) { + expect(res.status).toBe(404); + expect(await res.json()).toEqual({ + status: "error", + error: "Chat not found", + }); + } + }); + + it("returns { auth, session, chat } 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 }), + ]); + vi.mocked(selectChats).mockResolvedValue([baseChatRow({ id: "chat_1", session_id: "sess_1" })]); + + const res = await validateMarkChatReadRequest(makeReq(), "sess_1", "chat_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.chat.id).toBe("chat_1"); + } + }); +}); diff --git a/lib/sessions/chats/markChatReadHandler.ts b/lib/sessions/chats/markChatReadHandler.ts new file mode 100644 index 000000000..39223bc89 --- /dev/null +++ b/lib/sessions/chats/markChatReadHandler.ts @@ -0,0 +1,35 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateMarkChatReadRequest } from "@/lib/sessions/chats/validateMarkChatReadRequest"; +import { upsertChatRead } from "@/lib/supabase/chat_reads/upsertChatRead"; + +/** + * Handles `POST /api/sessions/{sessionId}/chats/{chatId}/read`. + * Upserts `chat_reads.last_read_at` for the authenticated account so + * subsequent list-chats calls report `hasUnread: false` for this chat. + * + * @param request - The incoming request. + * @param sessionId - The parent session id. + * @param chatId - The chat id to mark as read. + * @returns A NextResponse with `{ success: true }` on 200, or an error. + */ +export async function markChatReadHandler( + request: NextRequest, + sessionId: string, + chatId: string, +): Promise { + const validated = await validateMarkChatReadRequest(request, sessionId, chatId); + if (validated instanceof NextResponse) { + return validated; + } + + const row = await upsertChatRead(validated.auth.accountId, chatId); + if (!row) { + return NextResponse.json( + { status: "error", error: "Failed to mark chat as read" }, + { status: 500, headers: getCorsHeaders() }, + ); + } + + return NextResponse.json({ success: true }, { status: 200, headers: getCorsHeaders() }); +} diff --git a/lib/sessions/chats/validateMarkChatReadRequest.ts b/lib/sessions/chats/validateMarkChatReadRequest.ts new file mode 100644 index 000000000..46ff53c61 --- /dev/null +++ b/lib/sessions/chats/validateMarkChatReadRequest.ts @@ -0,0 +1,66 @@ +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 { selectChats } from "@/lib/supabase/chats/selectChats"; +import type { Tables } from "@/types/database.types"; + +export interface ValidatedMarkChatReadRequest { + auth: AuthContext; + session: Tables<"sessions">; + chat: Tables<"chats">; +} + +/** + * Validates `POST /api/sessions/{sessionId}/chats/{chatId}/read`: + * 1. Authenticates the caller + * 2. Loads the session and chat + * 3. Confirms the account owns the session and the chat belongs to it + * + * @param request - The incoming request. + * @param sessionId - The parent session id. + * @param chatId - The chat id to mark as read. + * @returns A NextResponse on failure, or the validated auth + session + chat. + */ +export async function validateMarkChatReadRequest( + request: NextRequest, + sessionId: string, + chatId: string, +): Promise { + const auth = await validateAuthContext(request); + if (auth instanceof NextResponse) { + return auth; + } + + const [sessionRows, chatRows] = await Promise.all([ + selectSessions({ id: sessionId }), + selectChats({ id: chatId }), + ]); + + const session = sessionRows[0] ?? null; + const chat = chatRows[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() }, + ); + } + + if (!chat || chat.session_id !== sessionId) { + return NextResponse.json( + { status: "error", error: "Chat not found" }, + { status: 404, headers: getCorsHeaders() }, + ); + } + + return { auth, session, chat }; +} diff --git a/lib/supabase/chat_reads/upsertChatRead.ts b/lib/supabase/chat_reads/upsertChatRead.ts new file mode 100644 index 000000000..a36121ab7 --- /dev/null +++ b/lib/supabase/chat_reads/upsertChatRead.ts @@ -0,0 +1,38 @@ +import supabase from "@/lib/supabase/serverClient"; +import type { Tables } from "@/types/database.types"; + +/** + * Upserts a `chat_reads` row for the given account and chat, setting + * `last_read_at` to the current time. Idempotent — safe to call repeatedly. + * + * @param accountId - The owning account id. + * @param chatId - The chat id to mark as read. + * @returns The upserted row, or `null` if the write failed. + */ +export async function upsertChatRead( + accountId: string, + chatId: string, +): Promise | null> { + const now = new Date().toISOString(); + + const { data, error } = await supabase + .from("chat_reads") + .upsert( + { + account_id: accountId, + chat_id: chatId, + last_read_at: now, + updated_at: now, + }, + { onConflict: "account_id,chat_id" }, + ) + .select() + .maybeSingle(); + + if (error) { + console.error("[upsertChatRead] error:", error); + return null; + } + + return data; +}