paperclip/ui/src/hooks/useCompanyMission.ts

73 lines
3.2 KiB
TypeScript

import { useQuery } from "@tanstack/react-query";
import { goalsApi } from "../api/goals";
import { queryKeys } from "../lib/queryKeys";
import { selectDefaultCompanyGoalId } from "../lib/onboarding-launch";
import {
selectExistingCompanyMission,
type ExistingCompanyMission,
} from "../lib/onboarding-mission";
/**
* Whether a company already has its mission, for deciding which onboarding
* step it belongs on.
*
* A company created by Paperclip Cloud does have one: Cloud collects the
* mission at signup and the tenant writes it as a company-level goal. Opening
* such a company on the mission step asks the customer something they answered
* minutes earlier on another origin.
*
* `settled` says whether the answer can be acted on. Callers wait for it
* before opening the wizard, because the wizard applies a step once, when it
* opens, and does not revise it afterwards — see the sync effect in
* `OnboardingWizard`. A step decided before the lookup finishes would be the
* step the customer is left on.
*
* Settled, not answered, on purpose. A gate that waits for the data itself
* fails closed: a goals request that exhausts its retries leaves the value
* undefined forever, and onboarding would then never open at all. `hasMission`
* stays `undefined` after a failure, which {@link onboardingStepForCompany}
* reads as "no mission" — the customer is asked for it again, and the flow
* continues. Asking a question twice is recoverable; never opening onboarding
* is not. This is the same fail-open rule the wake and provisioning readiness
* gates follow: a check that guards a convenience must never be able to block
* the thing it guards.
*
* `mission` carries the same goal back in the shape the wizard's mission
* textarea holds it. A company entered on the agent step never runs steps 1
* and 2, so that field is otherwise empty — and it is what seeds the lead
* agent's instructions, so an empty one costs the customer the mission they
* gave at signup.
*
* `fetching` is exposed separately from `settled` for the same reason the
* draft ownership gate distinguishes them: retained goals from a previous read
* are the right company's but not necessarily its current mission, so a
* consumer that must not act on a stale mission waits on this rather than on
* `settled`.
*
* The goal list is read under the query key the launch path already uses, so
* this shares that cache entry rather than adding a request.
*/
export function useCompanyMission(companyId: string | null | undefined): {
hasMission: boolean | undefined;
settled: boolean;
mission: ExistingCompanyMission;
fetching: boolean;
} {
const { data: goals, isPending, isFetching } = useQuery({
queryKey: queryKeys.goals.list(companyId ?? ""),
queryFn: () => goalsApi.list(companyId!),
enabled: Boolean(companyId),
});
return {
hasMission: goals ? selectDefaultCompanyGoalId(goals) !== null : undefined,
// A disabled query stays pending forever, so no company means nothing to
// wait for rather than an answer that never comes.
settled: !companyId || !isPending,
mission: goals
? selectExistingCompanyMission(goals)
: { goalId: null, goalInput: "" },
fetching: Boolean(companyId) && isFetching,
};
}