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,
  };
}