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
38 changes: 38 additions & 0 deletions app/api/sessions/[sessionId]/chats/[chatId]/read/route.ts
Original file line number Diff line number Diff line change
@@ -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;
79 changes: 79 additions & 0 deletions lib/sessions/chats/__tests__/markChatReadHandler.test.ts
Original file line number Diff line number Diff line change
@@ -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",
});
});
});
152 changes: 152 additions & 0 deletions lib/sessions/chats/__tests__/validateMarkChatReadRequest.test.ts
Original file line number Diff line number Diff line change
@@ -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");
}
});
});
35 changes: 35 additions & 0 deletions lib/sessions/chats/markChatReadHandler.ts
Original file line number Diff line number Diff line change
@@ -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<NextResponse> {
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() });
}
66 changes: 66 additions & 0 deletions lib/sessions/chats/validateMarkChatReadRequest.ts
Original file line number Diff line number Diff line change
@@ -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<NextResponse | ValidatedMarkChatReadRequest> {
Comment on lines +26 to +30

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Add Zod validation for sessionId/chatId before database reads.

sessionId and chatId are consumed without schema validation. Add a validate function (Zod) and short-circuit with a 400 on invalid params before calling Supabase.

Suggested direction
+import { z } from "zod";
+
+const markChatReadParamsSchema = z.object({
+  sessionId: z.string().uuid(),
+  chatId: z.string().uuid(),
+});
...
 export async function validateMarkChatReadRequest(
   request: NextRequest,
   sessionId: string,
   chatId: string,
 ): Promise<NextResponse | ValidatedMarkChatReadRequest> {
+  const parsed = markChatReadParamsSchema.safeParse({ sessionId, chatId });
+  if (!parsed.success) {
+    return NextResponse.json(
+      { status: "error", error: "Invalid route parameters" },
+      { status: 400, headers: getCorsHeaders() },
+    );
+  }

As per coding guidelines, "All API endpoints should use a validate function for input parsing using Zod for schema validation".

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export async function validateMarkChatReadRequest(
request: NextRequest,
sessionId: string,
chatId: string,
): Promise<NextResponse | ValidatedMarkChatReadRequest> {
import { z } from "zod";
const markChatReadParamsSchema = z.object({
sessionId: z.string().uuid(),
chatId: z.string().uuid(),
});
export async function validateMarkChatReadRequest(
request: NextRequest,
sessionId: string,
chatId: string,
): Promise<NextResponse | ValidatedMarkChatReadRequest> {
const parsed = markChatReadParamsSchema.safeParse({ sessionId, chatId });
if (!parsed.success) {
return NextResponse.json(
{ status: "error", error: "Invalid route parameters" },
{ status: 400, headers: getCorsHeaders() },
);
}
// ... rest of function body continues
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/sessions/chats/validateMarkChatReadRequest.ts` around lines 26 - 30, The
validateMarkChatReadRequest function currently uses sessionId and chatId without
schema checks; add a Zod schema (e.g., z.object({ sessionId: z.string().min(1),
chatId: z.string().min(1) }) or stricter if UUIDs are expected) and run
schema.parse or safeParse at the top of validateMarkChatReadRequest to
short-circuit invalid input with a NextResponse 400 before any Supabase/database
calls, returning the parsed values to continue processing; update the function
signature/return path to use the validated values from the Zod result and ensure
error responses include a clear validation message.

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 };
}
Loading
Loading