-
Notifications
You must be signed in to change notification settings - Fork 9
Feat(api) - migrate post chat read #624
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
4016b15
feat(chat-workflow): forward Privy JWT as RECOUP_ACCESS_TOKEN
ahmednahima0-beep 39a56ba
refactor(chat-workflow): update response format for chat read endpoints
ahmednahima0-beep 60d65cd
Merge branch 'test' into feat/migrate-post-chat-read
ahmednahima0-beep 5005764
refactor(tests): streamline request creation in chat read tests
ahmednahima0-beep 32f942c
Merge branch 'feat/migrate-post-chat-read' of https://github.com/reco…
ahmednahima0-beep 048f939
Merge branch 'test' into feat/migrate-post-chat-read
sweetmantech File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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
152
lib/sessions/chats/__tests__/validateMarkChatReadRequest.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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"); | ||
| } | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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() }); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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> { | ||
| 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 }; | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Add Zod validation for
sessionId/chatIdbefore database reads.sessionIdandchatIdare consumed without schema validation. Add a validate function (Zod) and short-circuit with a 400 on invalid params before calling Supabase.Suggested direction
As per coding guidelines, "All API endpoints should use a validate function for input parsing using Zod for schema validation".
📝 Committable suggestion
🤖 Prompt for AI Agents