Files
PaperClipAI/ui/src/api/companies-query.ts
T
377bc87df1 feat(connectors): show and select personal AI account defaults (#19845)
Show a crown on each saved personal AI provider default. Add a provider-specific menu action to other eligible accounts. Share saved-state queries, block duplicate writes, and keep the saved crown until a write succeeds.

Keep account details available for accessible grants. Show session lookup errors with Retry. Resolve list permissions once per request and index account lookups. Add UI and API regression coverage and account documentation.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
Co-Authored-By: Paperclip <Paperclip-Paperclip@users.noreply.github.com>
2026-11-07 10:26:08 -06:00

166 lines
7.2 KiB
TypeScript

import { useQuery, type QueryClient } from "@tanstack/react-query";
import type { BoardBootstrap, Company } from "@paperclipai/shared";
import { authApi } from "./auth";
import { companiesApi } from "./companies";
import { ApiError } from "./client";
import { queryKeys } from "../lib/queryKeys";
import { useBootstrapQuery } from "./bootstrap-query";
import { hasUnverifiedSession } from "../lib/cached-session";
export type CompanyListResult = { companies: Company[]; unauthorized: boolean };
// Single source of truth for the company-list query. Every consumer reads the
// same cache entry, so they must agree on the shape — returning a bare
// `Company[]` from one and this wrapped object from another silently corrupts
// the entry and crashes whichever reads the other's shape.
//
// The entry is keyed by account. Callers therefore cannot ask for "the company
// list" without saying whose, which is the point: the previous design let a
// list fetched for one account answer a question about another, and every
// consumer had to defend against that individually.
export function companyListQueryOptions(userId: string | null) {
return {
queryKey: queryKeys.companies.list(userId),
queryFn: async (): Promise<CompanyListResult> => {
// Request coalescing keys on the path alone, so it would happily answer a
// fetch for this account with a `/companies` request issued under the
// previous one — putting the wrong account's list in an account-keyed
// entry. Coalescing has nothing to offer here anyway: React Query already
// dedupes concurrent fetches within a key, and *across* keys the accounts
// differ by construction, which is exactly when sharing is wrong.
companiesApi.detachInflightList();
try {
return { companies: await companiesApi.list(), unauthorized: false };
} catch (err) {
if (err instanceof ApiError && (err.status === 401 || err.status === 403)) {
return { companies: [], unauthorized: true };
}
throw err;
}
},
} as const;
}
/** The administration directory has its own account-scoped cache and request. */
export function companyDirectoryQueryOptions(userId: string | null) {
return {
queryKey: queryKeys.companies.directory(userId),
queryFn: async () => {
companiesApi.detachInflightDirectory();
return companiesApi.directory();
},
} as const;
}
const sessionQueryOptions = {
queryKey: queryKeys.auth.session,
queryFn: () => authApi.getSession(),
} as const;
/** Observe the shared session read when a surface needs error and retry state. */
export function useAccountSessionQuery() {
return useQuery(sessionQueryOptions);
}
/**
* The signed-in account, as the cache currently understands it.
*
* `settled` means the session query *answered*, not merely that it stopped
* being pending. The distinction is the whole point of this helper. `null` is a
* real answer — `authApi.getSession` returns it for a 401, meaning "signed out"
* — but a session request that *failed* answers nothing, and it throws rather
* than returning null. Treating that failure as `null` would key an account's
* list to `anonymous` while the request still carried their cookie, writing a
* credentialed response into the entry every signed-out reader trusts. Which is
* the original bug, rebuilt for anonymous.
*
* So: settled on success only. A failed session lookup leaves the list unfetched
* until the query recovers, which it does on the next refetch.
*/
export function useAccountIdentity(): {
userId: string | null;
settled: boolean;
failed: boolean;
bootstrap: BoardBootstrap | undefined;
updatedAt: number;
} {
const { data: session, isSuccess, isError, dataUpdatedAt } = useAccountSessionQuery();
const userId = session?.user.id ?? null;
return {
userId, settled: isSuccess, failed: isError, updatedAt: dataUpdatedAt,
bootstrap: isSuccess && session?.boardBootstrap?.userId === userId ? session.boardBootstrap : undefined,
};
}
/**
* Observe the company list for the account signed in now.
*
* Pass `enabled: false` to hold for a caller's own reasons; the session gate is
* applied on top of it either way.
*/
export function useCompanyListQuery(
options: { enabled?: boolean; staleTime?: number; retry?: number | boolean } = {},
) {
const { userId, settled, bootstrap, updatedAt } = useAccountIdentity();
const query = useBootstrapQuery<CompanyListResult>({
...companyListQueryOptions(userId),
...options,
enabled: settled && (options.enabled ?? true),
}, bootstrap ? { identity: bootstrap, data: { companies: bootstrap.companies, unauthorized: false }, updatedAt } : undefined);
// While the account is unknown this query is disabled, and a disabled query
// reports `isLoading: false` with `data: undefined` — which consumers default
// to an empty list and read as "asked, and owns nothing". That is the
// destructive reading: `shouldClearStoredCompanySelection` would throw away
// the customer's stored company on every cold boot, before the session had
// even landed.
//
// Waiting for the account is part of getting the list, so it is reported as
// part of getting the list. Consumers gate on these two, and both must mean
// "no answer yet" for the whole window in which there is no answer.
const waitingForAccount = !settled && (options.enabled ?? true);
return {
...query,
isLoading: query.isLoading || waitingForAccount,
isFetching: query.isFetching || waitingForAccount,
};
}
/**
* Resolve the account identity for an imperative path, fetching the session if
* the cache has no answer yet.
*
* Not `getQueryData` with a `?? null` fallback: an empty or errored session
* entry would read as "signed out" and key an authenticated response to
* `anonymous`. There is no safe default here — the identity is either known or
* must be obtained, and if it cannot be obtained the caller must fail rather
* than guess.
*/
export async function resolveAccountUserId(queryClient: QueryClient): Promise<string | null> {
const state = queryClient.getQueryState<Awaited<ReturnType<typeof authApi.getSession>>>(
queryKeys.auth.session,
);
// `isInvalidated` matters as much as `status` here. Invalidation is how the
// app says "this is the previous account's answer" — InviteLanding invalidates
// the session immediately after a sign-in and resolves an identity in the next
// breath. The entry is still `success`, still holding the *old* user, until a
// refetch lands; trusting it would key the new account's list to the old
// account's id, which is the misattribution this whole file exists to prevent.
if (state?.status === "success" && !state.isInvalidated && !hasUnverifiedSession(queryClient)) {
return state.data?.user.id ?? null;
}
const session = await queryClient.fetchQuery({ ...sessionQueryOptions, staleTime: 0 });
return session?.user.id ?? null;
}
/** Fetch the company list for the account signed in now, ignoring what is cached. */
export async function fetchCompanyListForCurrentAccount(
queryClient: QueryClient,
): Promise<CompanyListResult> {
const userId = await resolveAccountUserId(queryClient);
return queryClient.fetchQuery({
...companyListQueryOptions(userId),
staleTime: 0,
});
}