From 66cc71d228865061265cda36b5a184d7a72d4d39 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 27 May 2026 17:12:00 +0000 Subject: [PATCH] Fix collaborators-not-iterable crash; rename misspelled colloborators route MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Crash root cause: UsersPermissionsCard and CapabilitiesCard both used the TanStack Query key ["portfolioUsers", id], but a previous change made UsersPermissionsCard's fetcher return { users, currentUser } while CapabilitiesCard's still returned an array. TanStack Query dedupes by key — whichever component mounted first won the cache; the other read an incompatible shape and crashed on `for (const c of collaborators)`. Fix: extracted a single shared fetcher (collaboratorsClient.ts) so both components import the same fetchCollaborators function and consume the same response shape. CapabilitiesCard projects data?.users ?? []; the shared cache stays consistent regardless of mount order. Also rename the route folder from "colloborators" to "collaborators" (spelling fix). Used git mv so history follows. Fetch URLs, console error labels, and the route doc comment updated. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../{colloborators => collaborators}/route.ts | 8 ++-- .../(portfolio)/settings/CapabilitiesCard.tsx | 27 ++++-------- .../settings/UsersPermissionsCard.tsx | 44 +++++-------------- .../settings/collaboratorsClient.ts | 35 +++++++++++++++ 4 files changed, 58 insertions(+), 56 deletions(-) rename src/app/api/portfolio/[portfolioId]/{colloborators => collaborators}/route.ts (97%) create mode 100644 src/app/portfolio/[slug]/(portfolio)/settings/collaboratorsClient.ts diff --git a/src/app/api/portfolio/[portfolioId]/colloborators/route.ts b/src/app/api/portfolio/[portfolioId]/collaborators/route.ts similarity index 97% rename from src/app/api/portfolio/[portfolioId]/colloborators/route.ts rename to src/app/api/portfolio/[portfolioId]/collaborators/route.ts index 3fc72965..53bdb444 100644 --- a/src/app/api/portfolio/[portfolioId]/colloborators/route.ts +++ b/src/app/api/portfolio/[portfolioId]/collaborators/route.ts @@ -30,7 +30,7 @@ import { resolvePortfolioPrivilege, } from "@/app/lib/resolvePortfolioPrivilege"; -// Get colloborators (users) that have access to the portfolio, plus the +// Get collaborators (users) that have access to the portfolio, plus the // effective privilege of the requesting user (so the UI knows which actions // to expose). export async function GET( @@ -140,7 +140,7 @@ export async function DELETE( { status: 200 }, ); } catch (err) { - console.error("DELETE /colloborators error:", err); + console.error("DELETE /collaborators error:", err); return NextResponse.json( { error: "Failed to remove user" }, { status: 500 }, @@ -184,7 +184,7 @@ export async function PUT( { status: 200 } ); } catch (err) { - console.error("PUT /colloborators error:", err); + console.error("PUT /collaborators error:", err); return NextResponse.json( { error: "Failed to update role" }, { status: 500 } @@ -372,7 +372,7 @@ export async function POST( { status: 200 }, ); } catch (err) { - console.error("POST /colloborators error:", err); + console.error("POST /collaborators error:", err); return NextResponse.json( { error: "Failed to invite user" }, { status: 500 } diff --git a/src/app/portfolio/[slug]/(portfolio)/settings/CapabilitiesCard.tsx b/src/app/portfolio/[slug]/(portfolio)/settings/CapabilitiesCard.tsx index 533c2e3c..b9ac1ef5 100644 --- a/src/app/portfolio/[slug]/(portfolio)/settings/CapabilitiesCard.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/settings/CapabilitiesCard.tsx @@ -11,6 +11,10 @@ import { import { Button } from "@/app/shadcn_components/ui/button"; import { Badge } from "@/app/shadcn_components/ui/badge"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { + COLLABORATORS_QUERY_KEY, + fetchCollaborators, +} from "./collaboratorsClient"; type Capability = "approver" | "contractor"; @@ -30,20 +34,6 @@ async function getCapabilities(portfolioId: string): Promise return res.json(); } -async function getCollaborators( - portfolioId: string, -): Promise<{ userId: string; name: string | null; email: string }[]> { - const res = await fetch(`/api/portfolio/${portfolioId}/colloborators`); - if (!res.ok) throw new Error("Failed to fetch collaborators"); - const json = await res.json(); - const users = Array.isArray(json) ? json : json.users ?? []; - return users.map((u: any) => ({ - userId: String(u.userId), - name: u.name ?? null, - email: u.email ?? "", - })); -} - async function assignCapability( portfolioId: string, userId: string, @@ -81,19 +71,20 @@ export function CapabilitiesCard({ portfolioId }: { portfolioId: string }) { refetchOnWindowFocus: false, }); - const { data: collaborators = [], isLoading: loadingCollabs } = useQuery({ - queryKey: ["portfolioUsers", portfolioId], - queryFn: () => getCollaborators(portfolioId), + const { data: collaboratorsResponse, isLoading: loadingCollabs } = useQuery({ + queryKey: COLLABORATORS_QUERY_KEY(portfolioId), + queryFn: () => fetchCollaborators(portfolioId), enabled: !!portfolioId, refetchOnWindowFocus: false, }); + const collaborators = collaboratorsResponse?.users ?? []; const isLoading = loadingCaps || loadingCollabs; // Build a map: userId -> { capabilities: [] } const capMap: CapabilityMap = {}; for (const c of collaborators) { - capMap[c.userId] = { name: c.name, email: c.email, capabilities: [] }; + capMap[c.userId] = { name: c.name ?? null, email: c.email, capabilities: [] }; } for (const e of entries) { if (capMap[e.userId]) { diff --git a/src/app/portfolio/[slug]/(portfolio)/settings/UsersPermissionsCard.tsx b/src/app/portfolio/[slug]/(portfolio)/settings/UsersPermissionsCard.tsx index 6e6d58d9..a5f61cf3 100644 --- a/src/app/portfolio/[slug]/(portfolio)/settings/UsersPermissionsCard.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/settings/UsersPermissionsCard.tsx @@ -22,6 +22,11 @@ import { canAdminister, type PortfolioPrivilege, } from "@/app/lib/portfolioAdmin"; +import { + COLLABORATORS_QUERY_KEY, + fetchCollaborators, + type CollaboratorsResponse, +} from "./collaboratorsClient"; type PendingInvitation = { invitationId: string; @@ -30,35 +35,6 @@ type PendingInvitation = { createdAt: string; }; -type CollaboratorsResponse = { - users: Collaborator[]; - currentUser?: { privilege: PortfolioPrivilege }; -}; - -async function getPortfolioUsers( - portfolioId: string, -): Promise { - const res = await fetch(`/api/portfolio/${portfolioId}/colloborators`, { - method: "GET", - headers: { "Content-Type": "application/json" }, - }); - if (!res.ok) throw new Error("Failed to fetch users"); - const json = await res.json(); - const rawUsers = Array.isArray(json) ? json : json.users; - const users: Collaborator[] = Array.isArray(rawUsers) - ? rawUsers.map((u: any) => ({ - portfolioUserId: String(u.portfolioUserId), - userId: String(u.userId), - name: u.name ?? null, - email: u.email ?? "", - role: u.role, - })) - : []; - const privilege: PortfolioPrivilege | undefined = - json?.currentUser?.privilege; - return privilege ? { users, currentUser: { privilege } } : { users }; -} - async function getPortfolioInvitations( portfolioId: string, ): Promise { @@ -84,7 +60,7 @@ async function updatePortfolioUserRole( portfolioUserId: string, role: Role, ): Promise { - const res = await fetch(`/api/portfolio/${portfolioId}/colloborators`, { + const res = await fetch(`/api/portfolio/${portfolioId}/collaborators`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ portfolioUserId, role }), @@ -101,7 +77,7 @@ async function invitePortfolioUser( role: Role, name: string, ): Promise { - const res = await fetch(`/api/portfolio/${portfolioId}/colloborators`, { + const res = await fetch(`/api/portfolio/${portfolioId}/collaborators`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email, role, name }), @@ -116,7 +92,7 @@ async function removePortfolioUser( portfolioId: string, portfolioUserId: string, ): Promise { - const res = await fetch(`/api/portfolio/${portfolioId}/colloborators`, { + const res = await fetch(`/api/portfolio/${portfolioId}/collaborators`, { method: "DELETE", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ portfolioUserId }), @@ -149,7 +125,7 @@ export function UsersPermissionsCard({ portfolioId }: { portfolioId: string }) { const queryClient = useQueryClient(); - const usersKey = ["portfolioUsers", portfolioId]; + const usersKey = COLLABORATORS_QUERY_KEY(portfolioId); const invitationsKey = ["portfolioInvitations", portfolioId]; const { @@ -159,7 +135,7 @@ export function UsersPermissionsCard({ portfolioId }: { portfolioId: string }) { refetch, } = useQuery({ queryKey: usersKey, - queryFn: () => getPortfolioUsers(portfolioId), + queryFn: () => fetchCollaborators(portfolioId), enabled: !!portfolioId, refetchOnWindowFocus: false, }); diff --git a/src/app/portfolio/[slug]/(portfolio)/settings/collaboratorsClient.ts b/src/app/portfolio/[slug]/(portfolio)/settings/collaboratorsClient.ts new file mode 100644 index 00000000..85d5a171 --- /dev/null +++ b/src/app/portfolio/[slug]/(portfolio)/settings/collaboratorsClient.ts @@ -0,0 +1,35 @@ +import type { Collaborator } from "./roles"; +import type { PortfolioPrivilege } from "@/app/lib/portfolioAdmin"; + +export type CollaboratorsResponse = { + users: Collaborator[]; + currentUser?: { privilege: PortfolioPrivilege }; +}; + +// Shared fetcher used by every component that queries the portfolio user +// list. Keeping a single function (and a single response shape) means +// useQuery deduping behaves correctly when the user-access page mounts +// both UsersPermissionsCard and CapabilitiesCard against the same key. +export async function fetchCollaborators( + portfolioId: string, +): Promise { + const res = await fetch(`/api/portfolio/${portfolioId}/collaborators`); + if (!res.ok) throw new Error("Failed to fetch collaborators"); + const json = await res.json(); + const rawUsers = Array.isArray(json) ? json : (json?.users ?? []); + const users: Collaborator[] = Array.isArray(rawUsers) + ? rawUsers.map((u: any) => ({ + portfolioUserId: String(u.portfolioUserId), + userId: String(u.userId), + name: u.name ?? null, + email: u.email ?? "", + role: u.role, + })) + : []; + const privilege: PortfolioPrivilege | undefined = + json?.currentUser?.privilege; + return privilege ? { users, currentUser: { privilege } } : { users }; +} + +export const COLLABORATORS_QUERY_KEY = (portfolioId: string) => + ["portfolioUsers", portfolioId] as const;