diff --git a/src/domain/team-members/mappers/team-member-mapper.ts b/src/domain/team-members/mappers/team-member-mapper.ts index d283da28..de1652ba 100644 --- a/src/domain/team-members/mappers/team-member-mapper.ts +++ b/src/domain/team-members/mappers/team-member-mapper.ts @@ -88,9 +88,12 @@ export function mapTeamMemberListItems(rawItems: unknown[]): TeamMemberListItem[ export function mapTeamMemberDetail(raw: unknown): TeamMemberDetail { const record = (typeof raw === "object" && raw !== null ? raw : {}) as Record; const listItem = mapTeamMemberListItem(record); + // Present only on an update that re-sent a pending member's invite. + const inviteEmailSent = record.inviteEmailSent ?? record.InviteEmailSent; return { ...listItem, serviceAreas: readStringArray(record, "serviceAreas", "ServiceAreas"), permissions: readPermissions(record), + ...(typeof inviteEmailSent === "boolean" && { inviteEmailSent }), }; } diff --git a/src/domain/team-members/types/team-member.ts b/src/domain/team-members/types/team-member.ts index 6faa47c5..975eb697 100644 --- a/src/domain/team-members/types/team-member.ts +++ b/src/domain/team-members/types/team-member.ts @@ -47,6 +47,8 @@ export interface TeamPermissionValue { export interface TeamMemberDetail extends TeamMemberListItem { serviceAreas: string[]; permissions: TeamPermissionValue[]; + /** Set only after an update that changed a pending member's email and re-sent their invite. */ + inviteEmailSent?: boolean; } export interface UpdateTeamMemberInput { diff --git a/src/domain/team-members/use-cases/use-team-member-detail.ts b/src/domain/team-members/use-cases/use-team-member-detail.ts index 3622ab5c..c54b1f7c 100644 --- a/src/domain/team-members/use-cases/use-team-member-detail.ts +++ b/src/domain/team-members/use-cases/use-team-member-detail.ts @@ -23,6 +23,15 @@ export function useTeamMemberDetail( }); } +export const UPDATED_INVITE_EMAIL_FAILED_MESSAGE = + "Team member updated, but the invite to their new email couldn't be sent. Use Resend invite."; + +function updatedMessage(member: TeamMemberDetail): string { + return member.inviteEmailSent === true + ? `Team member updated. A new invite was sent to ${member.email}.` + : "Team member updated."; +} + interface UpdateVariables { id: string | number; input: UpdateTeamMemberInput; @@ -36,7 +45,11 @@ export function useUpdateTeamMember(): UseMutationResult { void queryClient.invalidateQueries({ queryKey: queryKeys.teamMembers.all }); void queryClient.setQueryData(queryKeys.teamMembers.detail(member.id), member); - toast.success("Team member updated."); + if (member.inviteEmailSent === false) { + toast.warning(UPDATED_INVITE_EMAIL_FAILED_MESSAGE); + return; + } + toast.success(updatedMessage(member)); }, onError: (error: Error) => { toast.error(error.message || "Failed to update team member"); diff --git a/src/test/domain/team-members/team-member-mapper.test.ts b/src/test/domain/team-members/team-member-mapper.test.ts index 164a2ef7..9073f169 100644 --- a/src/test/domain/team-members/team-member-mapper.test.ts +++ b/src/test/domain/team-members/team-member-mapper.test.ts @@ -54,4 +54,10 @@ describe("mapTeamMemberDetail", () => { ], }); }); + + it("keeps the invite email result only when the update reported one", () => { + expect(mapTeamMemberDetail({ id: "u1", inviteEmailSent: false }).inviteEmailSent).toBe(false); + expect(mapTeamMemberDetail({ Id: "u1", InviteEmailSent: true }).inviteEmailSent).toBe(true); + expect(mapTeamMemberDetail({ id: "u1" })).not.toHaveProperty("inviteEmailSent"); + }); }); diff --git a/src/test/domain/team-members/use-update-team-member.test.tsx b/src/test/domain/team-members/use-update-team-member.test.tsx new file mode 100644 index 00000000..aa7cc5a4 --- /dev/null +++ b/src/test/domain/team-members/use-update-team-member.test.tsx @@ -0,0 +1,72 @@ +import type { ReactNode } from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { act, renderHook, waitFor } from "@testing-library/react"; +import { toast } from "react-toastify"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { teamMembersApi } from "@/domain/team-members/api/team-members-api"; +import type { + TeamMemberDetail, + UpdateTeamMemberInput, +} from "@/domain/team-members/types/team-member"; +import { + UPDATED_INVITE_EMAIL_FAILED_MESSAGE, + useUpdateTeamMember, +} from "@/domain/team-members/use-cases/use-team-member-detail"; + +vi.mock("react-toastify", () => ({ + toast: { success: vi.fn(), warning: vi.fn(), error: vi.fn() }, +})); + +vi.mock("@/domain/team-members/api/team-members-api", () => ({ + teamMembersApi: { update: vi.fn() }, +})); + +const UPDATED = { + id: "user-7", + name: "Taylor Reed", + email: "taylor.reed@example.com", + pendingRegistration: true, + serviceAreas: ["East"], + permissions: [], +} as unknown as TeamMemberDetail; + +function wrapper({ children }: { children: ReactNode }) { + const client = new QueryClient({ defaultOptions: { mutations: { retry: false } } }); + return {children}; +} + +async function update(updated: TeamMemberDetail) { + vi.mocked(teamMembersApi.update).mockResolvedValue(updated); + const { result } = renderHook(() => useUpdateTeamMember(), { wrapper }); + act(() => result.current.mutate({ id: "user-7", input: {} as UpdateTeamMemberInput })); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); +} + +describe("useUpdateTeamMember", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("confirms an ordinary update", async () => { + await update(UPDATED); + + expect(toast.success).toHaveBeenCalledWith("Team member updated."); + expect(toast.warning).not.toHaveBeenCalled(); + }); + + it("tells the admin a new invite went to the corrected email", async () => { + await update({ ...UPDATED, inviteEmailSent: true }); + + expect(toast.success).toHaveBeenCalledWith( + "Team member updated. A new invite was sent to taylor.reed@example.com.", + ); + }); + + it("warns the admin to re-send when the new invite could not be emailed", async () => { + await update({ ...UPDATED, inviteEmailSent: false }); + + expect(toast.warning).toHaveBeenCalledWith(UPDATED_INVITE_EMAIL_FAILED_MESSAGE); + expect(toast.success).not.toHaveBeenCalled(); + }); +});