use-counterpart-profile.ts5.1 KBView on GitHub /**
* The counterpart profile behind an open LinkedIn chat, and the one governed refresh we are
* willing to spend on it.
*
* The read is FREE — cache only, never a provider call — so opening a chat costs nothing and the
* panel can name the person immediately. The refresh is the paid half: at most one `profile_view`
* out of the seat's 50/day. This hook is where the "at most one" lives on the client, and there
* are two separate guards for it:
*
* 1. `autoRefreshed` — one auto-refresh per chat id per SESSION. A module-level Set rather than
* component state, because the panel unmounts every time you switch chats and remounts when
* you come back; component state would let a browse-away-and-back loop spend a view per visit.
* 2. `skipped === 'budget'` — the server already answered "not while the day's budget is this
* low". Re-asking cannot change that answer FOR AS LONG AS IT HOLDS, so a budget-blocked
* read never auto-fires; the card offers a "Load profile" button instead, and only the rep
* pressing it passes `force`. The answer expires with the rate window it was measured in —
* the server stops reporting it once the day's cap has rolled over, which is what lets a
* chat reopened tomorrow refresh on its own again.
*
* The SEAT is not one of this hook's concerns. A refresh spends one of a seat's 50 daily views
* and reaches LinkedIn as that seat's identity, so the server takes the seat off the chat rather
* than from here — this hook used to pick the org's first connected account, which in an org
* with several seats spent the wrong rep's budget under the wrong rep's name.
*
* See apps/mail/docs/linkedin-counterpart-profile.md §3.2 step 14.
*/
import { useCallback, useEffect } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import type { inferRouterOutputs } from '@trpc/server';
import type { AppRouter } from '@zero/server/trpc';
import { useTRPC } from '@/providers/query-provider';
type RouterOutputs = inferRouterOutputs<AppRouter>;
/** Exactly what the route returns — derived from the router, never a hand-kept mirror of it. */
export type CounterpartProfile = NonNullable<
RouterOutputs['outbound']['linkedin']['messaging']['counterpartProfile']
>;
export type CounterpartWorkEntry = CounterpartProfile['history'][number];
export type CounterpartPost = CounterpartProfile['posts'][number];
/**
* Chat ids whose auto-refresh has already been fired in this browser session. Deliberately
* module scope: see guard (1) above.
*/
const autoRefreshed = new Set<string>();
export interface UseCounterpartProfile {
profile: CounterpartProfile | null;
/** A refresh is in flight — the paid half, whether auto-fired or pressed. */
isPending: boolean;
/** The rep asking for the profile anyway, past the budget reserve. */
loadProfile: () => void;
/** The first free read has not answered yet — the only state that deserves a skeleton. */
isLoading: boolean;
}
export function useCounterpartProfile(chatId: string | null): UseCounterpartProfile {
const trpc = useTRPC();
const queryClient = useQueryClient();
const profileOptions = trpc.outbound.linkedin.messaging.counterpartProfile.queryOptions(
{ chatId: chatId ?? '' },
{ enabled: chatId !== null },
);
const profileQuery = useQuery(profileOptions);
const profileKey=[redacted];
const refresh = useMutation(
trpc.outbound.linkedin.messaging.refreshCounterpartProfile.mutationOptions({
/**
* Reconciled into the cache directly rather than invalidated. An invalidate would refetch,
* and between the invalidate and the answer the card would render the cold payload it
* started with — the name flickering back to a person with no role, one beat after the
* role arrived. The mutation already returns the fresh `CounterpartProfile`; the read has
* nothing to add.
*/
onSuccess: (data: CounterpartProfile | null) => {
if (data) queryClient.setQueryData(profileKey, data);
},
}),
);
const { mutate } = refresh;
const profile = profileQuery.data ?? null;
const stale = profile?.stale === true;
const budgetBlocked = profile?.skipped === 'budget';
useEffect(() => {
if (!chatId) return;
if (!stale || budgetBlocked) return;
if (autoRefreshed.has(chatId)) return;
// Marked BEFORE the call, not in its callback: two renders can pass this line before the
// first request resolves, and the guard has to hold against that, not just against retries.
autoRefreshed.add(chatId);
mutate({ chatId });
}, [chatId, stale, budgetBlocked, mutate]);
const loadProfile = useCallback(() => {
if (!chatId) return;
// `force` is the rep's authorisation to spend a view below the reserve — the same deliberate
// framing as "Find company" in ChatDetail.tsx. Nothing else in the client sends it.
mutate({ chatId, force: true });
}, [chatId, mutate]);
return {
profile,
isPending: refresh.isPending,
loadProfile,
isLoading: chatId !== null && profileQuery.isLoading,
};
}