diff --git a/src/api/invite-validation/useAcceptInviteMutation.ts b/src/api/invite-validation/useAcceptInviteMutation.ts new file mode 100644 index 00000000..ced12a73 --- /dev/null +++ b/src/api/invite-validation/useAcceptInviteMutation.ts @@ -0,0 +1,57 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { supabase } from "@/integrations/supabase/client"; +import { groupsKeys } from "@/api/groups/types"; +import type { InviteUsageResult } from "@/types/invites"; + +interface AcceptInviteParams { + token: string; + userId: string; +} + +export interface AcceptInviteResult extends InviteUsageResult { + alreadyMember: boolean; +} + +async function acceptInvite({ + token, + userId, +}: AcceptInviteParams): Promise { + const { data, error } = await supabase.rpc("use_invite_token", { + token, + user_id: userId, + }); + + if (error) { + console.error("Error using invite:", error); + throw new Error("Failed to join group"); + } + + const result = data?.[0]; + if (!result) { + throw new Error("Failed to join group"); + } + + if (result.success) { + return { ...result, alreadyMember: false }; + } + + // Already-in-group is the only failure where the RPC returns a group_id + if (result.group_id) { + return { ...result, alreadyMember: true }; + } + + throw new Error(result.message); +} + +export function useAcceptInviteMutation() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: acceptInvite, + onSuccess: (result) => { + if (!result.alreadyMember) { + queryClient.invalidateQueries({ queryKey: groupsKeys.all }); + } + }, + }); +} diff --git a/src/api/invite-validation/useInviteValidationQuery.ts b/src/api/invite-validation/useInviteValidationQuery.ts index a60522b8..847e6b41 100644 --- a/src/api/invite-validation/useInviteValidationQuery.ts +++ b/src/api/invite-validation/useInviteValidationQuery.ts @@ -1,6 +1,5 @@ -import { queryOptions, useQuery, useMutation } from "@tanstack/react-query"; +import { queryOptions, useQuery } from "@tanstack/react-query"; import { supabase } from "@/integrations/supabase/client"; -import { useToast } from "@/components/ui/use-toast"; import type { InviteValidation } from "@/types/invites"; import { inviteValidationKeys } from "./types"; @@ -39,54 +38,3 @@ export function useInviteValidationQuery(token: string | null) { enabled: !!token, }); } - -export function useInviteMutation() { - const { toast } = useToast(); - - return useMutation({ - mutationFn: async ({ - token, - userId, - }: { - token: string; - userId: string; - }) => { - const { data, error } = await supabase.rpc("use_invite_token", { - token, - user_id: userId, - }); - - if (error) { - console.error("Error using invite:", error); - throw new Error("Failed to join group"); - } - - if (data && data.length > 0) { - const result = data[0]; - if (result.success) { - return result; - } else { - throw new Error(result.message); - } - } - - throw new Error("Failed to join group"); - }, - onSuccess: () => { - // We'll need to get the group name from somewhere - let's make this flexible - toast({ - title: "Success", - description: "Successfully joined the group!", - }); - return true; - }, - onError: (error) => { - toast({ - title: "Error", - description: error.message, - variant: "destructive", - }); - return false; - }, - }); -} diff --git a/src/components/invite/InviteLandingPage.tsx b/src/components/invite/InviteLandingPage.tsx index c6fdddee..05a8994b 100644 --- a/src/components/invite/InviteLandingPage.tsx +++ b/src/components/invite/InviteLandingPage.tsx @@ -13,12 +13,12 @@ import type { InviteValidation } from "@/types/invites"; interface InviteLandingPageProps { inviteValidation: InviteValidation; - onSignupSuccess: () => void; + inviteToken: string; } export function InviteLandingPage({ inviteValidation, - onSignupSuccess, + inviteToken, }: InviteLandingPageProps) { const [showAuthDialog, setShowAuthDialog] = useState(false); @@ -73,8 +73,8 @@ export function InviteLandingPage({ setShowAuthDialog(false)} + inviteToken={inviteToken} groupName={inviteValidation.group_name} /> diff --git a/src/components/invite/useInviteFlow.test.tsx b/src/components/invite/useInviteFlow.test.tsx new file mode 100644 index 00000000..ef292c1b --- /dev/null +++ b/src/components/invite/useInviteFlow.test.tsx @@ -0,0 +1,240 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { renderHook, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { ReactNode } from "react"; +import type { User } from "@supabase/supabase-js"; +import { useInviteFlow } from "./useInviteFlow"; +import { supabase } from "@/integrations/supabase/client"; +import type { InviteValidation } from "@/types/invites"; + +const { navigateMock, toastMock } = vi.hoisted(() => ({ + navigateMock: vi.fn(), + toastMock: vi.fn(), +})); + +vi.mock("@/integrations/supabase/client", () => ({ + supabase: { rpc: vi.fn() }, +})); + +vi.mock("@tanstack/react-router", () => ({ + useNavigate: () => navigateMock, +})); + +vi.mock("@/components/ui/use-toast", () => ({ + useToast: () => ({ toast: toastMock }), +})); + +const rpcMock = vi.mocked(supabase.rpc); + +const user = { id: "user-1" } as User; + +const validInvite: InviteValidation = { + invite_id: "invite-row-id", + group_id: "group-1", + group_name: "Festival Crew", + is_valid: true, + reason: "valid", +}; + +const joinedResult = { + success: true, + message: "Successfully joined group", + group_id: "group-1", +}; + +function mockRpc( + validation: InviteValidation | null, + useResult: { success: boolean; message: string; group_id: string | null }, +) { + rpcMock.mockImplementation((async (fn: string) => { + if (fn === "validate_invite_token") { + return { data: validation ? [validation] : [], error: null }; + } + return { data: [useResult], error: null }; + }) as never); +} + +function useInviteTokenCalls() { + return rpcMock.mock.calls.filter(([fn]) => fn === "use_invite_token"); +} + +function renderFlow(initialProps: { + token: string | undefined; + user: User | null; +}) { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries"); + + const view = renderHook( + (props: { token: string | undefined; user: User | null }) => + useInviteFlow(props.token, props.user), + { + initialProps, + wrapper: function Wrapper({ children }: { children: ReactNode }) { + return ( + + {children} + + ); + }, + }, + ); + + return { ...view, invalidateSpy }; +} + +describe("useInviteFlow", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("validates the token then accepts the invite when a user is present", async () => { + mockRpc(validInvite, joinedResult); + + const { result, invalidateSpy } = renderFlow({ token: "token-1", user }); + + await waitFor(() => + expect(rpcMock).toHaveBeenCalledWith("validate_invite_token", { + token: "token-1", + }), + ); + await waitFor(() => + expect(rpcMock).toHaveBeenCalledWith("use_invite_token", { + token: "token-1", + user_id: "user-1", + }), + ); + + await waitFor(() => + expect(toastMock).toHaveBeenCalledWith({ + title: "Success", + description: "Welcome to Festival Crew!", + }), + ); + + expect(result.current.hasValidInvite).toBe(true); + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ["groups"] }); + expect(navigateMock).toHaveBeenCalledWith({ + to: ".", + search: expect.any(Function), + replace: true, + }); + + const searchUpdater = navigateMock.mock.calls[0][0].search; + expect(searchUpdater({ invite: "token-1", day: "friday" })).toEqual({ + invite: undefined, + day: "friday", + }); + }); + + it("waits for the user to sign in before accepting", async () => { + mockRpc(validInvite, joinedResult); + + const { result, rerender } = renderFlow({ token: "token-1", user: null }); + + await waitFor(() => expect(result.current.hasValidInvite).toBe(true)); + expect(useInviteTokenCalls()).toHaveLength(0); + + rerender({ token: "token-1", user }); + + await waitFor(() => expect(useInviteTokenCalls()).toHaveLength(1)); + }); + + it("shows the expired message and never consumes an invalid invite", async () => { + mockRpc( + { ...validInvite, is_valid: false, reason: "invite_expired" }, + joinedResult, + ); + + const { result } = renderFlow({ token: "token-1", user }); + + await waitFor(() => + expect(toastMock).toHaveBeenCalledWith({ + title: "Invalid Invite", + description: "This invite link has expired", + variant: "destructive", + }), + ); + + expect(result.current.hasValidInvite).toBe(false); + expect(useInviteTokenCalls()).toHaveLength(0); + }); + + it("surfaces an already-member reuse cleanly without an error", async () => { + mockRpc(validInvite, { + success: false, + message: "User already in group", + group_id: "group-1", + }); + + const { invalidateSpy } = renderFlow({ token: "token-1", user }); + + await waitFor(() => + expect(toastMock).toHaveBeenCalledWith({ + title: "Already a member", + description: "You're already a member of Festival Crew.", + }), + ); + + expect(toastMock).not.toHaveBeenCalledWith( + expect.objectContaining({ variant: "destructive" }), + ); + expect(invalidateSpy).not.toHaveBeenCalledWith({ queryKey: ["groups"] }); + expect(navigateMock).toHaveBeenCalled(); + }); + + it("shows an error toast and keeps the invite param when acceptance fails", async () => { + mockRpc(validInvite, { + success: false, + message: "Invalid invite token", + group_id: null, + }); + + renderFlow({ token: "token-1", user }); + + await waitFor(() => + expect(toastMock).toHaveBeenCalledWith({ + title: "Couldn't join group", + description: "Invalid invite token", + variant: "destructive", + }), + ); + + expect(navigateMock).not.toHaveBeenCalled(); + }); + + it("allows a retry after a failed attempt", async () => { + mockRpc(validInvite, { + success: false, + message: "Invalid invite token", + group_id: null, + }); + + const { rerender } = renderFlow({ token: "token-1", user }); + + await waitFor(() => expect(useInviteTokenCalls()).toHaveLength(1)); + + mockRpc(validInvite, joinedResult); + rerender({ token: "token-1", user: { ...user } }); + + await waitFor(() => expect(useInviteTokenCalls()).toHaveLength(2)); + }); + + it("only accepts once per token across re-renders", async () => { + mockRpc(validInvite, joinedResult); + + const { rerender } = renderFlow({ token: "token-1", user }); + + await waitFor(() => expect(useInviteTokenCalls()).toHaveLength(1)); + + rerender({ token: "token-1", user }); + + await Promise.resolve(); + expect(useInviteTokenCalls()).toHaveLength(1); + }); +}); diff --git a/src/components/invite/useInviteFlow.ts b/src/components/invite/useInviteFlow.ts new file mode 100644 index 00000000..767b4d03 --- /dev/null +++ b/src/components/invite/useInviteFlow.ts @@ -0,0 +1,90 @@ +import { useEffect, useRef } from "react"; +import { useNavigate } from "@tanstack/react-router"; +import type { User } from "@supabase/supabase-js"; +import { useToast } from "@/components/ui/use-toast"; +import { useInviteValidationQuery } from "@/api/invite-validation/useInviteValidationQuery"; +import { useAcceptInviteMutation } from "@/api/invite-validation/useAcceptInviteMutation"; +import type { InviteValidation } from "@/types/invites"; + +export function useInviteFlow( + inviteToken: string | undefined, + user: User | null, +) { + const { toast } = useToast(); + const navigate = useNavigate(); + + const inviteQuery = useInviteValidationQuery(inviteToken || null); + const { mutate: acceptInvite } = useAcceptInviteMutation(); + const attemptedTokenRef = useRef(null); + + useEffect(() => { + const error = getError(inviteQuery.error, inviteQuery.data); + if (error) { + toast({ + title: "Invalid Invite", + description: error, + variant: "destructive", + }); + } + }, [inviteQuery.error, inviteQuery.data, toast]); + + useEffect(() => { + const groupName = inviteQuery.data?.group_name; + if (!user || !inviteToken || inviteQuery.data?.is_valid !== true) return; + if (attemptedTokenRef.current === inviteToken) return; + attemptedTokenRef.current = inviteToken; + + acceptInvite( + { token: inviteToken, userId: user.id }, + { + onSuccess: (result) => { + toast({ + title: result.alreadyMember ? "Already a member" : "Success", + description: result.alreadyMember + ? `You're already a member of ${groupName || "the group"}.` + : `Welcome to ${groupName || "the group"}!`, + }); + navigate({ + to: ".", + search: (prev) => ({ ...prev, invite: undefined }), + replace: true, + }); + }, + onError: (error) => { + console.error("Failed to accept invite", error); + attemptedTokenRef.current = null; + toast({ + title: "Couldn't join group", + description: error.message, + variant: "destructive", + }); + }, + }, + ); + }, [user, inviteToken, inviteQuery.data, acceptInvite, toast, navigate]); + + return { + inviteValidation: inviteQuery.data, + isValidating: inviteQuery.isLoading, + hasValidInvite: inviteQuery.data?.is_valid === true, + }; +} + +function getError( + error: Error | null, + inviteValidation: InviteValidation | null | undefined, +): string | null { + if (error) return "This invite link is not valid"; + if (!inviteValidation || inviteValidation.is_valid) return null; + + switch (inviteValidation.reason) { + case "invite_expired": + return "This invite link has expired"; + case "invite_overused": + return "This invite link has reached its usage limit"; + case "invite_deactivated": + return "This invite link has been deactivated"; + default: + return "This invite link is no longer valid"; + } +} diff --git a/src/components/invite/useInviteValidation.ts b/src/components/invite/useInviteValidation.ts deleted file mode 100644 index ccf4216b..00000000 --- a/src/components/invite/useInviteValidation.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { useEffect } from "react"; -import { useToast } from "@/components/ui/use-toast"; -import { - useInviteValidationQuery, - useInviteMutation, -} from "@/api/invite-validation/useInviteValidationQuery"; - -export function useInviteValidation(inviteToken: string | undefined) { - const { toast } = useToast(); - - const { - data: inviteValidation, - isLoading: isValidating, - error: validationError, - } = useInviteValidationQuery(inviteToken || null); - - const inviteMutation = useInviteMutation(); - - // Handle validation side effects - useEffect(() => { - if (validationError) { - toast({ - title: "Invalid Invite", - description: "This invite link is not valid", - variant: "destructive", - }); - } - }, [validationError, toast]); - - useEffect(() => { - if (inviteValidation && !inviteValidation.is_valid) { - let message = "This invite link is no longer valid"; - switch (inviteValidation.reason) { - case "invite_expired": - message = "This invite link has expired"; - break; - case "invite_overused": - message = "This invite link has reached its usage limit"; - break; - case "invite_deactivated": - message = "This invite link has been deactivated"; - break; - } - toast({ - title: "Invalid Invite", - description: message, - variant: "destructive", - }); - } - }, [inviteValidation, toast]); - - function useInvite(userId: string): Promise { - if (!inviteToken) return Promise.resolve(false); - - return new Promise((resolve) => { - inviteMutation.mutate( - { - token: inviteToken, - userId, - }, - { - onSuccess: () => { - toast({ - title: "Success", - description: `Welcome to ${inviteValidation?.group_name || "the group"}!`, - }); - resolve(true); - }, - onError: (error) => { - console.error("failed validating invite", error); - resolve(false); - }, - }, - ); - }); - } - - return { - inviteToken: inviteToken || null, - inviteValidation, - isValidating, - validationError: validationError?.message || null, - useInvite, - hasValidInvite: inviteValidation?.is_valid === true, - }; -} diff --git a/src/contexts/AuthContext.tsx b/src/contexts/AuthContext.tsx index efeda5be..77577907 100644 --- a/src/contexts/AuthContext.tsx +++ b/src/contexts/AuthContext.tsx @@ -9,7 +9,6 @@ import { import { User } from "@supabase/supabase-js"; import { supabase } from "@/integrations/supabase/client"; import { useProfileQuery } from "@/api/auth/useProfile"; -import { useToast } from "@/hooks/use-toast"; import { AuthDialog } from "@/components/AuthDialog/AuthDialog"; import { Profile } from "@/api/auth/types"; @@ -42,7 +41,6 @@ export function AuthProvider({ children }: AuthProviderProps) { const [inviteToken, setInviteToken] = useState(); const [groupName, setGroupName] = useState(); - const { toast } = useToast(); const profileQuery = useProfileQuery(user?.id); const profile = profileQuery.data; @@ -50,70 +48,17 @@ export function AuthProvider({ children }: AuthProviderProps) { // Set up auth state listener first const { data: { subscription }, - } = supabase.auth.onAuthStateChange(async (event, session) => { - // setSession(session); + } = supabase.auth.onAuthStateChange((event, session) => { setUser(session?.user || null); setLoading(false); - // Clear cached profile on sign out for security - if (event === "SIGNED_OUT") { - // For sign out, use the current user state from closure - if (user?.id) { - // await profileOfflineService.clearCachedProfile(user.id); - } - } - - // Handle invite processing when user signs in - if (event === "SIGNED_IN" && session?.user) { - const urlParams = new URLSearchParams(window.location.search); - const inviteToken = urlParams.get("invite"); - - if (inviteToken) { - try { - const { data, error } = await supabase.rpc("use_invite_token", { - token: inviteToken, - user_id: session.user.id, - }); - - if (error) { - console.error("Error using invite:", error); - toast({ - title: "Error", - description: "Failed to join group", - variant: "destructive", - }); - } else if (data && data.length > 0) { - const result = data[0]; - if (result.success) { - toast({ - title: "Success", - description: "Welcome to the group!", - }); - // Clear invite from URL - const newUrl = new URL(window.location.href); - newUrl.searchParams.delete("invite"); - window.history.replaceState({}, "", newUrl.toString()); - } else { - toast({ - title: "Error", - description: result.message, - variant: "destructive", - }); - } - } - } catch (error) { - console.error("Error processing invite:", error); - } - } - - // Close auth dialog on successful sign in + if (event === "SIGNED_IN") { setAuthDialogOpen(false); } }); // Then check for existing session supabase.auth.getSession().then(({ data: { session } }) => { - // setSession(session); setUser(session?.user || null); setLoading(false); }); @@ -121,7 +66,7 @@ export function AuthProvider({ children }: AuthProviderProps) { return () => { subscription.unsubscribe(); }; - }, [toast, user?.id]); + }, []); async function signOut() { // Clear cached profile before signing out diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx index 2c112fb6..97b369d3 100644 --- a/src/routes/__root.tsx +++ b/src/routes/__root.tsx @@ -16,7 +16,7 @@ import { TanStackRouterDevtools } from "@tanstack/router-devtools"; import { HelmetProvider } from "react-helmet-async"; import { AuthProvider } from "@/contexts/AuthContext"; import { useAuth } from "@/contexts/AuthContext"; -import { useInviteValidation } from "@/components/invite/useInviteValidation"; +import { useInviteFlow } from "@/components/invite/useInviteFlow"; import { InviteLandingPage } from "@/components/invite/InviteLandingPage"; import { OnboardingDialog } from "@/components/onboarding/OnboardingDialog"; import { useProfileQuery } from "@/api/auth/useProfile"; @@ -78,8 +78,10 @@ function RootComponent() { function RootContent() { const { user, loading: authLoading, needsOnboarding } = useAuth(); const search = useSearch({ from: "__root__" }); - const { inviteValidation, isValidating, hasValidInvite } = - useInviteValidation(search.invite); + const { inviteValidation, isValidating, hasValidInvite } = useInviteFlow( + search.invite, + user, + ); const { isLoading: profileLoading } = useProfileQuery(user?.id); @@ -101,11 +103,11 @@ function RootContent() { ); } - if (hasValidInvite && !user && inviteValidation) { + if (hasValidInvite && !user && inviteValidation && search.invite) { return ( {}} + inviteToken={search.invite} /> ); }