From d35445324be3d7308569200033cbed6d5c065942 Mon Sep 17 00:00:00 2001 From: Arpit Gupta Date: Wed, 8 Apr 2026 03:22:03 +0530 Subject: [PATCH 1/2] refactor: migrate account emails to dedicated api --- app/api/account-emails/route.ts | 45 ------------ components/Files/FileInfoDialog.tsx | 23 ++----- components/TasksPage/TasksList.tsx | 23 +------ hooks/useAccountEmails.ts | 42 +++++++++++ .../__tests__/fetchAccountEmails.test.ts | 69 +++++++++++++++++++ lib/accounts/fetchAccountEmails.ts | 40 +++++++++++ 6 files changed, 159 insertions(+), 83 deletions(-) delete mode 100644 app/api/account-emails/route.ts create mode 100644 hooks/useAccountEmails.ts create mode 100644 lib/accounts/__tests__/fetchAccountEmails.test.ts create mode 100644 lib/accounts/fetchAccountEmails.ts diff --git a/app/api/account-emails/route.ts b/app/api/account-emails/route.ts deleted file mode 100644 index 32f5fdf4a..000000000 --- a/app/api/account-emails/route.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import getAccountEmails from "@/lib/supabase/account_emails/getAccountEmails"; -import { checkAccountArtistAccess } from "@/lib/supabase/account_artist_ids/checkAccountArtistAccess"; - -export async function GET(req: NextRequest) { - const accountIds = req.nextUrl.searchParams.getAll("accountIds"); - const currentAccountId = req.nextUrl.searchParams.get("currentAccountId"); - const artistAccountId = req.nextUrl.searchParams.get("artistAccountId"); - - if (!currentAccountId || !artistAccountId) { - return NextResponse.json( - { error: "Missing authentication parameters" }, - { status: 400 } - ); - } - - if (!accountIds || accountIds.length === 0) { - return NextResponse.json([]); - } - - try { - // Verify current user has access to this artist - const hasAccess = await checkAccountArtistAccess( - currentAccountId, - artistAccountId - ); - - if (!hasAccess) { - return NextResponse.json({ error: "Unauthorized" }, { status: 403 }); - } - - // Security model: If you can see tasks for this artist, you can see task creators' emails - // No additional filtering needed - the tasks query already scopes by artist access - const emails = await getAccountEmails(accountIds); - return NextResponse.json(emails); - } catch { - return NextResponse.json( - { error: "Failed to fetch account emails" }, - { status: 500 } - ); - } -} - -export const dynamic = "force-dynamic"; - diff --git a/components/Files/FileInfoDialog.tsx b/components/Files/FileInfoDialog.tsx index b743b446f..337b3463f 100644 --- a/components/Files/FileInfoDialog.tsx +++ b/components/Files/FileInfoDialog.tsx @@ -11,11 +11,8 @@ import { extractAccountIds } from "@/utils/extractAccountIds"; import FileInfoDialogHeader from "./FileInfoDialogHeader"; import FileInfoDialogContent from "./FileInfoDialogContent"; import FileInfoDialogMetadata from "./FileInfoDialogMetadata"; -import { useQuery } from "@tanstack/react-query"; -import { Tables } from "@/types/database.types"; import { useUserProvider } from "@/providers/UserProvder"; - -type AccountEmail = Tables<"account_emails">; +import { useAccountEmails } from "@/hooks/useAccountEmails"; type FileInfoDialogProps = { file: FileRow | null; @@ -38,19 +35,10 @@ export default function FileInfoDialog({ file, open, onOpenChange }: FileInfoDia const canEdit = file ? isTextFile(file.file_name) : false; // Fetch owner email - const { data: emails } = useQuery({ - queryKey: ["file-owner-email", ownerAccountId, artistAccountId], - queryFn: async () => { - if (!ownerAccountId || !artistAccountId || !userData) return []; - const params = new URLSearchParams(); - params.append("accountIds", ownerAccountId); - params.append("currentAccountId", userData.id); - params.append("artistAccountId", artistAccountId); - const response = await fetch(`/api/account-emails?${params}`); - if (!response.ok) return []; - return response.json(); - }, - enabled: !!ownerAccountId && !!artistAccountId && !!userData && open, + const { data: emails } = useAccountEmails({ + accountIds: ownerAccountId ? [ownerAccountId] : [], + enabled: open, + queryKey: ["file-owner-email", ownerAccountId], }); const ownerEmail = emails?.[0]?.email || undefined; @@ -121,4 +109,3 @@ export default function FileInfoDialog({ file, open, onOpenChange }: FileInfoDia ); } - diff --git a/components/TasksPage/TasksList.tsx b/components/TasksPage/TasksList.tsx index 9bd21c38c..38463e8a2 100644 --- a/components/TasksPage/TasksList.tsx +++ b/components/TasksPage/TasksList.tsx @@ -1,14 +1,10 @@ -import { Tables } from "@/types/database.types"; import { Task } from "@/lib/tasks/getTasks"; import TaskCard from "@/components/VercelChat/tools/tasks/TaskCard"; import TaskSkeleton from "./TaskSkeleton"; import TaskDetailsDialog from "@/components/VercelChat/dialogs/tasks/TaskDetailsDialog"; -import { useArtistProvider } from "@/providers/ArtistProvider"; import { useUserProvider } from "@/providers/UserProvder"; import { useMemo } from "react"; -import { useQuery } from "@tanstack/react-query"; - -type AccountEmail = Tables<"account_emails">; +import { useAccountEmails } from "@/hooks/useAccountEmails"; interface TasksListProps { tasks: Task[]; @@ -18,7 +14,6 @@ interface TasksListProps { const TasksList: React.FC = ({ tasks, isLoading, isError }) => { const { userData } = useUserProvider(); - const { selectedArtist } = useArtistProvider(); // Extract unique account IDs from tasks const accountIds = useMemo( @@ -27,21 +22,9 @@ const TasksList: React.FC = ({ tasks, isLoading, isError }) => { ); // Batch fetch emails for all task owners - const { data: accountEmails = [] } = useQuery({ + const { data: accountEmails = [] } = useAccountEmails({ + accountIds, queryKey: ["task-owner-emails", accountIds], - queryFn: async () => { - if (accountIds.length === 0 || !userData) return []; - const params = new URLSearchParams(); - accountIds.forEach(id => params.append("accountIds", id)); - params.append("currentAccountId", userData.id); - if (selectedArtist) { - params.append("artistAccountId", selectedArtist.account_id); - } - const response = await fetch(`/api/account-emails?${params}`); - if (!response.ok) throw new Error("Failed to fetch emails"); - return response.json(); - }, - enabled: accountIds.length > 0 && !!userData, }); // Create lookup map for O(1) email access diff --git a/hooks/useAccountEmails.ts b/hooks/useAccountEmails.ts new file mode 100644 index 000000000..c18c58245 --- /dev/null +++ b/hooks/useAccountEmails.ts @@ -0,0 +1,42 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { usePrivy } from "@privy-io/react-auth"; +import { fetchAccountEmails, type AccountEmail } from "@/lib/accounts/fetchAccountEmails"; + +interface UseAccountEmailsParams { + accountIds: string[]; + enabled?: boolean; + queryKey?: readonly unknown[]; +} + +/** + * Fetches account email rows for one or more account IDs the authenticated user can access. + */ +export function useAccountEmails({ + accountIds, + enabled = true, + queryKey = ["account-emails", accountIds], +}: UseAccountEmailsParams) { + const { getAccessToken, authenticated } = usePrivy(); + + return useQuery({ + queryKey: [...queryKey], + queryFn: async () => { + if (accountIds.length === 0) { + return []; + } + + const accessToken = await getAccessToken(); + if (!accessToken) { + throw new Error("Please sign in to view account emails"); + } + + return fetchAccountEmails({ + accessToken, + accountIds, + }); + }, + enabled: enabled && accountIds.length > 0 && authenticated, + }); +} diff --git a/lib/accounts/__tests__/fetchAccountEmails.test.ts b/lib/accounts/__tests__/fetchAccountEmails.test.ts new file mode 100644 index 000000000..ba5b11340 --- /dev/null +++ b/lib/accounts/__tests__/fetchAccountEmails.test.ts @@ -0,0 +1,69 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { fetchAccountEmails } from "../fetchAccountEmails"; + +vi.mock("@/lib/api/getClientApiBaseUrl", () => ({ + getClientApiBaseUrl: vi.fn(() => "https://test-recoup-api.vercel.app"), +})); + +const mockFetch = vi.fn(); +global.fetch = mockFetch; + +describe("fetchAccountEmails", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns early when no account IDs are provided", async () => { + const result = await fetchAccountEmails({ + accessToken: "token", + accountIds: [], + }); + + expect(result).toEqual([]); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it("builds repeated account_id params and sends bearer auth", async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue([ + { + id: "email-1", + account_id: "acc-1", + email: "owner@example.com", + updated_at: "2026-04-08T00:00:00.000Z", + }, + ]), + }); + + const result = await fetchAccountEmails({ + accessToken: "token-123", + accountIds: ["acc-1", "acc-2"], + }); + + expect(mockFetch).toHaveBeenCalledWith( + "https://test-recoup-api.vercel.app/api/accounts/emails?account_id=acc-1&account_id=acc-2", + { + method: "GET", + headers: { + Authorization: "Bearer token-123", + }, + }, + ); + expect(result[0]?.email).toBe("owner@example.com"); + }); + + it("throws the API error message when the request fails", async () => { + mockFetch.mockResolvedValue({ + ok: false, + json: vi.fn().mockResolvedValue({ error: "Unauthorized" }), + }); + + await expect( + fetchAccountEmails({ + accessToken: "token", + accountIds: ["acc-1"], + }), + ).rejects.toThrow("Unauthorized"); + }); +}); diff --git a/lib/accounts/fetchAccountEmails.ts b/lib/accounts/fetchAccountEmails.ts new file mode 100644 index 000000000..202daa518 --- /dev/null +++ b/lib/accounts/fetchAccountEmails.ts @@ -0,0 +1,40 @@ +import { getClientApiBaseUrl } from "@/lib/api/getClientApiBaseUrl"; +import { Tables } from "@/types/database.types"; + +export type AccountEmail = Tables<"account_emails">; + +interface FetchAccountEmailsParams { + accessToken: string; + accountIds: string[]; +} + +/** + * Fetches account email rows for the provided account IDs. + */ +export async function fetchAccountEmails({ + accessToken, + accountIds, +}: FetchAccountEmailsParams): Promise { + if (accountIds.length === 0) { + return []; + } + + const url = new URL(`${getClientApiBaseUrl()}/api/accounts/emails`); + accountIds.forEach(accountId => { + url.searchParams.append("account_id", accountId); + }); + + const response = await fetch(url.toString(), { + method: "GET", + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }); + + if (!response.ok) { + const error = await response.json().catch(() => null); + throw new Error(error?.error || "Failed to fetch account emails"); + } + + return response.json(); +} From 130d00f29ab49fd9fbc6a2820069f5b796efd41c Mon Sep 17 00:00:00 2001 From: Arpit Gupta Date: Wed, 8 Apr 2026 03:24:29 +0530 Subject: [PATCH 2/2] refactor: simplify account emails hook --- components/Files/FileInfoDialog.tsx | 23 ++++--- components/TasksPage/TasksList.tsx | 17 +++-- hooks/useAccountEmails.ts | 9 +-- .../__tests__/fetchAccountEmails.test.ts | 69 ------------------- lib/accounts/fetchAccountEmails.ts | 2 +- 5 files changed, 29 insertions(+), 91 deletions(-) delete mode 100644 lib/accounts/__tests__/fetchAccountEmails.test.ts diff --git a/components/Files/FileInfoDialog.tsx b/components/Files/FileInfoDialog.tsx index 337b3463f..c36f44ccf 100644 --- a/components/Files/FileInfoDialog.tsx +++ b/components/Files/FileInfoDialog.tsx @@ -20,17 +20,21 @@ type FileInfoDialogProps = { onOpenChange: (open: boolean) => void; }; -export default function FileInfoDialog({ file, open, onOpenChange }: FileInfoDialogProps) { +export default function FileInfoDialog({ + file, + open, + onOpenChange, +}: FileInfoDialogProps) { const { userData } = useUserProvider(); const { content } = useFileContent( - file?.file_name || "", - file?.storage_key || "", - userData?.account_id || "" + file?.file_name || "", + file?.storage_key || "", + userData?.account_id || "", ); - + // Extract account IDs and check if file is editable - const { ownerAccountId, artistAccountId } = file - ? extractAccountIds(file.storage_key) + const { ownerAccountId, artistAccountId } = file + ? extractAccountIds(file.storage_key) : { ownerAccountId: "", artistAccountId: "" }; const canEdit = file ? isTextFile(file.file_name) : false; @@ -38,7 +42,6 @@ export default function FileInfoDialog({ file, open, onOpenChange }: FileInfoDia const { data: emails } = useAccountEmails({ accountIds: ownerAccountId ? [ownerAccountId] : [], enabled: open, - queryKey: ["file-owner-email", ownerAccountId], }); const ownerEmail = emails?.[0]?.email || undefined; @@ -80,7 +83,7 @@ export default function FileInfoDialog({ file, open, onOpenChange }: FileInfoDia } baseHandleEditToggle(editing); }; - + return ( @@ -95,7 +98,7 @@ export default function FileInfoDialog({ file, open, onOpenChange }: FileInfoDia />
- = ({ tasks, isLoading, isError }) => { // Extract unique account IDs from tasks const accountIds = useMemo( - () => [...new Set(tasks.map(task => task.account_id))], - [tasks] + () => [...new Set(tasks.map((task) => task.account_id))], + [tasks], ); // Batch fetch emails for all task owners const { data: accountEmails = [] } = useAccountEmails({ accountIds, - queryKey: ["task-owner-emails", accountIds], }); // Create lookup map for O(1) email access const emailByAccountId = useMemo(() => { const map = new Map(); - accountEmails.forEach(ae => { + accountEmails.forEach((ae) => { if (ae.account_id && ae.email) { map.set(ae.account_id, ae.email); } @@ -39,7 +38,11 @@ const TasksList: React.FC = ({ tasks, isLoading, isError }) => { }, [accountEmails]); if (isError) { - return
Failed to load tasks
; + return ( +
+ Failed to load tasks +
+ ); } if (isLoading || !userData) { @@ -71,8 +74,8 @@ const TasksList: React.FC = ({ tasks, isLoading, isError }) => { index !== tasks.length - 1 ? "border-b border-border " : "" } > -
diff --git a/hooks/useAccountEmails.ts b/hooks/useAccountEmails.ts index c18c58245..5cb9f6c99 100644 --- a/hooks/useAccountEmails.ts +++ b/hooks/useAccountEmails.ts @@ -2,12 +2,14 @@ import { useQuery } from "@tanstack/react-query"; import { usePrivy } from "@privy-io/react-auth"; -import { fetchAccountEmails, type AccountEmail } from "@/lib/accounts/fetchAccountEmails"; +import { + fetchAccountEmails, + type AccountEmail, +} from "@/lib/accounts/fetchAccountEmails"; interface UseAccountEmailsParams { accountIds: string[]; enabled?: boolean; - queryKey?: readonly unknown[]; } /** @@ -16,12 +18,11 @@ interface UseAccountEmailsParams { export function useAccountEmails({ accountIds, enabled = true, - queryKey = ["account-emails", accountIds], }: UseAccountEmailsParams) { const { getAccessToken, authenticated } = usePrivy(); return useQuery({ - queryKey: [...queryKey], + queryKey: ["account-emails", accountIds], queryFn: async () => { if (accountIds.length === 0) { return []; diff --git a/lib/accounts/__tests__/fetchAccountEmails.test.ts b/lib/accounts/__tests__/fetchAccountEmails.test.ts deleted file mode 100644 index ba5b11340..000000000 --- a/lib/accounts/__tests__/fetchAccountEmails.test.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { fetchAccountEmails } from "../fetchAccountEmails"; - -vi.mock("@/lib/api/getClientApiBaseUrl", () => ({ - getClientApiBaseUrl: vi.fn(() => "https://test-recoup-api.vercel.app"), -})); - -const mockFetch = vi.fn(); -global.fetch = mockFetch; - -describe("fetchAccountEmails", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("returns early when no account IDs are provided", async () => { - const result = await fetchAccountEmails({ - accessToken: "token", - accountIds: [], - }); - - expect(result).toEqual([]); - expect(mockFetch).not.toHaveBeenCalled(); - }); - - it("builds repeated account_id params and sends bearer auth", async () => { - mockFetch.mockResolvedValue({ - ok: true, - json: vi.fn().mockResolvedValue([ - { - id: "email-1", - account_id: "acc-1", - email: "owner@example.com", - updated_at: "2026-04-08T00:00:00.000Z", - }, - ]), - }); - - const result = await fetchAccountEmails({ - accessToken: "token-123", - accountIds: ["acc-1", "acc-2"], - }); - - expect(mockFetch).toHaveBeenCalledWith( - "https://test-recoup-api.vercel.app/api/accounts/emails?account_id=acc-1&account_id=acc-2", - { - method: "GET", - headers: { - Authorization: "Bearer token-123", - }, - }, - ); - expect(result[0]?.email).toBe("owner@example.com"); - }); - - it("throws the API error message when the request fails", async () => { - mockFetch.mockResolvedValue({ - ok: false, - json: vi.fn().mockResolvedValue({ error: "Unauthorized" }), - }); - - await expect( - fetchAccountEmails({ - accessToken: "token", - accountIds: ["acc-1"], - }), - ).rejects.toThrow("Unauthorized"); - }); -}); diff --git a/lib/accounts/fetchAccountEmails.ts b/lib/accounts/fetchAccountEmails.ts index 202daa518..7f1ec23ea 100644 --- a/lib/accounts/fetchAccountEmails.ts +++ b/lib/accounts/fetchAccountEmails.ts @@ -20,7 +20,7 @@ export async function fetchAccountEmails({ } const url = new URL(`${getClientApiBaseUrl()}/api/accounts/emails`); - accountIds.forEach(accountId => { + accountIds.forEach((accountId) => { url.searchParams.append("account_id", accountId); });