diff --git a/app/api/chats/[id]/artist/route.ts b/app/api/chats/[id]/artist/route.ts new file mode 100644 index 00000000..2ec3c371 --- /dev/null +++ b/app/api/chats/[id]/artist/route.ts @@ -0,0 +1,35 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getChatArtistHandler } from "@/lib/chats/getChatArtistHandler"; + +/** + * 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/chats/[id]/artist + * + * Retrieves the artist associated with a chat room. + * Returns 404 when the room does not exist or is not accessible by the caller. + * + * @param request - The incoming request object. + * @param root0 - The route context object. + * @param root0.params - The dynamic route params containing chat id. + * @returns A NextResponse with artist linkage data or an error. + */ +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +): Promise { + const { id } = await params; + return getChatArtistHandler(request, id); +} diff --git a/lib/chats/__tests__/getChatArtistHandler.test.ts b/lib/chats/__tests__/getChatArtistHandler.test.ts new file mode 100644 index 00000000..c88ce650 --- /dev/null +++ b/lib/chats/__tests__/getChatArtistHandler.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; +import { getChatArtistHandler } from "@/lib/chats/getChatArtistHandler"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import selectRoom from "@/lib/supabase/rooms/selectRoom"; +import { buildGetChatsParams } from "@/lib/chats/buildGetChatsParams"; + +vi.mock("@/lib/auth/validateAuthContext", () => ({ + validateAuthContext: vi.fn(), +})); + +vi.mock("@/lib/supabase/rooms/selectRoom", () => ({ + default: vi.fn(), +})); + +vi.mock("@/lib/chats/buildGetChatsParams", () => ({ + buildGetChatsParams: vi.fn(), +})); + +const createRequest = () => new NextRequest("http://localhost/api/chats/chat-id/artist"); + +describe("getChatArtistHandler", () => { + const accountId = "11111111-1111-1111-1111-111111111111"; + const roomId = "123e4567-e89b-42d3-a456-426614174000"; + const artistId = "123e4567-e89b-42d3-a456-426614174001"; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns 400 for invalid chat id", async () => { + const response = await getChatArtistHandler(createRequest(), "invalid-id"); + const body = await response.json(); + + expect(response.status).toBe(400); + expect(body).toEqual({ + status: "error", + error: "id must be a valid UUID", + }); + }); + + it("returns auth error response when auth validation fails", async () => { + vi.mocked(validateAuthContext).mockResolvedValue( + NextResponse.json({ status: "error", error: "Unauthorized" }, { status: 401 }), + ); + + const response = await getChatArtistHandler(createRequest(), roomId); + const body = await response.json(); + + expect(response.status).toBe(401); + expect(body).toEqual({ + status: "error", + error: "Unauthorized", + }); + }); + + it("returns 404 when room does not exist", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ accountId, orgId: null }); + vi.mocked(selectRoom).mockResolvedValue(null); + + const response = await getChatArtistHandler(createRequest(), roomId); + const body = await response.json(); + + expect(response.status).toBe(404); + expect(body).toEqual({ + status: "error", + error: "Chat room not found", + }); + }); + + it("returns 404 when user has no access to another account's room", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ accountId, orgId: null }); + vi.mocked(selectRoom).mockResolvedValue({ + id: roomId, + account_id: "123e4567-e89b-42d3-a456-426614174002", + artist_id: artistId, + topic: "Test", + updated_at: null, + }); + vi.mocked(buildGetChatsParams).mockResolvedValue({ + params: { account_ids: [accountId] }, + error: null, + }); + + const response = await getChatArtistHandler(createRequest(), roomId); + const body = await response.json(); + + expect(response.status).toBe(403); + expect(body).toEqual({ + status: "error", + error: "Access denied to this chat", + }); + }); + + it("returns chat artist for room owner", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ accountId, orgId: null }); + vi.mocked(selectRoom).mockResolvedValue({ + id: roomId, + account_id: accountId, + artist_id: artistId, + topic: "Test", + updated_at: null, + }); + vi.mocked(buildGetChatsParams).mockResolvedValue({ + params: { account_ids: [accountId] }, + error: null, + }); + + const response = await getChatArtistHandler(createRequest(), roomId); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body).toEqual({ + status: "success", + room_id: roomId, + artist_id: artistId, + artist_exists: true, + }); + }); + + it("returns null artist fields when chat has no artist", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ accountId, orgId: null }); + vi.mocked(selectRoom).mockResolvedValue({ + id: roomId, + account_id: accountId, + artist_id: null, + topic: "Test", + updated_at: null, + }); + vi.mocked(buildGetChatsParams).mockResolvedValue({ + params: { account_ids: [accountId] }, + error: null, + }); + + const response = await getChatArtistHandler(createRequest(), roomId); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body).toEqual({ + status: "success", + room_id: roomId, + artist_id: null, + artist_exists: false, + }); + }); +}); diff --git a/lib/chats/__tests__/validateChatAccess.test.ts b/lib/chats/__tests__/validateChatAccess.test.ts index 00bb430b..4c925544 100644 --- a/lib/chats/__tests__/validateChatAccess.test.ts +++ b/lib/chats/__tests__/validateChatAccess.test.ts @@ -157,6 +157,6 @@ describe("validateChatAccess", () => { }); const result = await validateChatAccess(request, roomId); - expect(result).toEqual({ roomId }); + expect(result).toEqual({ roomId, room, accountId }); }); }); diff --git a/lib/chats/getChatArtistHandler.ts b/lib/chats/getChatArtistHandler.ts new file mode 100644 index 00000000..ad04d8dc --- /dev/null +++ b/lib/chats/getChatArtistHandler.ts @@ -0,0 +1,33 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateChatAccess } from "@/lib/chats/validateChatAccess"; + +/** + * Handles GET /api/chats/[id]/artist. + * + * Returns the artist associated with a chat room if accessible by the caller. + * + * @param request - The incoming request object for auth context. + * @param id - The chat room ID from route params. + * @returns A NextResponse with artist linkage data or an error. + */ +export async function getChatArtistHandler( + request: NextRequest, + id: string, +): Promise { + const roomResult = await validateChatAccess(request, id); + if (roomResult instanceof NextResponse) { + return roomResult; + } + + return NextResponse.json( + { + status: "success", + room_id: roomResult.room.id, + artist_id: roomResult.room.artist_id, + artist_exists: Boolean(roomResult.room.artist_id), + }, + { status: 200, headers: getCorsHeaders() }, + ); +} diff --git a/lib/chats/validateChatAccess.ts b/lib/chats/validateChatAccess.ts index 4e40c13f..30f89e5d 100644 --- a/lib/chats/validateChatAccess.ts +++ b/lib/chats/validateChatAccess.ts @@ -5,9 +5,12 @@ import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { validateAuthContext } from "@/lib/auth/validateAuthContext"; import selectRoom from "@/lib/supabase/rooms/selectRoom"; import { buildGetChatsParams } from "@/lib/chats/buildGetChatsParams"; +import type { Tables } from "@/types/database.types"; export interface ValidatedChatAccess { roomId: string; + room: Tables<"rooms">; + accountId: string; } const chatIdSchema = z.string().uuid("id must be a valid UUID"); @@ -17,7 +20,7 @@ const chatIdSchema = z.string().uuid("id must be a valid UUID"); * * @param request - The incoming request (used for auth context) * @param roomId - The room/chat UUID to validate access for - * @returns NextResponse on auth/access failure, or validated roomId + * @returns NextResponse on auth/access failure, or validated access data */ export async function validateChatAccess( request: NextRequest, @@ -64,5 +67,5 @@ export async function validateChatAccess( ); } - return { roomId: room.id }; + return { roomId: room.id, room, accountId }; }