diff --git a/app/api/chats/route.ts b/app/api/chats/route.ts index 3dac0d5e..3a307095 100644 --- a/app/api/chats/route.ts +++ b/app/api/chats/route.ts @@ -4,6 +4,7 @@ import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { createChatHandler } from "@/lib/chats/createChatHandler"; import { getChatsHandler } from "@/lib/chats/getChatsHandler"; import { updateChatHandler } from "@/lib/chats/updateChatHandler"; +import { deleteChatHandler } from "@/lib/chats/deleteChatHandler"; /** * OPTIONS handler for CORS preflight requests. @@ -72,3 +73,20 @@ export async function POST(request: NextRequest): Promise { export async function PATCH(request: NextRequest): Promise { return updateChatHandler(request); } + +/** + * DELETE /api/chats + * + * Delete a chat room and related records. + * + * Authentication: x-api-key header or Authorization Bearer token required. + * + * Body parameters: + * - id (required): UUID of the chat room to delete + * + * @param request - The request object + * @returns A NextResponse with deletion result or an error + */ +export async function DELETE(request: NextRequest): Promise { + return deleteChatHandler(request); +} diff --git a/lib/chats/__tests__/deleteChatHandler.test.ts b/lib/chats/__tests__/deleteChatHandler.test.ts new file mode 100644 index 00000000..3e23cbd8 --- /dev/null +++ b/lib/chats/__tests__/deleteChatHandler.test.ts @@ -0,0 +1,79 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; +import { deleteChatHandler } from "../deleteChatHandler"; +import { validateDeleteChatBody } from "../validateDeleteChatBody"; +import { deleteRoom } from "@/lib/supabase/rooms/deleteRoom"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), +})); + +vi.mock("@/lib/chats/validateDeleteChatBody", () => ({ + validateDeleteChatBody: vi.fn(), +})); + +vi.mock("@/lib/supabase/rooms/deleteRoom", () => ({ + deleteRoom: vi.fn(), +})); + +describe("deleteChatHandler", () => { + const id = "123e4567-e89b-12d3-a456-426614174000"; + + const request = new NextRequest("http://localhost/api/chats", { + method: "DELETE", + headers: { "Content-Type": "application/json", "x-api-key": "test-key" }, + body: JSON.stringify({ id }), + }); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("deletes chat and returns success response", async () => { + vi.mocked(validateDeleteChatBody).mockResolvedValue({ id }); + vi.mocked(deleteRoom).mockResolvedValue(true); + + const response = await deleteChatHandler(request); + + expect(response.status).toBe(200); + const body = await response.json(); + expect(body).toEqual({ + status: "success", + id, + message: "Chat deleted successfully", + }); + expect(deleteRoom).toHaveBeenCalledWith(id); + }); + + it("returns validation response when request is invalid", async () => { + vi.mocked(validateDeleteChatBody).mockResolvedValue( + NextResponse.json({ status: "error", error: "chatId is required" }, { status: 400 }), + ); + + const response = await deleteChatHandler(request); + expect(response.status).toBe(400); + expect(deleteRoom).not.toHaveBeenCalled(); + }); + + it("returns 500 when deletion fails", async () => { + vi.mocked(validateDeleteChatBody).mockResolvedValue({ id }); + vi.mocked(deleteRoom).mockResolvedValue(false); + + const response = await deleteChatHandler(request); + expect(response.status).toBe(500); + const body = await response.json(); + expect(body.status).toBe("error"); + expect(body.error).toBe("Failed to delete chat"); + }); + + it("returns 500 with generic message when deletion throws", async () => { + vi.mocked(validateDeleteChatBody).mockResolvedValue({ id }); + vi.mocked(deleteRoom).mockRejectedValue(new Error("Database down")); + + const response = await deleteChatHandler(request); + expect(response.status).toBe(500); + const body = await response.json(); + expect(body.status).toBe("error"); + expect(body.error).toBe("Server error"); + }); +}); diff --git a/lib/chats/__tests__/validateChatAccess.test.ts b/lib/chats/__tests__/validateChatAccess.test.ts new file mode 100644 index 00000000..00bb430b --- /dev/null +++ b/lib/chats/__tests__/validateChatAccess.test.ts @@ -0,0 +1,162 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; +import { validateChatAccess } from "../validateChatAccess"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import selectRoom from "@/lib/supabase/rooms/selectRoom"; +import { buildGetChatsParams } from "@/lib/chats/buildGetChatsParams"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), +})); + +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(), +})); + +describe("validateChatAccess", () => { + const roomId = "123e4567-e89b-12d3-a456-426614174000"; + const accountId = "123e4567-e89b-12d3-a456-426614174001"; + const request = new NextRequest("http://localhost/api/chats", { + headers: { "x-api-key": "test-key" }, + }); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns 400 when roomId is invalid uuid", async () => { + const result = await validateChatAccess(request, "invalid-id"); + expect(result).toBeInstanceOf(NextResponse); + const response = result as NextResponse; + expect(response.status).toBe(400); + }); + + it("returns auth response when auth fails", async () => { + vi.mocked(validateAuthContext).mockResolvedValue( + NextResponse.json({ status: "error", error: "Unauthorized" }, { status: 401 }), + ); + + const result = await validateChatAccess(request, roomId); + expect(result).toBeInstanceOf(NextResponse); + const response = result as NextResponse; + expect(response.status).toBe(401); + }); + + it("returns 404 when room does not exist", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId, + orgId: null, + authToken: "test-key", + }); + vi.mocked(selectRoom).mockResolvedValue(null); + + const result = await validateChatAccess(request, roomId); + expect(result).toBeInstanceOf(NextResponse); + const response = result as NextResponse; + expect(response.status).toBe(404); + }); + + it("returns 403 when room belongs to inaccessible account", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId, + orgId: null, + authToken: "test-key", + }); + vi.mocked(selectRoom).mockResolvedValue({ + id: roomId, + account_id: "another-account", + artist_id: null, + topic: "Topic", + updated_at: "2026-03-30T00:00:00Z", + }); + vi.mocked(buildGetChatsParams).mockResolvedValue({ + params: { account_ids: [accountId] }, + error: null, + }); + + const result = await validateChatAccess(request, roomId); + expect(result).toBeInstanceOf(NextResponse); + const response = result as NextResponse; + expect(response.status).toBe(403); + }); + + it("returns 403 when buildGetChatsParams returns null params", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId, + orgId: null, + authToken: "test-key", + }); + vi.mocked(selectRoom).mockResolvedValue({ + id: roomId, + account_id: accountId, + artist_id: null, + topic: "Topic", + updated_at: "2026-03-30T00:00:00Z", + }); + vi.mocked(buildGetChatsParams).mockResolvedValue({ + params: null, + error: "Access denied", + }); + + const result = await validateChatAccess(request, roomId); + expect(result).toBeInstanceOf(NextResponse); + const response = result as NextResponse; + expect(response.status).toBe(403); + }); + + it("returns 403 when room has null account_id", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId, + orgId: null, + authToken: "test-key", + }); + vi.mocked(selectRoom).mockResolvedValue({ + id: roomId, + account_id: null, + artist_id: null, + topic: "Topic", + updated_at: "2026-03-30T00:00:00Z", + }); + vi.mocked(buildGetChatsParams).mockResolvedValue({ + params: { account_ids: [accountId] }, + error: null, + }); + + const result = await validateChatAccess(request, roomId); + expect(result).toBeInstanceOf(NextResponse); + const response = result as NextResponse; + expect(response.status).toBe(403); + }); + + it("returns roomId for accessible room", async () => { + const room = { + id: roomId, + account_id: accountId, + artist_id: null, + topic: "Topic", + updated_at: "2026-03-30T00:00:00Z", + }; + + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId, + orgId: null, + authToken: "test-key", + }); + vi.mocked(selectRoom).mockResolvedValue(room); + vi.mocked(buildGetChatsParams).mockResolvedValue({ + params: { account_ids: [accountId] }, + error: null, + }); + + const result = await validateChatAccess(request, roomId); + expect(result).toEqual({ roomId }); + }); +}); diff --git a/lib/chats/__tests__/validateDeleteChatBody.test.ts b/lib/chats/__tests__/validateDeleteChatBody.test.ts new file mode 100644 index 00000000..ed059fed --- /dev/null +++ b/lib/chats/__tests__/validateDeleteChatBody.test.ts @@ -0,0 +1,67 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; +import { validateDeleteChatBody } from "../validateDeleteChatBody"; +import { validateChatAccess } from "../validateChatAccess"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), +})); + +vi.mock("../validateChatAccess", () => ({ + validateChatAccess: vi.fn(), +})); + +describe("validateDeleteChatBody", () => { + const id = "123e4567-e89b-12d3-a456-426614174000"; + + const createRequest = (body: object | string) => + new NextRequest("http://localhost/api/chats", { + method: "DELETE", + headers: { "Content-Type": "application/json", "x-api-key": "test-key" }, + body: typeof body === "string" ? body : JSON.stringify(body), + }); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns validated id when access check passes", async () => { + vi.mocked(validateChatAccess).mockResolvedValue({ roomId: id }); + + const result = await validateDeleteChatBody(createRequest({ id })); + expect(result).toEqual({ id }); + expect(validateChatAccess).toHaveBeenCalledWith(expect.any(NextRequest), id); + }); + + it("returns 400 when id is missing", async () => { + const result = await validateDeleteChatBody(createRequest({})); + expect(result).toBeInstanceOf(NextResponse); + const response = result as NextResponse; + expect(response.status).toBe(400); + }); + + it("returns 400 when id is invalid UUID", async () => { + const result = await validateDeleteChatBody(createRequest({ id: "invalid" })); + expect(result).toBeInstanceOf(NextResponse); + const response = result as NextResponse; + expect(response.status).toBe(400); + }); + + it("returns access response when access check fails", async () => { + vi.mocked(validateChatAccess).mockResolvedValue( + NextResponse.json({ status: "error", error: "Unauthorized" }, { status: 401 }), + ); + + const result = await validateDeleteChatBody(createRequest({ id })); + expect(result).toBeInstanceOf(NextResponse); + const response = result as NextResponse; + expect(response.status).toBe(401); + }); + + it("returns 400 on invalid JSON body", async () => { + const result = await validateDeleteChatBody(createRequest("{invalid-json")); + expect(result).toBeInstanceOf(NextResponse); + const response = result as NextResponse; + expect(response.status).toBe(400); + }); +}); diff --git a/lib/chats/__tests__/validateUpdateChatBody.test.ts b/lib/chats/__tests__/validateUpdateChatBody.test.ts index e59e8c64..6650959d 100644 --- a/lib/chats/__tests__/validateUpdateChatBody.test.ts +++ b/lib/chats/__tests__/validateUpdateChatBody.test.ts @@ -1,415 +1,111 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { NextRequest, NextResponse } from "next/server"; import { validateUpdateChatBody } from "../validateUpdateChatBody"; - -import { validateAuthContext } from "@/lib/auth/validateAuthContext"; -import selectRoom from "@/lib/supabase/rooms/selectRoom"; -import { buildGetChatsParams } from "@/lib/chats/buildGetChatsParams"; +import { validateChatAccess } from "../validateChatAccess"; vi.mock("@/lib/networking/getCorsHeaders", () => ({ getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), })); -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(), +vi.mock("../validateChatAccess", () => ({ + validateChatAccess: vi.fn(), })); describe("validateUpdateChatBody", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); + const chatId = "123e4567-e89b-12d3-a456-426614174000"; - const createRequest = (body: object) => { - return new NextRequest("http://localhost/api/chats", { + const createRequest = (body: object | string) => + new NextRequest("http://localhost/api/chats", { method: "PATCH", headers: { "Content-Type": "application/json", "x-api-key": "test-key" }, - body: JSON.stringify(body), - }); - }; - - describe("successful validation", () => { - it("returns validated data when user owns the chat", async () => { - const chatId = "123e4567-e89b-12d3-a456-426614174000"; - const accountId = "123e4567-e89b-12d3-a456-426614174001"; - const topic = "Valid Topic"; - const room = { - id: chatId, - account_id: accountId, - artist_id: null, - topic: "Old Topic", - updated_at: "2024-01-01T00:00:00Z", - }; - - vi.mocked(validateAuthContext).mockResolvedValue({ - accountId, - orgId: null, - authToken: "test-key", - }); - - vi.mocked(selectRoom).mockResolvedValue(room); - - vi.mocked(buildGetChatsParams).mockResolvedValue({ - params: { account_ids: [accountId] }, - error: null, - }); - - const request = createRequest({ chatId, topic }); - const result = await validateUpdateChatBody(request); - - expect(result).not.toBeInstanceOf(NextResponse); - expect(result).toEqual({ chatId, topic }); - }); - - it("accepts topic at minimum length (3 chars)", async () => { - const chatId = "123e4567-e89b-12d3-a456-426614174000"; - const accountId = "123e4567-e89b-12d3-a456-426614174001"; - const topic = "abc"; - const room = { - id: chatId, - account_id: accountId, - artist_id: null, - topic: "Old", - updated_at: "2024-01-01T00:00:00Z", - }; - - vi.mocked(validateAuthContext).mockResolvedValue({ - accountId, - orgId: null, - authToken: "test-key", - }); - - vi.mocked(selectRoom).mockResolvedValue(room); - - vi.mocked(buildGetChatsParams).mockResolvedValue({ - params: { account_ids: [accountId] }, - error: null, - }); - - const request = createRequest({ chatId, topic }); - const result = await validateUpdateChatBody(request); - - expect(result).not.toBeInstanceOf(NextResponse); - expect(result).toEqual({ chatId, topic }); + body: typeof body === "string" ? body : JSON.stringify(body), }); - it("accepts topic at maximum length (50 chars)", async () => { - const chatId = "123e4567-e89b-12d3-a456-426614174000"; - const accountId = "123e4567-e89b-12d3-a456-426614174001"; - const topic = "a".repeat(50); - const room = { - id: chatId, - account_id: accountId, - artist_id: null, - topic: "Old", - updated_at: "2024-01-01T00:00:00Z", - }; - - vi.mocked(validateAuthContext).mockResolvedValue({ - accountId, - orgId: null, - authToken: "test-key", - }); - - vi.mocked(selectRoom).mockResolvedValue(room); - - vi.mocked(buildGetChatsParams).mockResolvedValue({ - params: { account_ids: [accountId] }, - error: null, - }); - - const request = createRequest({ chatId, topic }); - const result = await validateUpdateChatBody(request); - - expect(result).not.toBeInstanceOf(NextResponse); - expect(result).toEqual({ chatId, topic }); - }); - - it("allows org key to access member's chat", async () => { - const chatId = "123e4567-e89b-12d3-a456-426614174000"; - const memberAccountId = "123e4567-e89b-12d3-a456-426614174001"; - const orgId = "123e4567-e89b-12d3-a456-426614174002"; - const topic = "Valid Topic"; - const room = { - id: chatId, - account_id: memberAccountId, - artist_id: null, - topic: "Old", - updated_at: "2024-01-01T00:00:00Z", - }; - - vi.mocked(validateAuthContext).mockResolvedValue({ - accountId: orgId, - orgId, - authToken: "org-key", - }); - - vi.mocked(selectRoom).mockResolvedValue(room); - - vi.mocked(buildGetChatsParams).mockResolvedValue({ - params: { account_ids: [memberAccountId, "other-member"] }, - error: null, - }); - - const request = createRequest({ chatId, topic }); - const result = await validateUpdateChatBody(request); - - expect(result).not.toBeInstanceOf(NextResponse); - expect(result).toEqual({ chatId, topic }); - }); - - it("allows admin access when account_ids is undefined", async () => { - const chatId = "123e4567-e89b-12d3-a456-426614174000"; - const accountId = "123e4567-e89b-12d3-a456-426614174001"; - const topic = "Valid Topic"; - const room = { - id: chatId, - account_id: "any-account", - artist_id: null, - topic: "Old", - updated_at: "2024-01-01T00:00:00Z", - }; - - vi.mocked(validateAuthContext).mockResolvedValue({ - accountId, - orgId: "admin-org", - authToken: "admin-key", - }); - - vi.mocked(selectRoom).mockResolvedValue(room); - - vi.mocked(buildGetChatsParams).mockResolvedValue({ - params: {}, // No account_ids = admin access - error: null, - }); - - const request = createRequest({ chatId, topic }); - const result = await validateUpdateChatBody(request); - - expect(result).not.toBeInstanceOf(NextResponse); - expect(result).toEqual({ chatId, topic }); - }); + beforeEach(() => { + vi.clearAllMocks(); }); - describe("body validation errors", () => { - it("returns 400 when chatId is missing", async () => { - const request = createRequest({ topic: "Valid Topic" }); + it("returns validated data when access check passes", async () => { + const topic = "Valid Topic"; + vi.mocked(validateChatAccess).mockResolvedValue({ roomId: chatId }); - const result = await validateUpdateChatBody(request); - - expect(result).toBeInstanceOf(NextResponse); - const response = result as NextResponse; - expect(response.status).toBe(400); - const body = await response.json(); - expect(body.status).toBe("error"); - }); - - it("returns 400 when chatId is not a valid UUID", async () => { - const request = createRequest({ chatId: "not-a-uuid", topic: "Valid Topic" }); - - const result = await validateUpdateChatBody(request); - - expect(result).toBeInstanceOf(NextResponse); - const response = result as NextResponse; - expect(response.status).toBe(400); - const body = await response.json(); - expect(body.error).toContain("UUID"); - }); - - it("returns 400 when topic is missing", async () => { - const request = createRequest({ chatId: "123e4567-e89b-12d3-a456-426614174000" }); - - const result = await validateUpdateChatBody(request); - - expect(result).toBeInstanceOf(NextResponse); - const response = result as NextResponse; - expect(response.status).toBe(400); - }); - - it("returns 400 when topic is too short", async () => { - const request = createRequest({ - chatId: "123e4567-e89b-12d3-a456-426614174000", - topic: "ab", - }); - - const result = await validateUpdateChatBody(request); - - expect(result).toBeInstanceOf(NextResponse); - const response = result as NextResponse; - expect(response.status).toBe(400); - const body = await response.json(); - expect(body.error).toContain("3 and 50"); - }); - - it("returns 400 when topic is too long", async () => { - const request = createRequest({ - chatId: "123e4567-e89b-12d3-a456-426614174000", - topic: "a".repeat(51), - }); - - const result = await validateUpdateChatBody(request); - - expect(result).toBeInstanceOf(NextResponse); - const response = result as NextResponse; - expect(response.status).toBe(400); - const body = await response.json(); - expect(body.error).toContain("3 and 50"); - }); + const result = await validateUpdateChatBody(createRequest({ chatId, topic })); + expect(result).toEqual({ chatId, topic }); + expect(validateChatAccess).toHaveBeenCalledWith(expect.any(NextRequest), chatId); }); - describe("JSON parsing errors", () => { - it("handles invalid JSON gracefully", async () => { - const request = new NextRequest("http://localhost/api/chats", { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: "not valid json", - }); - - const result = await validateUpdateChatBody(request); - - expect(result).toBeInstanceOf(NextResponse); - const response = result as NextResponse; - expect(response.status).toBe(400); - }); - - it("handles empty body gracefully", async () => { - const request = new NextRequest("http://localhost/api/chats", { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - }); - - const result = await validateUpdateChatBody(request); - - expect(result).toBeInstanceOf(NextResponse); - const response = result as NextResponse; - expect(response.status).toBe(400); - }); + it("accepts topic at minimum length (3 chars)", async () => { + vi.mocked(validateChatAccess).mockResolvedValue({ roomId: chatId }); + const result = await validateUpdateChatBody(createRequest({ chatId, topic: "abc" })); + expect(result).toEqual({ chatId, topic: "abc" }); }); - describe("authentication errors", () => { - it("returns 401 when auth fails", async () => { - vi.mocked(validateAuthContext).mockResolvedValue( - NextResponse.json({ status: "error", error: "Unauthorized" }, { status: 401 }), - ); - - const request = createRequest({ - chatId: "123e4567-e89b-12d3-a456-426614174000", - topic: "Valid Topic", - }); - const result = await validateUpdateChatBody(request); - - expect(result).toBeInstanceOf(NextResponse); - const response = result as NextResponse; - expect(response.status).toBe(401); - }); + it("accepts topic at maximum length (50 chars)", async () => { + vi.mocked(validateChatAccess).mockResolvedValue({ roomId: chatId }); + const topic = "a".repeat(50); + const result = await validateUpdateChatBody(createRequest({ chatId, topic })); + expect(result).toEqual({ chatId, topic }); }); - describe("room not found errors", () => { - it("returns 404 when chat does not exist", async () => { - vi.mocked(validateAuthContext).mockResolvedValue({ - accountId: "123e4567-e89b-12d3-a456-426614174001", - orgId: null, - authToken: "test-key", - }); - - vi.mocked(selectRoom).mockResolvedValue(null); - - const request = createRequest({ - chatId: "123e4567-e89b-12d3-a456-426614174000", - topic: "Valid Topic", - }); - const result = await validateUpdateChatBody(request); - - expect(result).toBeInstanceOf(NextResponse); - const response = result as NextResponse; - expect(response.status).toBe(404); - const body = await response.json(); - expect(body.error).toContain("not found"); - }); + it("returns 400 when chatId is missing", async () => { + const result = await validateUpdateChatBody(createRequest({ topic: "Valid Topic" })); + expect(result).toBeInstanceOf(NextResponse); + const response = result as NextResponse; + expect(response.status).toBe(400); }); - describe("access denied errors", () => { - it("returns 403 when user tries to update another user's chat", async () => { - const userAccountId = "123e4567-e89b-12d3-a456-426614174001"; - const otherAccountId = "123e4567-e89b-12d3-a456-426614174002"; - const chatId = "123e4567-e89b-12d3-a456-426614174000"; - const room = { - id: chatId, - account_id: otherAccountId, - artist_id: null, - topic: "Old Topic", - updated_at: "2024-01-01T00:00:00Z", - }; - - vi.mocked(validateAuthContext).mockResolvedValue({ - accountId: userAccountId, - orgId: null, - authToken: "test-key", - }); - - vi.mocked(selectRoom).mockResolvedValue(room); - - vi.mocked(buildGetChatsParams).mockResolvedValue({ - params: { account_ids: [userAccountId] }, - error: null, - }); - - const request = createRequest({ - chatId, - topic: "Valid Topic", - }); - const result = await validateUpdateChatBody(request); + it("returns 400 when chatId is not a valid UUID", async () => { + const result = await validateUpdateChatBody( + createRequest({ chatId: "invalid-uuid", topic: "Valid Topic" }), + ); + expect(result).toBeInstanceOf(NextResponse); + const response = result as NextResponse; + expect(response.status).toBe(400); + }); - expect(result).toBeInstanceOf(NextResponse); - const response = result as NextResponse; - expect(response.status).toBe(403); - const body = await response.json(); - expect(body.error).toContain("Access denied"); - }); + it("returns 400 when topic is missing", async () => { + const result = await validateUpdateChatBody(createRequest({ chatId })); + expect(result).toBeInstanceOf(NextResponse); + const response = result as NextResponse; + expect(response.status).toBe(400); + }); - it("returns 403 when org key cannot access non-member's chat", async () => { - const orgId = "123e4567-e89b-12d3-a456-426614174001"; - const nonMemberAccountId = "123e4567-e89b-12d3-a456-426614174002"; - const chatId = "123e4567-e89b-12d3-a456-426614174000"; - const room = { - id: chatId, - account_id: nonMemberAccountId, - artist_id: null, - topic: "Old Topic", - updated_at: "2024-01-01T00:00:00Z", - }; + it("returns 400 when topic is too short", async () => { + const result = await validateUpdateChatBody(createRequest({ chatId, topic: "ab" })); + expect(result).toBeInstanceOf(NextResponse); + const response = result as NextResponse; + expect(response.status).toBe(400); + }); - vi.mocked(validateAuthContext).mockResolvedValue({ - accountId: orgId, - orgId, - authToken: "org-key", - }); + it("returns 400 when topic is too long", async () => { + const result = await validateUpdateChatBody(createRequest({ chatId, topic: "a".repeat(51) })); + expect(result).toBeInstanceOf(NextResponse); + const response = result as NextResponse; + expect(response.status).toBe(400); + }); - vi.mocked(selectRoom).mockResolvedValue(room); + it("returns access response when access check fails", async () => { + vi.mocked(validateChatAccess).mockResolvedValue( + NextResponse.json({ status: "error", error: "Access denied" }, { status: 403 }), + ); - vi.mocked(buildGetChatsParams).mockResolvedValue({ - params: { account_ids: ["member-1", "member-2"] }, // non-member not in list - error: null, - }); + const result = await validateUpdateChatBody(createRequest({ chatId, topic: "Valid Topic" })); + expect(result).toBeInstanceOf(NextResponse); + const response = result as NextResponse; + expect(response.status).toBe(403); + }); - const request = createRequest({ - chatId, - topic: "Valid Topic", - }); - const result = await validateUpdateChatBody(request); + it("returns 400 on invalid JSON body", async () => { + const result = await validateUpdateChatBody(createRequest("{invalid-json")); + expect(result).toBeInstanceOf(NextResponse); + const response = result as NextResponse; + expect(response.status).toBe(400); + }); - expect(result).toBeInstanceOf(NextResponse); - const response = result as NextResponse; - expect(response.status).toBe(403); - const body = await response.json(); - expect(body.error).toContain("Access denied"); - }); + it("handles empty body gracefully", async () => { + const result = await validateUpdateChatBody(createRequest({})); + expect(result).toBeInstanceOf(NextResponse); + const response = result as NextResponse; + expect(response.status).toBe(400); }); }); diff --git a/lib/chats/deleteChatHandler.ts b/lib/chats/deleteChatHandler.ts new file mode 100644 index 00000000..c8d2fad8 --- /dev/null +++ b/lib/chats/deleteChatHandler.ts @@ -0,0 +1,48 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateDeleteChatBody } from "@/lib/chats/validateDeleteChatBody"; +import { deleteRoom } from "@/lib/supabase/rooms/deleteRoom"; + +/** + * Handles DELETE /api/chats - Delete a chat room and related records. + * + * @param request - The NextRequest object + * @returns NextResponse with deletion result or error + */ +export async function deleteChatHandler(request: NextRequest): Promise { + const validated = await validateDeleteChatBody(request); + if (validated instanceof NextResponse) { + return validated; + } + + const { id } = validated; + + try { + const deleted = await deleteRoom(id); + + if (!deleted) { + return NextResponse.json( + { status: "error", error: "Failed to delete chat" }, + { status: 500, headers: getCorsHeaders() }, + ); + } + + return NextResponse.json( + { + status: "success", + id, + message: "Chat deleted successfully", + }, + { status: 200, headers: getCorsHeaders() }, + ); + } catch (error) { + if (error instanceof Error) { + console.error("[ERROR] deleteChatHandler:", error.message); + } + return NextResponse.json( + { status: "error", error: "Server error" }, + { status: 500, headers: getCorsHeaders() }, + ); + } +} diff --git a/lib/chats/validateChatAccess.ts b/lib/chats/validateChatAccess.ts new file mode 100644 index 00000000..4e40c13f --- /dev/null +++ b/lib/chats/validateChatAccess.ts @@ -0,0 +1,68 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { z } from "zod"; +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"; + +export interface ValidatedChatAccess { + roomId: string; +} + +const chatIdSchema = z.string().uuid("id must be a valid UUID"); + +/** + * Validates that the authenticated caller can access a chat room. + * + * @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 + */ +export async function validateChatAccess( + request: NextRequest, + roomId: string, +): Promise { + const roomIdResult = chatIdSchema.safeParse(roomId); + if (!roomIdResult.success) { + return NextResponse.json( + { status: "error", error: roomIdResult.error.issues[0]?.message || "Invalid chat ID" }, + { status: 400, headers: getCorsHeaders() }, + ); + } + + const authResult = await validateAuthContext(request); + if (authResult instanceof NextResponse) { + return authResult; + } + + const { accountId } = authResult; + + const room = await selectRoom(roomIdResult.data); + if (!room) { + return NextResponse.json( + { status: "error", error: "Chat room not found" }, + { status: 404, headers: getCorsHeaders() }, + ); + } + + const { params, error } = await buildGetChatsParams({ + account_id: accountId, + }); + + if (!params) { + return NextResponse.json( + { status: "error", error: error ?? "Access denied" }, + { status: 403, headers: getCorsHeaders() }, + ); + } + + if (!room.account_id || !params.account_ids.includes(room.account_id)) { + return NextResponse.json( + { status: "error", error: "Access denied to this chat" }, + { status: 403, headers: getCorsHeaders() }, + ); + } + + return { roomId: room.id }; +} diff --git a/lib/chats/validateDeleteChatBody.ts b/lib/chats/validateDeleteChatBody.ts new file mode 100644 index 00000000..c9fa8a63 --- /dev/null +++ b/lib/chats/validateDeleteChatBody.ts @@ -0,0 +1,58 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateChatAccess } from "@/lib/chats/validateChatAccess"; + +export const deleteChatBodySchema = z.object({ + id: z.string().uuid("id must be a valid UUID"), +}); + +export type DeleteChatBody = z.infer; + +export interface ValidatedDeleteChat { + id: string; +} + +/** + * Validates request for DELETE /api/chats. + * Parses JSON, validates schema, authenticates, verifies room exists, and checks access. + * + * @param request - The NextRequest object + * @returns A NextResponse with an error if validation fails, or validated data if it passes + */ +export async function validateDeleteChatBody( + request: NextRequest, +): Promise { + let body: unknown; + try { + body = await request.json(); + } catch { + body = {}; + } + + const result = deleteChatBodySchema.safeParse(body); + if (!result.success) { + const firstError = result.error.issues[0]; + return NextResponse.json( + { + status: "error", + missing_fields: firstError.path, + error: firstError.message, + }, + { + status: 400, + headers: getCorsHeaders(), + }, + ); + } + + const { id } = result.data; + + const accessResult = await validateChatAccess(request, id); + if (accessResult instanceof NextResponse) { + return accessResult; + } + + return { id }; +} diff --git a/lib/chats/validateUpdateChatBody.ts b/lib/chats/validateUpdateChatBody.ts index 95e3c61f..8a9bfa48 100644 --- a/lib/chats/validateUpdateChatBody.ts +++ b/lib/chats/validateUpdateChatBody.ts @@ -1,10 +1,8 @@ import type { NextRequest } from "next/server"; import { NextResponse } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; -import { validateAuthContext } from "@/lib/auth/validateAuthContext"; -import selectRoom from "@/lib/supabase/rooms/selectRoom"; -import { buildGetChatsParams } from "./buildGetChatsParams"; import { z } from "zod"; +import { validateChatAccess } from "@/lib/chats/validateChatAccess"; /** * Zod schema for PATCH /api/chats request body. @@ -64,36 +62,9 @@ export async function validateUpdateChatBody( const { chatId, topic } = result.data; - // Validate authentication - const authResult = await validateAuthContext(request); - if (authResult instanceof NextResponse) { - return authResult; - } - - const { accountId, orgId } = authResult; - - // Verify room exists - const room = await selectRoom(chatId); - if (!room) { - return NextResponse.json( - { status: "error", error: "Chat room not found" }, - { status: 404, headers: getCorsHeaders() }, - ); - } - - // Check access control - const { params } = await buildGetChatsParams({ - account_id: accountId, - }); - - // If params.account_ids is undefined, it means admin access (all records) - if (params.account_ids && room.account_id) { - if (!params.account_ids.includes(room.account_id)) { - return NextResponse.json( - { status: "error", error: "Access denied to this chat" }, - { status: 403, headers: getCorsHeaders() }, - ); - } + const accessResult = await validateChatAccess(request, chatId); + if (accessResult instanceof NextResponse) { + return accessResult; } return { diff --git a/lib/supabase/rooms/deleteRoom.ts b/lib/supabase/rooms/deleteRoom.ts new file mode 100644 index 00000000..20d9ecac --- /dev/null +++ b/lib/supabase/rooms/deleteRoom.ts @@ -0,0 +1,18 @@ +import supabase from "@/lib/supabase/serverClient"; + +/** + * Deletes a room by ID. Related records are removed automatically via ON DELETE CASCADE. + * + * @param roomId - The room ID to delete + * @returns True when deletion succeeds, false on error + */ +export async function deleteRoom(roomId: string): Promise { + const { error } = await supabase.from("rooms").delete().eq("id", roomId); + + if (error) { + console.error("Error deleting room:", error); + return false; + } + + return true; +}