diff --git a/lib/chats/__tests__/buildGetChatsParams.test.ts b/lib/chats/__tests__/buildGetChatsParams.test.ts new file mode 100644 index 00000000..c65f7dac --- /dev/null +++ b/lib/chats/__tests__/buildGetChatsParams.test.ts @@ -0,0 +1,197 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { buildGetChatsParams } from "../buildGetChatsParams"; + +import { canAccessAccount } from "@/lib/organizations/canAccessAccount"; +import { getAccountOrganizations } from "@/lib/supabase/account_organization_ids/getAccountOrganizations"; + +vi.mock("@/lib/organizations/canAccessAccount", () => ({ + canAccessAccount: vi.fn(), +})); + +vi.mock("@/lib/supabase/account_organization_ids/getAccountOrganizations", () => ({ + getAccountOrganizations: vi.fn(), +})); + +vi.mock("@/lib/const", () => ({ + RECOUP_ORG_ID: "recoup-org-id", +})); + +describe("buildGetChatsParams", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("personal API key (org_id = null)", () => { + it("returns account_ids with key owner when no target_account_id", async () => { + const result = await buildGetChatsParams({ + account_id: "account-123", + org_id: null, + }); + + expect(result).toEqual({ + params: { account_ids: ["account-123"], artist_id: undefined }, + error: null, + }); + }); + + it("returns error when personal key tries to filter by account_id", async () => { + vi.mocked(canAccessAccount).mockResolvedValue(false); + + const result = await buildGetChatsParams({ + account_id: "account-123", + org_id: null, + target_account_id: "other-account", + }); + + expect(result).toEqual({ + params: null, + error: "Personal API keys cannot filter by account_id", + }); + }); + + it("includes artist_id filter for personal key", async () => { + const result = await buildGetChatsParams({ + account_id: "account-123", + org_id: null, + artist_id: "artist-456", + }); + + expect(result).toEqual({ + params: { account_ids: ["account-123"], artist_id: "artist-456" }, + error: null, + }); + }); + }); + + describe("organization API key", () => { + it("fetches org member account_ids when no target_account_id", async () => { + vi.mocked(getAccountOrganizations).mockResolvedValue([ + { account_id: "member-1", organization_id: "org-123", organization: null }, + { account_id: "member-2", organization_id: "org-123", organization: null }, + { account_id: "member-3", organization_id: "org-123", organization: null }, + ]); + + const result = await buildGetChatsParams({ + account_id: "org-123", + org_id: "org-123", + }); + + expect(getAccountOrganizations).toHaveBeenCalledWith({ organizationId: "org-123" }); + expect(result).toEqual({ + params: { account_ids: ["member-1", "member-2", "member-3"], artist_id: undefined }, + error: null, + }); + }); + + it("allows filtering by account_id if member of org", async () => { + vi.mocked(canAccessAccount).mockResolvedValue(true); + + const result = await buildGetChatsParams({ + account_id: "account-123", + org_id: "org-123", + target_account_id: "member-account", + }); + + expect(result).toEqual({ + params: { account_ids: ["member-account"], artist_id: undefined }, + error: null, + }); + expect(canAccessAccount).toHaveBeenCalledWith({ + orgId: "org-123", + targetAccountId: "member-account", + }); + }); + + it("returns error when account_id is not member of org", async () => { + vi.mocked(canAccessAccount).mockResolvedValue(false); + + const result = await buildGetChatsParams({ + account_id: "account-123", + org_id: "org-123", + target_account_id: "non-member-account", + }); + + expect(result).toEqual({ + params: null, + error: "account_id is not a member of this organization", + }); + }); + + it("includes artist_id filter for org key", async () => { + vi.mocked(getAccountOrganizations).mockResolvedValue([ + { account_id: "member-1", organization_id: "org-123", organization: null }, + ]); + + const result = await buildGetChatsParams({ + account_id: "account-123", + org_id: "org-123", + artist_id: "artist-456", + }); + + expect(result).toEqual({ + params: { account_ids: ["member-1"], artist_id: "artist-456" }, + error: null, + }); + }); + + it("returns empty account_ids when org has no members", async () => { + vi.mocked(getAccountOrganizations).mockResolvedValue([]); + + const result = await buildGetChatsParams({ + account_id: "org-123", + org_id: "org-123", + }); + + expect(result).toEqual({ + params: { account_ids: [], artist_id: undefined }, + error: null, + }); + }); + }); + + describe("Recoup admin key", () => { + const recoupOrgId = "recoup-org-id"; + + it("returns empty params (no filter) to get all records", async () => { + const result = await buildGetChatsParams({ + account_id: "admin-account", + org_id: recoupOrgId, + }); + + expect(result).toEqual({ + params: { artist_id: undefined }, + error: null, + }); + // Should NOT call getAccountOrganizations for admin + expect(getAccountOrganizations).not.toHaveBeenCalled(); + }); + + it("allows filtering by any account_id", async () => { + vi.mocked(canAccessAccount).mockResolvedValue(true); + + const result = await buildGetChatsParams({ + account_id: "admin-account", + org_id: recoupOrgId, + target_account_id: "any-account", + }); + + expect(result).toEqual({ + params: { account_ids: ["any-account"], artist_id: undefined }, + error: null, + }); + }); + + it("includes artist_id filter for admin key", async () => { + const result = await buildGetChatsParams({ + account_id: "admin-account", + org_id: recoupOrgId, + artist_id: "artist-456", + }); + + expect(result).toEqual({ + params: { artist_id: "artist-456" }, + error: null, + }); + }); + }); +}); diff --git a/lib/chats/__tests__/generateChatTitle.test.ts b/lib/chats/__tests__/generateChatTitle.test.ts index 32be1dd6..e1bb8a8b 100644 --- a/lib/chats/__tests__/generateChatTitle.test.ts +++ b/lib/chats/__tests__/generateChatTitle.test.ts @@ -1,12 +1,12 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; +import { generateChatTitle } from "../generateChatTitle"; +import generateText from "@/lib/ai/generateText"; + vi.mock("@/lib/ai/generateText", () => ({ default: vi.fn(), })); -import { generateChatTitle } from "../generateChatTitle"; -import generateText from "@/lib/ai/generateText"; - const mockGenerateText = vi.mocked(generateText); describe("generateChatTitle", () => { diff --git a/lib/chats/__tests__/getChatsHandler.test.ts b/lib/chats/__tests__/getChatsHandler.test.ts index 51a24a9c..fb5c1cdf 100644 --- a/lib/chats/__tests__/getChatsHandler.test.ts +++ b/lib/chats/__tests__/getChatsHandler.test.ts @@ -3,12 +3,22 @@ import { NextRequest, NextResponse } from "next/server"; import { getChatsHandler } from "../getChatsHandler"; import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { canAccessAccount } from "@/lib/organizations/canAccessAccount"; +import { getAccountOrganizations } from "@/lib/supabase/account_organization_ids/getAccountOrganizations"; import { selectRooms } from "@/lib/supabase/rooms/selectRooms"; vi.mock("@/lib/auth/validateAuthContext", () => ({ validateAuthContext: vi.fn(), })); +vi.mock("@/lib/organizations/canAccessAccount", () => ({ + canAccessAccount: vi.fn(), +})); + +vi.mock("@/lib/supabase/account_organization_ids/getAccountOrganizations", () => ({ + getAccountOrganizations: vi.fn(), +})); + vi.mock("@/lib/supabase/rooms/selectRooms", () => ({ selectRooms: vi.fn(), })); @@ -17,6 +27,10 @@ vi.mock("@/lib/networking/getCorsHeaders", () => ({ getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), })); +vi.mock("@/lib/const", () => ({ + RECOUP_ORG_ID: "recoup-org-id", +})); + /** * Creates a mock NextRequest with the given URL and headers. * @@ -44,7 +58,7 @@ describe("getChatsHandler", () => { NextResponse.json({ status: "error", error: "Unauthorized" }, { status: 401 }), ); - const request = createMockRequest("http://localhost/api/chats?account_id=123"); + const request = createMockRequest("http://localhost/api/chats"); const response = await getChatsHandler(request); const json = await response.json(); @@ -54,54 +68,75 @@ describe("getChatsHandler", () => { }); }); - describe("validation", () => { - it("returns 400 when account_id is missing", async () => { + describe("personal key behavior", () => { + it("returns chats for personal key owner without account_id param", async () => { + const accountId = "123e4567-e89b-12d3-a456-426614174000"; + const mockChats = [ + { + id: "chat-1", + account_id: accountId, + artist_id: null, + topic: "Chat 1", + updated_at: "2024-01-01T00:00:00Z", + }, + ]; + vi.mocked(validateAuthContext).mockResolvedValue({ - accountId: "auth-account-id", - orgId: null, + accountId, + orgId: null, // Personal key authToken: "test-token", }); + vi.mocked(selectRooms).mockResolvedValue(mockChats); const request = createMockRequest("http://localhost/api/chats"); const response = await getChatsHandler(request); const json = await response.json(); - expect(response.status).toBe(400); - expect(json.status).toBe("error"); - expect(selectRooms).not.toHaveBeenCalled(); + expect(response.status).toBe(200); + expect(json.status).toBe("success"); + expect(json.chats).toEqual(mockChats); + expect(selectRooms).toHaveBeenCalledWith({ + account_ids: [accountId], + artist_id: undefined, + }); }); - it("returns 400 when account_id is invalid UUID", async () => { + it("returns 403 when personal key tries to filter by account_id", async () => { + const accountId = "123e4567-e89b-12d3-a456-426614174000"; + const otherAccountId = "223e4567-e89b-12d3-a456-426614174000"; + vi.mocked(validateAuthContext).mockResolvedValue({ - accountId: "auth-account-id", - orgId: null, + accountId, + orgId: null, // Personal key authToken: "test-token", }); + vi.mocked(canAccessAccount).mockResolvedValue(false); - const request = createMockRequest("http://localhost/api/chats?account_id=invalid"); + const request = createMockRequest(`http://localhost/api/chats?account_id=${otherAccountId}`); const response = await getChatsHandler(request); const json = await response.json(); - expect(response.status).toBe(400); + expect(response.status).toBe(403); expect(json.status).toBe("error"); + expect(json.error).toBe("Personal API keys cannot filter by account_id"); expect(selectRooms).not.toHaveBeenCalled(); }); }); - describe("successful responses", () => { - it("returns chats for valid account_id", async () => { - const accountId = "123e4567-e89b-12d3-a456-426614174000"; + describe("org key behavior", () => { + it("returns chats for all org members without account_id param", async () => { + const orgId = "123e4567-e89b-12d3-a456-426614174000"; const mockChats = [ { id: "chat-1", - account_id: accountId, + account_id: "member-1", artist_id: null, topic: "Chat 1", updated_at: "2024-01-01T00:00:00Z", }, { id: "chat-2", - account_id: accountId, + account_id: "member-2", artist_id: null, topic: "Chat 2", updated_at: "2024-01-02T00:00:00Z", @@ -109,13 +144,17 @@ describe("getChatsHandler", () => { ]; vi.mocked(validateAuthContext).mockResolvedValue({ - accountId: "auth-account-id", - orgId: null, + accountId: orgId, + orgId, authToken: "test-token", }); + vi.mocked(getAccountOrganizations).mockResolvedValue([ + { account_id: "member-1", organization_id: orgId, organization: null }, + { account_id: "member-2", organization_id: orgId, organization: null }, + ]); vi.mocked(selectRooms).mockResolvedValue(mockChats); - const request = createMockRequest(`http://localhost/api/chats?account_id=${accountId}`); + const request = createMockRequest("http://localhost/api/chats"); const response = await getChatsHandler(request); const json = await response.json(); @@ -123,14 +162,136 @@ describe("getChatsHandler", () => { expect(json.status).toBe("success"); expect(json.chats).toEqual(mockChats); expect(selectRooms).toHaveBeenCalledWith({ - accountId, - artistId: undefined, + account_ids: ["member-1", "member-2"], + artist_id: undefined, }); }); + it("allows org key to filter by account_id for org member", async () => { + const orgId = "123e4567-e89b-12d3-a456-426614174000"; + const memberId = "223e4567-e89b-12d3-a456-426614174000"; + const mockChats = [ + { + id: "chat-1", + account_id: memberId, + artist_id: null, + topic: "Chat 1", + updated_at: "2024-01-01T00:00:00Z", + }, + ]; + + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: orgId, + orgId, + authToken: "test-token", + }); + vi.mocked(canAccessAccount).mockResolvedValue(true); + vi.mocked(selectRooms).mockResolvedValue(mockChats); + + const request = createMockRequest(`http://localhost/api/chats?account_id=${memberId}`); + const response = await getChatsHandler(request); + const json = await response.json(); + + expect(response.status).toBe(200); + expect(json.status).toBe("success"); + expect(selectRooms).toHaveBeenCalledWith({ + account_ids: [memberId], + artist_id: undefined, + }); + }); + + it("returns 403 when org key tries to access non-member account", async () => { + const orgId = "123e4567-e89b-12d3-a456-426614174000"; + const nonMemberId = "323e4567-e89b-12d3-a456-426614174000"; + + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: orgId, + orgId, + authToken: "test-token", + }); + vi.mocked(canAccessAccount).mockResolvedValue(false); + + const request = createMockRequest(`http://localhost/api/chats?account_id=${nonMemberId}`); + const response = await getChatsHandler(request); + const json = await response.json(); + + expect(response.status).toBe(403); + expect(json.status).toBe("error"); + expect(json.error).toBe("account_id is not a member of this organization"); + expect(selectRooms).not.toHaveBeenCalled(); + }); + }); + + describe("Recoup admin key behavior", () => { + it("returns all chats without account_id param", async () => { + const recoupOrgId = "recoup-org-id"; + const mockChats = [ + { + id: "chat-1", + account_id: "any-account", + artist_id: null, + topic: "Chat 1", + updated_at: "2024-01-01T00:00:00Z", + }, + ]; + + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: recoupOrgId, + orgId: recoupOrgId, + authToken: "test-token", + }); + vi.mocked(selectRooms).mockResolvedValue(mockChats); + + const request = createMockRequest("http://localhost/api/chats"); + const response = await getChatsHandler(request); + const json = await response.json(); + + expect(response.status).toBe(200); + expect(json.status).toBe("success"); + expect(selectRooms).toHaveBeenCalledWith({ + artist_id: undefined, + }); + }); + }); + + describe("validation", () => { + it("returns 400 when account_id is invalid UUID", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "auth-account-id", + orgId: null, + authToken: "test-token", + }); + + const request = createMockRequest("http://localhost/api/chats?account_id=invalid"); + const response = await getChatsHandler(request); + const json = await response.json(); + + expect(response.status).toBe(400); + expect(json.status).toBe("error"); + expect(selectRooms).not.toHaveBeenCalled(); + }); + + it("returns 400 when artist_account_id is invalid UUID", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "auth-account-id", + orgId: null, + authToken: "test-token", + }); + + const request = createMockRequest("http://localhost/api/chats?artist_account_id=invalid"); + const response = await getChatsHandler(request); + const json = await response.json(); + + expect(response.status).toBe(400); + expect(json.status).toBe("error"); + expect(selectRooms).not.toHaveBeenCalled(); + }); + }); + + describe("successful responses", () => { it("returns filtered chats when artist_account_id is provided", async () => { const accountId = "123e4567-e89b-12d3-a456-426614174000"; - const artistId = "123e4567-e89b-12d3-a456-426614174001"; + const artistId = "223e4567-e89b-12d3-a456-426614174001"; const mockChats = [ { id: "chat-1", @@ -142,15 +303,13 @@ describe("getChatsHandler", () => { ]; vi.mocked(validateAuthContext).mockResolvedValue({ - accountId: "auth-account-id", - orgId: null, + accountId, + orgId: null, // Personal key authToken: "test-token", }); vi.mocked(selectRooms).mockResolvedValue(mockChats); - const request = createMockRequest( - `http://localhost/api/chats?account_id=${accountId}&artist_account_id=${artistId}`, - ); + const request = createMockRequest(`http://localhost/api/chats?artist_account_id=${artistId}`); const response = await getChatsHandler(request); const json = await response.json(); @@ -158,8 +317,8 @@ describe("getChatsHandler", () => { expect(json.status).toBe("success"); expect(json.chats).toEqual(mockChats); expect(selectRooms).toHaveBeenCalledWith({ - accountId, - artistId, + account_ids: [accountId], + artist_id: artistId, }); }); @@ -167,13 +326,13 @@ describe("getChatsHandler", () => { const accountId = "123e4567-e89b-12d3-a456-426614174000"; vi.mocked(validateAuthContext).mockResolvedValue({ - accountId: "auth-account-id", + accountId, orgId: null, authToken: "test-token", }); vi.mocked(selectRooms).mockResolvedValue([]); - const request = createMockRequest(`http://localhost/api/chats?account_id=${accountId}`); + const request = createMockRequest("http://localhost/api/chats"); const response = await getChatsHandler(request); const json = await response.json(); @@ -188,13 +347,13 @@ describe("getChatsHandler", () => { const accountId = "123e4567-e89b-12d3-a456-426614174000"; vi.mocked(validateAuthContext).mockResolvedValue({ - accountId: "auth-account-id", + accountId, orgId: null, authToken: "test-token", }); vi.mocked(selectRooms).mockResolvedValue(null); - const request = createMockRequest(`http://localhost/api/chats?account_id=${accountId}`); + const request = createMockRequest("http://localhost/api/chats"); const response = await getChatsHandler(request); const json = await response.json(); @@ -207,13 +366,13 @@ describe("getChatsHandler", () => { const accountId = "123e4567-e89b-12d3-a456-426614174000"; vi.mocked(validateAuthContext).mockResolvedValue({ - accountId: "auth-account-id", + accountId, orgId: null, authToken: "test-token", }); vi.mocked(selectRooms).mockRejectedValue(new Error("Database error")); - const request = createMockRequest(`http://localhost/api/chats?account_id=${accountId}`); + const request = createMockRequest("http://localhost/api/chats"); const response = await getChatsHandler(request); const json = await response.json(); diff --git a/lib/chats/__tests__/validateCreateChatBody.test.ts b/lib/chats/__tests__/validateCreateChatBody.test.ts index e233e441..0ec4fbe2 100644 --- a/lib/chats/__tests__/validateCreateChatBody.test.ts +++ b/lib/chats/__tests__/validateCreateChatBody.test.ts @@ -1,9 +1,6 @@ import { describe, it, expect } from "vitest"; import { NextResponse } from "next/server"; -import { - validateCreateChatBody, - createChatBodySchema, -} from "../validateCreateChatBody"; +import { validateCreateChatBody, createChatBodySchema } from "../validateCreateChatBody"; describe("validateCreateChatBody", () => { describe("artistId validation", () => { @@ -13,9 +10,7 @@ describe("validateCreateChatBody", () => { }); expect(result).not.toBeInstanceOf(NextResponse); - expect((result as any).artistId).toBe( - "123e4567-e89b-12d3-a456-426614174000", - ); + expect((result as any).artistId).toBe("123e4567-e89b-12d3-a456-426614174000"); }); it("rejects invalid UUID for artistId", () => { @@ -40,9 +35,7 @@ describe("validateCreateChatBody", () => { }); expect(result).not.toBeInstanceOf(NextResponse); - expect((result as any).chatId).toBe( - "123e4567-e89b-12d3-a456-426614174000", - ); + expect((result as any).chatId).toBe("123e4567-e89b-12d3-a456-426614174000"); }); it("rejects invalid UUID for chatId", () => { @@ -61,9 +54,7 @@ describe("validateCreateChatBody", () => { }); expect(result).not.toBeInstanceOf(NextResponse); - expect((result as any).accountId).toBe( - "123e4567-e89b-12d3-a456-426614174000", - ); + expect((result as any).accountId).toBe("123e4567-e89b-12d3-a456-426614174000"); }); it("rejects invalid UUID for accountId", () => { @@ -95,9 +86,7 @@ describe("validateCreateChatBody", () => { const result = createChatBodySchema.safeParse(validBody); expect(result.success).toBe(true); if (result.success) { - expect(result.data.accountId).toBe( - "123e4567-e89b-12d3-a456-426614174002", - ); + expect(result.data.accountId).toBe("123e4567-e89b-12d3-a456-426614174002"); } }); }); @@ -110,9 +99,7 @@ describe("validateCreateChatBody", () => { }); expect(result).not.toBeInstanceOf(NextResponse); - expect((result as any).firstMessage).toBe( - "What marketing strategies should I use?", - ); + expect((result as any).firstMessage).toBe("What marketing strategies should I use?"); }); it("accepts missing firstMessage (optional)", () => { diff --git a/lib/chats/__tests__/validateGetChatsQuery.test.ts b/lib/chats/__tests__/validateGetChatsQuery.test.ts deleted file mode 100644 index 0f7c7acd..00000000 --- a/lib/chats/__tests__/validateGetChatsQuery.test.ts +++ /dev/null @@ -1,130 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { NextResponse } from "next/server"; -import { validateGetChatsQuery } from "../validateGetChatsQuery"; - -describe("validateGetChatsQuery", () => { - describe("account_id validation", () => { - it("accepts valid UUID for account_id", () => { - const searchParams = new URLSearchParams({ - account_id: "123e4567-e89b-12d3-a456-426614174000", - }); - - const result = validateGetChatsQuery(searchParams); - - expect(result).not.toBeInstanceOf(NextResponse); - expect((result as { account_id: string }).account_id).toBe( - "123e4567-e89b-12d3-a456-426614174000", - ); - }); - - it("rejects missing account_id", () => { - const searchParams = new URLSearchParams({}); - - const result = validateGetChatsQuery(searchParams); - - expect(result).toBeInstanceOf(NextResponse); - if (result instanceof NextResponse) { - expect(result.status).toBe(400); - } - }); - - it("rejects invalid UUID for account_id", () => { - const searchParams = new URLSearchParams({ - account_id: "invalid-uuid", - }); - - const result = validateGetChatsQuery(searchParams); - - expect(result).toBeInstanceOf(NextResponse); - if (result instanceof NextResponse) { - expect(result.status).toBe(400); - } - }); - }); - - describe("artist_account_id validation", () => { - it("accepts valid UUID for artist_account_id", () => { - const searchParams = new URLSearchParams({ - account_id: "123e4567-e89b-12d3-a456-426614174000", - artist_account_id: "123e4567-e89b-12d3-a456-426614174001", - }); - - const result = validateGetChatsQuery(searchParams); - - expect(result).not.toBeInstanceOf(NextResponse); - expect((result as { artist_account_id?: string }).artist_account_id).toBe( - "123e4567-e89b-12d3-a456-426614174001", - ); - }); - - it("accepts missing artist_account_id (optional)", () => { - const searchParams = new URLSearchParams({ - account_id: "123e4567-e89b-12d3-a456-426614174000", - }); - - const result = validateGetChatsQuery(searchParams); - - expect(result).not.toBeInstanceOf(NextResponse); - expect((result as { artist_account_id?: string }).artist_account_id).toBeUndefined(); - }); - - it("rejects invalid UUID for artist_account_id", () => { - const searchParams = new URLSearchParams({ - account_id: "123e4567-e89b-12d3-a456-426614174000", - artist_account_id: "invalid-uuid", - }); - - const result = validateGetChatsQuery(searchParams); - - expect(result).toBeInstanceOf(NextResponse); - if (result instanceof NextResponse) { - expect(result.status).toBe(400); - } - }); - }); - - describe("error response format", () => { - it("returns helpful error message for missing account_id", async () => { - const searchParams = new URLSearchParams({}); - - const result = validateGetChatsQuery(searchParams); - - expect(result).toBeInstanceOf(NextResponse); - if (result instanceof NextResponse) { - const json = await result.json(); - expect(json.status).toBe("error"); - expect(json.missing_fields).toEqual(["account_id"]); - expect(json.error).toBe("account_id is required"); - } - }); - - it("returns helpful error message for invalid account_id UUID", async () => { - const searchParams = new URLSearchParams({ account_id: "not-a-uuid" }); - - const result = validateGetChatsQuery(searchParams); - - expect(result).toBeInstanceOf(NextResponse); - if (result instanceof NextResponse) { - const json = await result.json(); - expect(json.status).toBe("error"); - expect(json.error).toBe("account_id must be a valid UUID"); - } - }); - - it("returns helpful error message for invalid artist_account_id UUID", async () => { - const searchParams = new URLSearchParams({ - account_id: "123e4567-e89b-12d3-a456-426614174000", - artist_account_id: "not-a-uuid", - }); - - const result = validateGetChatsQuery(searchParams); - - expect(result).toBeInstanceOf(NextResponse); - if (result instanceof NextResponse) { - const json = await result.json(); - expect(json.status).toBe("error"); - expect(json.error).toBe("artist_account_id must be a valid UUID"); - } - }); - }); -}); diff --git a/lib/chats/__tests__/validateGetChatsRequest.test.ts b/lib/chats/__tests__/validateGetChatsRequest.test.ts new file mode 100644 index 00000000..c2405cd8 --- /dev/null +++ b/lib/chats/__tests__/validateGetChatsRequest.test.ts @@ -0,0 +1,270 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; +import { validateGetChatsRequest } from "../validateGetChatsRequest"; + +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { canAccessAccount } from "@/lib/organizations/canAccessAccount"; +import { getAccountOrganizations } from "@/lib/supabase/account_organization_ids/getAccountOrganizations"; + +// Mock dependencies +vi.mock("@/lib/auth/validateAuthContext", () => ({ + validateAuthContext: vi.fn(), +})); + +vi.mock("@/lib/organizations/canAccessAccount", () => ({ + canAccessAccount: vi.fn(), +})); + +vi.mock("@/lib/supabase/account_organization_ids/getAccountOrganizations", () => ({ + getAccountOrganizations: vi.fn(), +})); + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => new Headers()), +})); + +vi.mock("@/lib/const", () => ({ + RECOUP_ORG_ID: "recoup-org-id", +})); + +describe("validateGetChatsRequest", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should return error if auth fails", async () => { + vi.mocked(validateAuthContext).mockResolvedValue( + NextResponse.json({ status: "error", error: "Unauthorized" }, { status: 401 }), + ); + + const request = new NextRequest("http://localhost/api/chats"); + const result = await validateGetChatsRequest(request); + + expect(result).toBeInstanceOf(NextResponse); + const response = result as NextResponse; + expect(response.status).toBe(401); + }); + + it("should return single account ID for personal key", async () => { + const mockAccountId = "personal-account-123"; + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: mockAccountId, + orgId: null, // Personal key + authToken: "test-token", + }); + + const request = new NextRequest("http://localhost/api/chats", { + headers: { "x-api-key": "test-api-key" }, + }); + const result = await validateGetChatsRequest(request); + + expect(result).not.toBeInstanceOf(NextResponse); + expect(result).toEqual({ + account_ids: [mockAccountId], + artist_id: undefined, + }); + }); + + it("should return org member account_ids for org key", async () => { + const mockOrgId = "org-123"; + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: mockOrgId, + orgId: mockOrgId, + authToken: "test-token", + }); + vi.mocked(getAccountOrganizations).mockResolvedValue([ + { account_id: "member-1", organization_id: mockOrgId, organization: null }, + { account_id: "member-2", organization_id: mockOrgId, organization: null }, + ]); + + const request = new NextRequest("http://localhost/api/chats", { + headers: { "x-api-key": "test-api-key" }, + }); + const result = await validateGetChatsRequest(request); + + expect(result).not.toBeInstanceOf(NextResponse); + expect(result).toEqual({ + account_ids: ["member-1", "member-2"], + artist_id: undefined, + }); + }); + + it("should return undefined account_ids for Recoup admin key", async () => { + const recoupOrgId = "recoup-org-id"; + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: recoupOrgId, + orgId: recoupOrgId, + authToken: "test-token", + }); + + const request = new NextRequest("http://localhost/api/chats", { + headers: { "x-api-key": "recoup-admin-key" }, + }); + const result = await validateGetChatsRequest(request); + + expect(result).not.toBeInstanceOf(NextResponse); + expect(result).toEqual({ + account_ids: undefined, + artist_id: undefined, + }); + }); + + it("should parse artist_account_id query parameter correctly", async () => { + const mockAccountId = "account-123"; + const artistId = "a1111111-1111-4111-8111-111111111111"; + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: mockAccountId, + orgId: null, + authToken: "test-token", + }); + + const request = new NextRequest(`http://localhost/api/chats?artist_account_id=${artistId}`, { + headers: { "x-api-key": "test-api-key" }, + }); + const result = await validateGetChatsRequest(request); + + expect(result).not.toBeInstanceOf(NextResponse); + expect(result).toEqual({ + account_ids: [mockAccountId], + artist_id: artistId, + }); + }); + + it("should reject invalid artist_account_id query parameter", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "account-123", + orgId: null, + authToken: "test-token", + }); + + const request = new NextRequest("http://localhost/api/chats?artist_account_id=invalid", { + headers: { "x-api-key": "test-api-key" }, + }); + const result = await validateGetChatsRequest(request); + + expect(result).toBeInstanceOf(NextResponse); + const response = result as NextResponse; + expect(response.status).toBe(400); + }); + + it("should reject personal key trying to filter by account_id", async () => { + const mockAccountId = "a1111111-1111-4111-8111-111111111111"; + const otherAccountId = "b2222222-2222-4222-8222-222222222222"; + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: mockAccountId, + orgId: null, // Personal key + authToken: "test-token", + }); + + const request = new NextRequest(`http://localhost/api/chats?account_id=${otherAccountId}`, { + headers: { "x-api-key": "test-api-key" }, + }); + const result = await validateGetChatsRequest(request); + + expect(result).toBeInstanceOf(NextResponse); + const response = result as NextResponse; + expect(response.status).toBe(403); + }); + + it("should allow org key to filter by account_id within org", async () => { + const mockOrgId = "c3333333-3333-4333-8333-333333333333"; + const targetAccountId = "d4444444-4444-4444-8444-444444444444"; + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: mockOrgId, + orgId: mockOrgId, + authToken: "test-token", + }); + vi.mocked(canAccessAccount).mockResolvedValue(true); + + const request = new NextRequest(`http://localhost/api/chats?account_id=${targetAccountId}`, { + headers: { "x-api-key": "test-api-key" }, + }); + const result = await validateGetChatsRequest(request); + + expect(canAccessAccount).toHaveBeenCalledWith({ + orgId: mockOrgId, + targetAccountId, + }); + expect(result).not.toBeInstanceOf(NextResponse); + expect(result).toEqual({ + account_ids: [targetAccountId], + artist_id: undefined, + }); + }); + + it("should reject org key filtering by account_id not in org", async () => { + const mockOrgId = "f6666666-6666-4666-8666-666666666666"; + const notInOrgId = "b8888888-8888-4888-8888-888888888888"; + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: mockOrgId, + orgId: mockOrgId, + authToken: "test-token", + }); + vi.mocked(canAccessAccount).mockResolvedValue(false); + + const request = new NextRequest(`http://localhost/api/chats?account_id=${notInOrgId}`, { + headers: { "x-api-key": "test-api-key" }, + }); + const result = await validateGetChatsRequest(request); + + expect(canAccessAccount).toHaveBeenCalledWith({ + orgId: mockOrgId, + targetAccountId: notInOrgId, + }); + expect(result).toBeInstanceOf(NextResponse); + const response = result as NextResponse; + expect(response.status).toBe(403); + }); + + it("should allow Recoup admin to filter by any account_id", async () => { + const recoupOrgId = "recoup-org-id"; + const anyAccountId = "a1111111-1111-4111-8111-111111111111"; + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: recoupOrgId, + orgId: recoupOrgId, + authToken: "test-token", + }); + vi.mocked(canAccessAccount).mockResolvedValue(true); // Admin always has access + + const request = new NextRequest(`http://localhost/api/chats?account_id=${anyAccountId}`, { + headers: { "x-api-key": "recoup-admin-key" }, + }); + const result = await validateGetChatsRequest(request); + + expect(canAccessAccount).toHaveBeenCalledWith({ + orgId: recoupOrgId, + targetAccountId: anyAccountId, + }); + expect(result).not.toBeInstanceOf(NextResponse); + expect(result).toEqual({ + account_ids: [anyAccountId], + artist_id: undefined, + }); + }); + + it("should allow combining account_id and artist_account_id filters", async () => { + const mockOrgId = "c3333333-3333-4333-8333-333333333333"; + const targetAccountId = "d4444444-4444-4444-8444-444444444444"; + const artistId = "e5555555-5555-4555-8555-555555555555"; + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: mockOrgId, + orgId: mockOrgId, + authToken: "test-token", + }); + vi.mocked(canAccessAccount).mockResolvedValue(true); + + const request = new NextRequest( + `http://localhost/api/chats?account_id=${targetAccountId}&artist_account_id=${artistId}`, + { + headers: { "x-api-key": "test-api-key" }, + }, + ); + const result = await validateGetChatsRequest(request); + + expect(result).not.toBeInstanceOf(NextResponse); + expect(result).toEqual({ + account_ids: [targetAccountId], + artist_id: artistId, + }); + }); +}); diff --git a/lib/chats/buildGetChatsParams.ts b/lib/chats/buildGetChatsParams.ts new file mode 100644 index 00000000..71f3c097 --- /dev/null +++ b/lib/chats/buildGetChatsParams.ts @@ -0,0 +1,67 @@ +import { canAccessAccount } from "@/lib/organizations/canAccessAccount"; +import type { SelectRoomsParams } from "@/lib/supabase/rooms/selectRooms"; +import { getAccountOrganizations } from "@/lib/supabase/account_organization_ids/getAccountOrganizations"; +import { RECOUP_ORG_ID } from "@/lib/const"; + +export interface BuildGetChatsParamsInput { + /** The authenticated account ID */ + account_id: string; + /** The organization ID from the API key (null for personal keys) */ + org_id: string | null; + /** Optional target account ID to filter by */ + target_account_id?: string; + /** Optional artist ID to filter by */ + artist_id?: string; +} + +export type BuildGetChatsParamsResult = + | { params: SelectRoomsParams; error: null } + | { params: null; error: string }; + +/** + * Builds the parameters for selectRooms based on auth context. + * + * For personal keys: Returns account_ids with the key owner's account + * For org keys: Fetches all org member account_ids and returns them + * For Recoup admin key: Returns empty params to indicate ALL records + * + * If target_account_id is provided, validates access and returns that account. + * + * @param input - The auth context and optional filters + * @returns The params for selectRooms or an error + */ +export async function buildGetChatsParams( + input: BuildGetChatsParamsInput, +): Promise { + const { account_id, org_id, target_account_id, artist_id } = input; + + // Handle account_id filter if provided + if (target_account_id) { + const hasAccess = await canAccessAccount({ orgId: org_id, targetAccountId: target_account_id }); + if (!hasAccess) { + return { + params: null, + error: org_id + ? "account_id is not a member of this organization" + : "Personal API keys cannot filter by account_id", + }; + } + return { params: { account_ids: [target_account_id], artist_id }, error: null }; + } + + // No account_id filter - determine what to return based on key type + if (org_id === RECOUP_ORG_ID) { + // Recoup admin: return undefined to indicate ALL records + return { params: { artist_id }, error: null }; + } + + if (org_id) { + // Org key: fetch all member account IDs for this organization + const orgMembers = await getAccountOrganizations({ organizationId: org_id }); + const memberAccountIds = orgMembers.map((member) => member.account_id); + return { params: { account_ids: memberAccountIds, artist_id }, error: null }; + } + + // Personal key: Only return the key owner's account + return { params: { account_ids: [account_id], artist_id }, error: null }; +} diff --git a/lib/chats/getChatsHandler.ts b/lib/chats/getChatsHandler.ts index d91dbffc..da180ddf 100644 --- a/lib/chats/getChatsHandler.ts +++ b/lib/chats/getChatsHandler.ts @@ -1,39 +1,32 @@ import { NextRequest, NextResponse } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; -import { validateAuthContext } from "@/lib/auth/validateAuthContext"; -import { validateGetChatsQuery } from "@/lib/chats/validateGetChatsQuery"; +import { validateGetChatsRequest } from "@/lib/chats/validateGetChatsRequest"; import { selectRooms } from "@/lib/supabase/rooms/selectRooms"; /** * Handler for retrieving chats. + * Requires authentication via x-api-key header or Authorization bearer token. * - * Query parameters: - * - account_id (required): The account's ID (UUID) - * - artist_account_id (optional): Filter to chats for a specific artist (UUID) + * For personal keys: Returns array of chats for the account (if exists). + * For org keys: Returns array of chats for accounts in the organization. + * For Recoup admin key: Returns array of ALL chat records. + * + * Optional query parameters: + * - account_id: Filter to a specific account (validated against org membership) + * - artist_account_id: Filter to chats for a specific artist (UUID) * * @param request - The request object containing query parameters * @returns A NextResponse with chats data */ export async function getChatsHandler(request: NextRequest): Promise { try { - const authResult = await validateAuthContext(request); - if (authResult instanceof NextResponse) { - return authResult; + const validated = await validateGetChatsRequest(request); + if (validated instanceof NextResponse) { + return validated; } - const { searchParams } = new URL(request.url); - - const validatedQuery = validateGetChatsQuery(searchParams); - if (validatedQuery instanceof NextResponse) { - return validatedQuery; - } - - const { account_id, artist_account_id } = validatedQuery; - - const chats = await selectRooms({ - accountId: account_id, - artistId: artist_account_id, - }); + // Pass validated params directly to selectRooms + const chats = await selectRooms(validated); if (chats === null) { return NextResponse.json( diff --git a/lib/chats/validateGetChatsQuery.ts b/lib/chats/validateGetChatsQuery.ts deleted file mode 100644 index f81589e1..00000000 --- a/lib/chats/validateGetChatsQuery.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { NextResponse } from "next/server"; -import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; -import { z } from "zod"; - -const getChatsQuerySchema = z.object({ - account_id: z - .string({ message: "account_id is required" }) - .uuid("account_id must be a valid UUID"), - artist_account_id: z.uuid({ message: "artist_account_id must be a valid UUID" }).optional(), -}); - -export type GetChatsQuery = z.infer; - -/** - * Validates query parameters for GET /api/chats. - * - * @param searchParams - The URL search params - * @returns A NextResponse with an error if validation fails, or the validated query if validation passes. - */ -export function validateGetChatsQuery(searchParams: URLSearchParams): NextResponse | GetChatsQuery { - const params = { - account_id: searchParams.get("account_id") || undefined, - artist_account_id: searchParams.get("artist_account_id") || undefined, - }; - - const result = getChatsQuerySchema.safeParse(params); - - 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(), - }, - ); - } - - return result.data; -} diff --git a/lib/chats/validateGetChatsRequest.ts b/lib/chats/validateGetChatsRequest.ts new file mode 100644 index 00000000..da68c116 --- /dev/null +++ b/lib/chats/validateGetChatsRequest.ts @@ -0,0 +1,80 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import type { SelectRoomsParams } from "@/lib/supabase/rooms/selectRooms"; +import { buildGetChatsParams } from "./buildGetChatsParams"; +import { z } from "zod"; + +const getChatsQuerySchema = z.object({ + account_id: z.string().uuid("account_id must be a valid UUID").optional(), + artist_account_id: z.string().uuid("artist_account_id must be a valid UUID").optional(), +}); + +/** + * Validates GET /api/chats request. + * Handles authentication via x-api-key or Authorization bearer token. + * + * For personal keys: Returns accountIds with the key owner's account + * For org keys: Returns orgId for filtering by org membership in database + * For Recoup admin key: Returns empty params to indicate ALL chat records + * + * Query parameters: + * - account_id: Filter to a specific account (validated against org membership) + * - artist_account_id: Filter by artist ID + * + * @param request - The NextRequest object + * @returns A NextResponse with an error if validation fails, or SelectRoomsParams + */ +export async function validateGetChatsRequest( + request: NextRequest, +): Promise { + // Parse query parameters first + const { searchParams } = new URL(request.url); + const queryParams = { + account_id: searchParams.get("account_id") ?? undefined, + artist_account_id: searchParams.get("artist_account_id") ?? undefined, + }; + + const queryResult = getChatsQuerySchema.safeParse(queryParams); + if (!queryResult.success) { + const firstError = queryResult.error.issues[0]; + return NextResponse.json( + { + status: "error", + error: firstError.message, + }, + { status: 400, headers: getCorsHeaders() }, + ); + } + + const { account_id: target_account_id, artist_account_id: artist_id } = queryResult.data; + + // Use validateAuthContext for authentication + const authResult = await validateAuthContext(request); + if (authResult instanceof NextResponse) { + return authResult; + } + + const { accountId: account_id, orgId: org_id } = authResult; + + // Use shared function to build params + const { params, error } = await buildGetChatsParams({ + account_id, + org_id, + target_account_id, + artist_id, + }); + + if (error) { + return NextResponse.json( + { + status: "error", + error, + }, + { status: 403, headers: getCorsHeaders() }, + ); + } + + return params; +} diff --git a/lib/supabase/rooms/__tests__/selectRooms.test.ts b/lib/supabase/rooms/__tests__/selectRooms.test.ts index 82b3732f..c021340d 100644 --- a/lib/supabase/rooms/__tests__/selectRooms.test.ts +++ b/lib/supabase/rooms/__tests__/selectRooms.test.ts @@ -24,6 +24,7 @@ describe("selectRooms", () => { function createMockQueryBuilder(data: unknown, error: unknown = null) { const chainableMock = { eq: vi.fn(), + in: vi.fn(), order: vi.fn(), then: vi.fn((resolve: (value: { data: unknown; error: unknown }) => void) => { resolve({ data, error }); @@ -31,6 +32,7 @@ describe("selectRooms", () => { }; chainableMock.eq.mockReturnValue(chainableMock); + chainableMock.in.mockReturnValue(chainableMock); chainableMock.order.mockReturnValue(chainableMock); const selectMock = vi.fn().mockReturnValue(chainableMock); @@ -67,6 +69,7 @@ describe("selectRooms", () => { expect(result).toEqual(mockRooms); expect(supabase.from).toHaveBeenCalledWith("rooms"); expect(chainableMock.eq).not.toHaveBeenCalled(); + expect(chainableMock.in).not.toHaveBeenCalled(); }); it("returns all rooms when empty object provided", async () => { @@ -78,16 +81,17 @@ describe("selectRooms", () => { expect(result).toEqual(mockRooms); expect(chainableMock.eq).not.toHaveBeenCalled(); + expect(chainableMock.in).not.toHaveBeenCalled(); }); }); - describe("with accountId filter", () => { - it("filters by accountId when provided", async () => { - const accountId = "123e4567-e89b-12d3-a456-426614174000"; + describe("with account_ids filter", () => { + it("filters by account_ids when provided", async () => { + const account_ids = ["123e4567-e89b-12d3-a456-426614174000"]; const mockRooms = [ { id: "room-1", - account_id: accountId, + account_id: account_ids[0], artist_id: null, topic: "Room 1", updated_at: "2024-01-01T00:00:00Z", @@ -96,22 +100,46 @@ describe("selectRooms", () => { const { chainableMock } = createMockQueryBuilder(mockRooms); - const result = await selectRooms({ accountId }); + const result = await selectRooms({ account_ids }); expect(result).toEqual(mockRooms); - expect(chainableMock.eq).toHaveBeenCalledTimes(1); - expect(chainableMock.eq).toHaveBeenCalledWith("account_id", accountId); + expect(chainableMock.in).toHaveBeenCalledTimes(1); + expect(chainableMock.in).toHaveBeenCalledWith("account_id", account_ids); + }); + + it("returns empty array when account_ids is empty array", async () => { + createMockQueryBuilder([]); + + const result = await selectRooms({ account_ids: [] }); + + expect(result).toEqual([]); + expect(supabase.from).not.toHaveBeenCalled(); + }); + + it("filters by multiple account_ids", async () => { + const account_ids = ["account-1", "account-2", "account-3"]; + const mockRooms = [ + { id: "room-1", account_id: "account-1" }, + { id: "room-2", account_id: "account-2" }, + ]; + + const { chainableMock } = createMockQueryBuilder(mockRooms); + + const result = await selectRooms({ account_ids }); + + expect(result).toEqual(mockRooms); + expect(chainableMock.in).toHaveBeenCalledWith("account_id", account_ids); }); }); - describe("with artistId filter", () => { - it("filters by artistId when provided", async () => { - const artistId = "123e4567-e89b-12d3-a456-426614174001"; + describe("with artist_id filter", () => { + it("filters by artist_id when provided", async () => { + const artist_id = "123e4567-e89b-12d3-a456-426614174001"; const mockRooms = [ { id: "room-1", account_id: "account-1", - artist_id: artistId, + artist_id: artist_id, topic: "Artist Room", updated_at: "2024-01-01T00:00:00Z", }, @@ -119,23 +147,23 @@ describe("selectRooms", () => { const { chainableMock } = createMockQueryBuilder(mockRooms); - const result = await selectRooms({ artistId }); + const result = await selectRooms({ artist_id }); expect(result).toEqual(mockRooms); expect(chainableMock.eq).toHaveBeenCalledTimes(1); - expect(chainableMock.eq).toHaveBeenCalledWith("artist_id", artistId); + expect(chainableMock.eq).toHaveBeenCalledWith("artist_id", artist_id); }); }); describe("with combined filters", () => { - it("filters by both accountId and artistId when provided", async () => { - const accountId = "123e4567-e89b-12d3-a456-426614174000"; - const artistId = "123e4567-e89b-12d3-a456-426614174001"; + it("filters by both account_ids and artist_id when provided", async () => { + const account_ids = ["123e4567-e89b-12d3-a456-426614174000"]; + const artist_id = "123e4567-e89b-12d3-a456-426614174001"; const mockRooms = [ { id: "room-1", - account_id: accountId, - artist_id: artistId, + account_id: account_ids[0], + artist_id: artist_id, topic: "Artist Room", updated_at: "2024-01-01T00:00:00Z", }, @@ -143,12 +171,13 @@ describe("selectRooms", () => { const { chainableMock } = createMockQueryBuilder(mockRooms); - const result = await selectRooms({ accountId, artistId }); + const result = await selectRooms({ account_ids, artist_id }); expect(result).toEqual(mockRooms); - expect(chainableMock.eq).toHaveBeenCalledTimes(2); - expect(chainableMock.eq).toHaveBeenCalledWith("account_id", accountId); - expect(chainableMock.eq).toHaveBeenCalledWith("artist_id", artistId); + expect(chainableMock.in).toHaveBeenCalledTimes(1); + expect(chainableMock.in).toHaveBeenCalledWith("account_id", account_ids); + expect(chainableMock.eq).toHaveBeenCalledTimes(1); + expect(chainableMock.eq).toHaveBeenCalledWith("artist_id", artist_id); }); }); @@ -168,7 +197,7 @@ describe("selectRooms", () => { it("returns empty array when no rooms found", async () => { createMockQueryBuilder([]); - const result = await selectRooms({ accountId: "non-existent" }); + const result = await selectRooms({ account_ids: ["non-existent"] }); expect(result).toEqual([]); }); diff --git a/lib/supabase/rooms/__tests__/upsertRoom.test.ts b/lib/supabase/rooms/__tests__/upsertRoom.test.ts index 1aa12c7a..c5fb2e47 100644 --- a/lib/supabase/rooms/__tests__/upsertRoom.test.ts +++ b/lib/supabase/rooms/__tests__/upsertRoom.test.ts @@ -1,5 +1,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; +import { upsertRoom } from "../upsertRoom"; + const mockFrom = vi.fn(); const mockUpsert = vi.fn(); const mockSelect = vi.fn(); @@ -11,8 +13,6 @@ vi.mock("@/lib/supabase/serverClient", () => ({ }, })); -import { upsertRoom } from "../upsertRoom"; - describe("upsertRoom", () => { beforeEach(() => { vi.clearAllMocks(); diff --git a/lib/supabase/rooms/selectRooms.ts b/lib/supabase/rooms/selectRooms.ts index 550046a4..33e1debe 100644 --- a/lib/supabase/rooms/selectRooms.ts +++ b/lib/supabase/rooms/selectRooms.ts @@ -3,30 +3,40 @@ import type { Tables } from "@/types/database.types"; type Room = Tables<"rooms">; -interface SelectRoomsParams { - accountId?: string; - artistId?: string; +export interface SelectRoomsParams { + /** Account IDs to filter by. If undefined, returns all records. */ + account_ids?: string[]; + /** Filter by artist ID */ + artist_id?: string; } /** * Selects rooms with optional filters. + * - If account_ids is provided, filters by those IDs. + * - If account_ids is undefined, returns all records. * * @param params - Optional filter parameters - * @param params.accountId - Filter by account ID - * @param params.artistId - Filter by artist ID + * @param params.account_ids - Optional array of account IDs to filter by. + * @param params.artist_id - Filter by artist ID * @returns Array of rooms or null if error */ export async function selectRooms(params: SelectRoomsParams = {}): Promise { - const { accountId, artistId } = params; + const { account_ids, artist_id } = params; + + // If account_ids is an empty array, return empty (no accounts to look up) + if (account_ids !== undefined && account_ids.length === 0) { + return []; + } let query = supabase.from("rooms").select("*"); - if (accountId) { - query = query.eq("account_id", accountId); + // Filter by account IDs if provided + if (account_ids !== undefined) { + query = query.in("account_id", account_ids); } - if (artistId) { - query = query.eq("artist_id", artistId); + if (artist_id) { + query = query.eq("artist_id", artist_id); } query = query.order("updated_at", { ascending: false });