307b6c94de
## What changed This refactor fixes AI chat error surfacing by aligning both the backend and frontend with the existing GraphQL error architecture instead of adding AI-local error translation. On the backend: - add a dedicated GraphQL billing exception path - register billing GraphQL handling globally for GraphQL requests - reuse the existing AI GraphQL interceptor path for agent/chat exceptions - keep billing status classification shared between REST and GraphQL - remove the earlier attempt to preserve `CustomException` metadata in the global GraphQL fallback On the frontend: - keep the original Apollo GraphQL error object in AI chat state - reuse shared Apollo/GraphQL helpers for user-facing messages and error-type checks - delete AI-specific error extraction helpers that duplicated generic GraphQL parsing - replace a few direct `extensions.subCode` call sites with a shared predicate ## Why it changed The original bug was that `BillingException` and AI exceptions thrown from chat were not being translated into GraphQL errors with the expected `extensions.subCode` and `extensions.userFriendlyMessage`, so the AI chat UI had nothing structured to inspect. An intermediate fix worked mechanically but pushed `CustomException` handling into the global GraphQL fallback, which blurred the intended layering. This PR moves the behavior back to explicit GraphQL edges. ## Root cause `AgentChatResolver` could throw `BillingException` and `AgentException`, but: - billing had a REST exception filter and no shared GraphQL equivalent - AI chat was not consistently using the same GraphQL exception translation path as the sibling AI resolver - the frontend chat UI had drifted into AI-specific error parsing instead of consuming the same structured Apollo errors as the rest of the app ## Impact - `BILLING_CREDITS_EXHAUSTED` is now preserved through GraphQL and can render the existing credits-exhausted UI in chat - `API_KEY_NOT_CONFIGURED` is preserved through the AI GraphQL path - AI chat now follows the same general GraphQL error consumption pattern as the rest of the frontend - billing GraphQL handling is less dependent on individual resolver authors remembering to add a filter ## Validation - `yarn jest --config packages/twenty-server/jest.config.mjs packages/twenty-server/src/engine/core-modules/billing/utils/__tests__/billing-graphql-api-exception-handler.util.spec.ts packages/twenty-server/src/engine/metadata-modules/ai/ai-agent/utils/__tests__/agent-graphql-api-exception-handler.util.spec.ts` - `yarn jest --config packages/twenty-front/jest.config.mjs packages/twenty-front/src/utils/__tests__/is-graphql-error-of-type.util.test.ts` - `npx oxlint --type-aware ...` on touched backend/frontend files - `npx prettier --check ...` on touched backend/frontend files ## Follow-up ideas - consolidate frontend GraphQL error helpers further so more existing direct `extensions.subCode` checks move to shared utilities - consider whether common GraphQL exception filter registration should live in a more explicit GraphQL-specific module instead of `CoreEngineModule` - add an end-to-end test for a real `sendChatMessage` GraphQL failure path in AI chat --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
134 lines
4.6 KiB
TypeScript
134 lines
4.6 KiB
TypeScript
import { useAuth } from '@/auth/hooks/useAuth';
|
|
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
|
|
import { CombinedGraphQLErrors } from '@apollo/client/errors';
|
|
import { AppPath } from 'twenty-shared/types';
|
|
|
|
import { verifyEmailRedirectPathState } from '@/app/states/verifyEmailRedirectPathState';
|
|
import { useVerifyLogin } from '@/auth/hooks/useVerifyLogin';
|
|
import { clientConfigApiStatusState } from '@/client-config/states/clientConfigApiStatusState';
|
|
import { useIsCurrentLocationOnAWorkspace } from '@/domain-manager/hooks/useIsCurrentLocationOnAWorkspace';
|
|
import { useRedirectToWorkspaceDomain } from '@/domain-manager/hooks/useRedirectToWorkspaceDomain';
|
|
import { ModalContent } from 'twenty-ui/layout';
|
|
import { useLingui } from '@lingui/react/macro';
|
|
import { useEffect, useState } from 'react';
|
|
import { useSearchParams } from 'react-router-dom';
|
|
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
|
import { isDefined } from 'twenty-shared/utils';
|
|
import { useNavigateApp } from '~/hooks/useNavigateApp';
|
|
import { getWorkspaceUrl } from '~/utils/getWorkspaceUrl';
|
|
import { isGraphqlErrorOfType } from '~/utils/is-graphql-error-of-type.util';
|
|
import { EmailVerificationSent } from '@/auth/sign-in-up/components/EmailVerificationSent';
|
|
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
|
|
|
|
export const VerifyEmailEffect = () => {
|
|
const {
|
|
verifyEmailAndGetLoginToken,
|
|
verifyEmailAndGetWorkspaceAgnosticToken,
|
|
} = useAuth();
|
|
|
|
const { enqueueErrorSnackBar, enqueueSuccessSnackBar } = useSnackBar();
|
|
|
|
const [searchParams] = useSearchParams();
|
|
const [isError, setIsError] = useState(false);
|
|
|
|
const setVerifyEmailRedirectPath = useSetAtomState(
|
|
verifyEmailRedirectPathState,
|
|
);
|
|
|
|
const email = searchParams.get('email');
|
|
const emailVerificationToken = searchParams.get('emailVerificationToken');
|
|
const verifyEmailRedirectPath = searchParams.get('nextPath');
|
|
|
|
const navigate = useNavigateApp();
|
|
const { redirectToWorkspaceDomain } = useRedirectToWorkspaceDomain();
|
|
const { verifyLoginToken } = useVerifyLogin();
|
|
const { isOnAWorkspace } = useIsCurrentLocationOnAWorkspace();
|
|
const clientConfigApiStatus = useAtomStateValue(clientConfigApiStatusState);
|
|
|
|
const { t } = useLingui();
|
|
useEffect(() => {
|
|
const verifyEmailToken = async () => {
|
|
if (!email || !emailVerificationToken) {
|
|
enqueueErrorSnackBar({
|
|
message: t`Invalid email verification link.`,
|
|
options: {
|
|
dedupeKey: 'email-verification-link-dedupe-key',
|
|
},
|
|
});
|
|
return navigate(AppPath.SignInUp);
|
|
}
|
|
|
|
const successSnackbarParams = {
|
|
message: t`Email verified.`,
|
|
options: {
|
|
dedupeKey: 'email-verification-dedupe-key',
|
|
},
|
|
};
|
|
|
|
try {
|
|
if (!isOnAWorkspace) {
|
|
await verifyEmailAndGetWorkspaceAgnosticToken(
|
|
emailVerificationToken,
|
|
email,
|
|
);
|
|
|
|
return enqueueSuccessSnackBar(successSnackbarParams);
|
|
}
|
|
|
|
const { loginToken, workspaceUrls } = await verifyEmailAndGetLoginToken(
|
|
emailVerificationToken,
|
|
email,
|
|
);
|
|
|
|
enqueueSuccessSnackBar(successSnackbarParams);
|
|
|
|
const workspaceUrl = getWorkspaceUrl(workspaceUrls);
|
|
if (workspaceUrl.slice(0, -1) !== window.location.origin) {
|
|
return await redirectToWorkspaceDomain(workspaceUrl, AppPath.Verify, {
|
|
loginToken: loginToken.token,
|
|
});
|
|
}
|
|
|
|
if (isDefined(verifyEmailRedirectPath)) {
|
|
setVerifyEmailRedirectPath(verifyEmailRedirectPath);
|
|
}
|
|
|
|
await verifyLoginToken(loginToken.token);
|
|
} catch (error) {
|
|
enqueueErrorSnackBar({
|
|
...(CombinedGraphQLErrors.is(error)
|
|
? { apolloError: error }
|
|
: { message: t`Email verification failed` }),
|
|
options: {
|
|
dedupeKey: 'email-verification-error-dedupe-key',
|
|
},
|
|
});
|
|
if (isGraphqlErrorOfType(error, 'EMAIL_ALREADY_VERIFIED')) {
|
|
navigate(AppPath.SignInUp);
|
|
}
|
|
|
|
setIsError(true);
|
|
}
|
|
};
|
|
|
|
if (!clientConfigApiStatus.isLoadedOnce) {
|
|
return;
|
|
}
|
|
|
|
verifyEmailToken();
|
|
|
|
// Verify email only needs to run once at mount
|
|
// oxlint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [clientConfigApiStatus.isLoadedOnce]);
|
|
|
|
if (isError) {
|
|
return (
|
|
<ModalContent isVerticallyCentered isHorizontallyCentered>
|
|
<EmailVerificationSent email={email} isError={true} />
|
|
</ModalContent>
|
|
);
|
|
}
|
|
|
|
return <></>;
|
|
};
|