diff --git a/client/src/components/support/BulkChangeModal.tsx b/client/src/components/support/BulkChangeModal.tsx index b9b7def0b..d940d4671 100644 --- a/client/src/components/support/BulkChangeModal.tsx +++ b/client/src/components/support/BulkChangeModal.tsx @@ -1,7 +1,7 @@ import { useSupportCenterContext } from "../../context/SupportCenterContext"; import useSupportQueues from "../../hooks/useSupportQueues"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { User } from "../../types"; +import useAssignableUsers from "../../hooks/useAssignableUsers"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; import api from "../../api"; import { useForm } from "react-hook-form"; import { @@ -49,15 +49,7 @@ const BulkChangeModal: React.FC = ({ withCount: false, }); - const { data: assignableUsers } = useQuery< - Pick[] - >({ - queryKey: ["assignableUsers"], - queryFn: async () => { - const res = await api.getSupportAssignableUsers(); - return res.data.users; - }, - }); + const { data: assignableUsers } = useAssignableUsers({ enabled: open }); useEffect(() => { if (open) { diff --git a/client/src/components/support/QueueAutoAssignmentModal.tsx b/client/src/components/support/QueueAutoAssignmentModal.tsx index b3de0798c..105c503ca 100644 --- a/client/src/components/support/QueueAutoAssignmentModal.tsx +++ b/client/src/components/support/QueueAutoAssignmentModal.tsx @@ -11,7 +11,8 @@ import { IconCheck, IconX } from "@tabler/icons-react"; import api from "../../api"; import { useNotifications } from "../../context/NotificationContext"; import { useSupportCenterContext } from "../../context/SupportCenterContext"; -import { SupportQueueAutoAssignConfig, User } from "../../types"; +import useAssignableUsers from "../../hooks/useAssignableUsers"; +import { SupportQueueAutoAssignConfig } from "../../types"; interface QueueAutoAssignmentModalProps { open: boolean; @@ -39,16 +40,8 @@ const QueueAutoAssignmentModal: React.FC = ({ enabled: open, }); - const { data: assignableUsers, isLoading: isAssignableUsersLoading } = useQuery< - Pick[] - >({ - queryKey: ["assignableUsers"], - queryFn: async () => { - const res = await api.getSupportAssignableUsers(); - return res.data.users; - }, - enabled: open, - }); + const { data: assignableUsers, isLoading: isAssignableUsersLoading } = + useAssignableUsers({ enabled: open }); // Default the selected queue to the one active in the sidebar, falling back to the first. useEffect(() => { diff --git a/client/src/components/support/TicketAssigneePicker.tsx b/client/src/components/support/TicketAssigneePicker.tsx index ef9cdbb48..8b25dc0e6 100644 --- a/client/src/components/support/TicketAssigneePicker.tsx +++ b/client/src/components/support/TicketAssigneePicker.tsx @@ -1,13 +1,16 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; import { Popover, PopoverButton, PopoverPanel } from "@headlessui/react"; import { Avatar, Button, Input, Text } from "@libretexts/davis-react"; import { IconCheck, IconPlus, IconSearch, IconX } from "@tabler/icons-react"; import api from "../../api"; -import { User } from "../../types"; +import { SupportTicket } from "../../types"; import { useNotifications } from "../../context/NotificationContext"; import { useTypedSelector } from "../../state/hooks"; import useDebounce from "../../hooks/useDebounce"; +import useAssignableUsers, { + AssignableUser, +} from "../../hooks/useAssignableUsers"; interface TicketAssigneePickerProps { ticketId: string; @@ -23,11 +26,6 @@ interface TicketAssigneePickerProps { triggerId?: string; } -type AssignableUser = Pick< - User, - "uuid" | "firstName" | "lastName" | "email" | "avatar" ->; - const fullName = (u: AssignableUser) => `${u.firstName} ${u.lastName}`; const TicketAssigneePicker: React.FC = ({ @@ -50,12 +48,7 @@ const TicketAssigneePicker: React.FC = ({ setSelected(assignedUUIDs ?? []); }, [assignedUUIDs]); - const { data: assignableUsers } = useQuery({ - queryKey: ["assignableUsers"], - queryFn: async () => { - const res = await api.getSupportAssignableUsers(); - return res.data.users; - }, + const { data: assignableUsers } = useAssignableUsers({ enabled: !!ticketId && !disabled, }); @@ -67,8 +60,31 @@ const TicketAssigneePicker: React.FC = ({ throw new Error(res.data.errMsg); } }, - onSuccess: async () => { - await queryClient.invalidateQueries(["ticket", ticketId]); + onSuccess: (_data, assignees) => { + // Patch the cached ticket so the UI settles immediately; the background + // invalidation below reconciles with the server without blocking the user. + // Additions resolve against the cached staff roster (falling back to the + // records already on the ticket) so both chips and any assignedUsers-driven + // UI stay correct even if the refetch is slow or fails. + queryClient.setQueryData( + ["ticket", ticketId], + (prev) => { + if (!prev) return prev; + const roster = assignableUsers ?? []; + return { + ...prev, + assignedUUIDs: assignees, + assignedUsers: assignees + .map( + (uuid) => + prev.assignedUsers?.find((u) => u.uuid === uuid) ?? + roster.find((u) => u.uuid === uuid) + ) + .filter((u): u is AssignableUser => !!u), + }; + } + ); + queryClient.invalidateQueries(["ticket", ticketId]); addNotification({ type: "success", message: "Successfully updated assignees.", @@ -92,7 +108,7 @@ const TicketAssigneePicker: React.FC = ({ mutateRef.current = updateAssignedMutation.mutate; const debouncedSave = useMemo( - () => debounce((assignees: string[]) => mutateRef.current(assignees), 500), + () => debounce((assignees: string[]) => mutateRef.current(assignees), 250), [] ); diff --git a/client/src/hooks/useAssignableUsers.ts b/client/src/hooks/useAssignableUsers.ts new file mode 100644 index 000000000..41c9af8f0 --- /dev/null +++ b/client/src/hooks/useAssignableUsers.ts @@ -0,0 +1,42 @@ +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { AssignableUser } from "../types"; +import api from "../api"; + +export type { AssignableUser }; + +/** + * Shared access to the support staff roster. The roster is small and near-static, + * so it is cached aggressively — every surface that offers assignment (ticket + * picker, bulk change, queue auto-assignment) reads the same cache entry instead + * of issuing its own request. + */ +const useAssignableUsers = ({ enabled = true }: { enabled?: boolean } = {}) => { + const QUERY_KEY = ['assignableUsers']; + const queryClient = useQueryClient(); + + const queryObj = useQuery({ + queryKey: QUERY_KEY, + queryFn: async () => { + const res = await api.getSupportAssignableUsers(); + if (res.data.err) { + throw new Error(res.data.errMsg); + } + return res.data.users; + }, + enabled, + staleTime: 5 * 60 * 1000, // 5 minutes + cacheTime: 30 * 60 * 1000, // survives navigation between the dashboard and a ticket + refetchOnWindowFocus: false, + meta: { + errorMessage: "Failed to fetch assignable users.", + } + }) + + const invalidate = () => { + return queryClient.invalidateQueries({ queryKey: QUERY_KEY }); + } + + return { ...queryObj, QUERY_KEY, invalidate }; +} + +export default useAssignableUsers diff --git a/client/src/screens/conductor/support/Ticket.tsx b/client/src/screens/conductor/support/Ticket.tsx index 3417e63b7..c0a55de3f 100644 --- a/client/src/screens/conductor/support/Ticket.tsx +++ b/client/src/screens/conductor/support/Ticket.tsx @@ -23,6 +23,7 @@ import { useDocumentTitle } from "usehooks-ts"; import AuthHelper from "../../../components/util/AuthHelper"; import { Button, Heading, Stack } from "@libretexts/davis-react"; import { IconCheck, IconRefresh, IconTrash } from "@tabler/icons-react"; +import useAssignableUsers from "../../../hooks/useAssignableUsers"; const getIdFromURL = (url: string) => { if (!url) return ""; @@ -62,6 +63,12 @@ const SupportTicketView = () => { enabled: !!id, }); + // Warm the staff roster cache while the ticket loads so the assignee picker + // has data on hand the first time it is opened. + useAssignableUsers({ + enabled: !!id && !!(user.isSupport || user.isHarvester), + }); + const updateTicketStatusMutation = useMutation({ mutationFn: (status: "open" | "in_progress" | "closed") => updateTicket({ status }), diff --git a/client/src/types/User.ts b/client/src/types/User.ts index 0cfa047e9..95dbc5af3 100644 --- a/client/src/types/User.ts +++ b/client/src/types/User.ts @@ -52,6 +52,16 @@ export type AuthorizedApp = { icon: string; }; +/** + * The narrow projection of a user returned by the support staff roster + * (`/support/assignable-users`). Assignment surfaces only ever need identity + * and avatar, so this is the shape they share. + */ +export type AssignableUser = Pick< + User, + "uuid" | "firstName" | "lastName" | "email" | "avatar" +>; + export type UserWCentralID = User & { centralID?: string; }; diff --git a/client/src/types/support.ts b/client/src/types/support.ts index 48a1e5eaa..747e812f4 100644 --- a/client/src/types/support.ts +++ b/client/src/types/support.ts @@ -1,5 +1,5 @@ import { SupportQueue } from "./supportqueues"; -import { User, UserWCentralID } from "./User"; +import { AssignableUser, User, UserWCentralID } from "./User"; export type SupportTicketGuest = { firstName: string; @@ -30,7 +30,7 @@ export type SupportTicket = { category?: string; capturedURL?: string; assignedUUIDs?: string[]; // User uuids - assignedUsers?: UserWCentralID[]; + assignedUsers?: AssignableUser[]; user?: UserWCentralID; guest?: SupportTicketGuest; ccedEmails?: { diff --git a/server/api/support.ts b/server/api/support.ts index 254adbb78..d8568b545 100644 --- a/server/api/support.ts +++ b/server/api/support.ts @@ -459,7 +459,8 @@ async function _getAssignableUsersInternal(): Promise