fix: harden ticket client data guards

This commit is contained in:
Esdras Renan 2025-11-01 01:48:15 -03:00
parent 5c5207ceb8
commit a3d431efa8
9 changed files with 129 additions and 111 deletions

View file

@ -48,12 +48,12 @@ export default function NewTicketPage() {
const queuesEnabled = Boolean(isStaff && convexUserId) const queuesEnabled = Boolean(isStaff && convexUserId)
const queueArgs = queuesEnabled const queueArgs = queuesEnabled
? { tenantId: DEFAULT_TENANT_ID, viewerId: convexUserId as Id<"users"> } ? { tenantId: DEFAULT_TENANT_ID, viewerId: convexUserId as Id<"users"> }
: "skip" : undefined
const queuesRaw = useQuery( const queuesRemote = useQuery(queuesEnabled ? api.queues.summary : "skip", queueArgs)
queuesEnabled ? api.queues.summary : "skip", const queues = useMemo(
queueArgs () => (Array.isArray(queuesRemote) ? (queuesRemote as TicketQueueSummary[]) : []),
) as TicketQueueSummary[] | undefined [queuesRemote]
const queues = useMemo(() => queuesRaw ?? [], [queuesRaw]) )
const create = useMutation(api.tickets.create) const create = useMutation(api.tickets.create)
const addComment = useMutation(api.tickets.addComment) const addComment = useMutation(api.tickets.addComment)
const staffRaw = useQuery(api.users.listAgents, { tenantId: DEFAULT_TENANT_ID }) as Doc<"users">[] | undefined const staffRaw = useQuery(api.users.listAgents, { tenantId: DEFAULT_TENANT_ID }) as Doc<"users">[] | undefined
@ -63,29 +63,34 @@ export default function NewTicketPage() {
) )
const directoryQueryEnabled = queuesEnabled && Boolean(convexUserId) const directoryQueryEnabled = queuesEnabled && Boolean(convexUserId)
const companiesRaw = useQuery( const companiesArgs = directoryQueryEnabled
directoryQueryEnabled ? api.companies.list : "skip",
directoryQueryEnabled
? { tenantId: DEFAULT_TENANT_ID, viewerId: convexUserId as Id<"users"> } ? { tenantId: DEFAULT_TENANT_ID, viewerId: convexUserId as Id<"users"> }
: "skip" : undefined
) as Array<{ id: string; name: string; slug?: string | null }> | undefined const companiesRemote = useQuery(
directoryQueryEnabled ? api.companies.list : "skip",
companiesArgs
)
const companies = useMemo( const companies = useMemo(
() => () =>
(companiesRaw ?? []).map((company) => ({ (Array.isArray(companiesRemote) ? companiesRemote : []).map((company) => ({
id: String(company.id), id: String(company.id),
name: company.name, name: company.name,
slug: company.slug ?? null, slug: company.slug ?? null,
})), })),
[companiesRaw] [companiesRemote]
) )
const customersRaw = useQuery( const customersArgs = directoryQueryEnabled
directoryQueryEnabled ? api.users.listCustomers : "skip",
directoryQueryEnabled
? { tenantId: DEFAULT_TENANT_ID, viewerId: convexUserId as Id<"users"> } ? { tenantId: DEFAULT_TENANT_ID, viewerId: convexUserId as Id<"users"> }
: "skip" : undefined
) as CustomerOption[] | undefined const customersRemote = useQuery(
const customers = useMemo(() => customersRaw ?? [], [customersRaw]) directoryQueryEnabled ? api.users.listCustomers : "skip",
customersArgs
)
const customers = useMemo(
() => (Array.isArray(customersRemote) ? (customersRemote as CustomerOption[]) : []),
[customersRemote]
)
const [subject, setSubject] = useState("") const [subject, setSubject] = useState("")
const [summary, setSummary] = useState("") const [summary, setSummary] = useState("")

View file

@ -132,12 +132,17 @@ export function CloseTicketDialog({
const addComment = useMutation(api.tickets.addComment) const addComment = useMutation(api.tickets.addComment)
const adjustWorkSummary = useMutation(api.tickets.adjustWorkSummary) const adjustWorkSummary = useMutation(api.tickets.adjustWorkSummary)
const closingTemplates = useQuery( const closingTemplateArgs =
actorId && open ? { tenantId, viewerId: actorId, kind: "closing" as const } : undefined
const closingTemplatesRemote = useQuery(
actorId && open ? api.commentTemplates.list : "skip", actorId && open ? api.commentTemplates.list : "skip",
actorId && open ? { tenantId, viewerId: actorId, kind: "closing" as const } : "skip" closingTemplateArgs
) as { id: string; title: string; body: string }[] | undefined )
const closingTemplates = Array.isArray(closingTemplatesRemote)
? (closingTemplatesRemote as { id: string; title: string; body: string }[])
: undefined
const templatesLoading = Boolean(actorId && open && closingTemplates === undefined) const templatesLoading = Boolean(actorId && open && !Array.isArray(closingTemplatesRemote))
const templates = useMemo<ClosingTemplate[]>(() => { const templates = useMemo<ClosingTemplate[]>(() => {
if (closingTemplates && closingTemplates.length > 0) { if (closingTemplates && closingTemplates.length > 0) {
return closingTemplates.map((t) => ({ id: t.id, title: t.title, body: t.body })) return closingTemplates.map((t) => ({ id: t.id, title: t.title, body: t.body }))

View file

@ -128,14 +128,14 @@ export function NewTicketDialog({ triggerClassName }: { triggerClassName?: strin
const queuesEnabled = Boolean(isStaff && convexUserId) const queuesEnabled = Boolean(isStaff && convexUserId)
const queueArgs = queuesEnabled const queueArgs = queuesEnabled
? { tenantId: DEFAULT_TENANT_ID, viewerId: convexUserId as Id<"users"> } ? { tenantId: DEFAULT_TENANT_ID, viewerId: convexUserId as Id<"users"> }
: "skip" : undefined
useDefaultQueues(DEFAULT_TENANT_ID) useDefaultQueues(DEFAULT_TENANT_ID)
const queuesRaw = useQuery( const queuesRemote = useQuery(queuesEnabled ? api.queues.summary : "skip", queueArgs)
queuesEnabled ? api.queues.summary : "skip", const queues = useMemo(
queueArgs () => (Array.isArray(queuesRemote) ? (queuesRemote as TicketQueueSummary[]) : []),
) as TicketQueueSummary[] | undefined [queuesRemote]
const queues = useMemo(() => queuesRaw ?? [], [queuesRaw]) )
const create = useMutation(api.tickets.create) const create = useMutation(api.tickets.create)
const addComment = useMutation(api.tickets.addComment) const addComment = useMutation(api.tickets.addComment)
const staffRaw = useQuery(api.users.listAgents, { tenantId: DEFAULT_TENANT_ID }) as Doc<"users">[] | undefined const staffRaw = useQuery(api.users.listAgents, { tenantId: DEFAULT_TENANT_ID }) as Doc<"users">[] | undefined
@ -145,29 +145,34 @@ export function NewTicketDialog({ triggerClassName }: { triggerClassName?: strin
) )
const directoryQueryEnabled = queuesEnabled && Boolean(convexUserId) const directoryQueryEnabled = queuesEnabled && Boolean(convexUserId)
const companiesRaw = useQuery( const companiesArgs = directoryQueryEnabled
directoryQueryEnabled ? api.companies.list : "skip",
directoryQueryEnabled
? { tenantId: DEFAULT_TENANT_ID, viewerId: convexUserId as Id<"users"> } ? { tenantId: DEFAULT_TENANT_ID, viewerId: convexUserId as Id<"users"> }
: "skip" : undefined
) as Array<{ id: string; name: string; slug?: string | null }> | undefined const companiesRemote = useQuery(
directoryQueryEnabled ? api.companies.list : "skip",
companiesArgs
)
const companies = useMemo( const companies = useMemo(
() => () =>
(companiesRaw ?? []).map((company) => ({ (Array.isArray(companiesRemote) ? companiesRemote : []).map((company) => ({
id: String(company.id), id: String(company.id),
name: company.name, name: company.name,
slug: company.slug ?? null, slug: company.slug ?? null,
})), })),
[companiesRaw] [companiesRemote]
) )
const customersRaw = useQuery( const customersArgs = directoryQueryEnabled
directoryQueryEnabled ? api.users.listCustomers : "skip",
directoryQueryEnabled
? { tenantId: DEFAULT_TENANT_ID, viewerId: convexUserId as Id<"users"> } ? { tenantId: DEFAULT_TENANT_ID, viewerId: convexUserId as Id<"users"> }
: "skip" : undefined
) as CustomerOption[] | undefined const customersRemote = useQuery(
const customers = useMemo(() => customersRaw ?? [], [customersRaw]) directoryQueryEnabled ? api.users.listCustomers : "skip",
customersArgs
)
const customers = useMemo(
() => (Array.isArray(customersRemote) ? (customersRemote as CustomerOption[]) : []),
[customersRemote]
)
const [attachments, setAttachments] = useState<Array<{ storageId: string; name: string; size?: number; type?: string }>>([]) const [attachments, setAttachments] = useState<Array<{ storageId: string; name: string; size?: number; type?: string }>>([])
const [customersInitialized, setCustomersInitialized] = useState(false) const [customersInitialized, setCustomersInitialized] = useState(false)
const attachmentsTotalBytes = useMemo( const attachmentsTotalBytes = useMemo(

View file

@ -32,12 +32,9 @@ export function PlayNextTicketCard({ context }: PlayNextTicketCardProps) {
const router = useRouter() const router = useRouter()
const { convexUserId, isStaff } = useAuth() const { convexUserId, isStaff } = useAuth()
const queuesEnabled = Boolean(isStaff && convexUserId) const queuesEnabled = Boolean(isStaff && convexUserId)
const queueArgs = queuesEnabled const queueArgs = queuesEnabled ? { tenantId: DEFAULT_TENANT_ID, viewerId: convexUserId as Id<"users"> } : undefined
? { tenantId: DEFAULT_TENANT_ID, viewerId: convexUserId as Id<"users"> } const queueSummaryResult = useQuery(queuesEnabled ? api.queues.summary : "skip", queueArgs)
: "skip" const queueSummary: TicketQueueSummary[] = Array.isArray(queueSummaryResult) ? queueSummaryResult : []
const queueSummary = (
useQuery(queuesEnabled ? api.queues.summary : "skip", queueArgs) as TicketQueueSummary[] | undefined
) ?? []
const playNext = useMutation(api.tickets.playNext) const playNext = useMutation(api.tickets.playNext)
const [selectedQueueId, setSelectedQueueId] = useState<string | undefined>(undefined) const [selectedQueueId, setSelectedQueueId] = useState<string | undefined>(undefined)

View file

@ -74,15 +74,16 @@ function TicketRow({ ticket, entering }: { ticket: Ticket; entering: boolean })
export function RecentTicketsPanel() { export function RecentTicketsPanel() {
const { convexUserId } = useAuth() const { convexUserId } = useAuth()
const ticketsRaw = useQuery( const ticketsArgs = convexUserId
api.tickets.list, ? { tenantId: DEFAULT_TENANT_ID, viewerId: convexUserId as Id<"users">, limit: 12 }
convexUserId ? { tenantId: DEFAULT_TENANT_ID, viewerId: convexUserId as Id<"users">, limit: 12 } : "skip" : undefined
) const ticketsResult = useQuery(convexUserId ? api.tickets.list : "skip", ticketsArgs)
const [enteringId, setEnteringId] = useState<string | null>(null) const [enteringId, setEnteringId] = useState<string | null>(null)
const previousIdsRef = useRef<string[]>([]) const previousIdsRef = useRef<string[]>([])
const tickets = useMemo(() => { const tickets = useMemo(() => {
const all = mapTicketsFromServerList((ticketsRaw ?? []) as unknown[]).filter((t) => t.status !== "RESOLVED") if (!Array.isArray(ticketsResult)) return []
const all = mapTicketsFromServerList(ticketsResult as unknown[]).filter((t) => t.status !== "RESOLVED")
// Unassigned first (no assignee), oldest first among unassigned; then the rest by updatedAt desc // Unassigned first (no assignee), oldest first among unassigned; then the rest by updatedAt desc
const unassigned = all const unassigned = all
.filter((t) => !t.assignee) .filter((t) => !t.assignee)
@ -91,10 +92,10 @@ export function RecentTicketsPanel() {
.filter((t) => !!t.assignee) .filter((t) => !!t.assignee)
.sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime()) .sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime())
return [...unassigned, ...assigned].slice(0, 6) return [...unassigned, ...assigned].slice(0, 6)
}, [ticketsRaw]) }, [ticketsResult])
useEffect(() => { useEffect(() => {
if (ticketsRaw === undefined) { if (!Array.isArray(ticketsResult)) {
previousIdsRef.current = [] previousIdsRef.current = []
return return
} }
@ -113,7 +114,7 @@ export function RecentTicketsPanel() {
setEnteringId(topId) setEnteringId(topId)
} }
previousIdsRef.current = ids previousIdsRef.current = ids
}, [tickets, ticketsRaw]) }, [tickets, ticketsResult])
useEffect(() => { useEffect(() => {
if (!enteringId) return if (!enteringId) return
@ -121,7 +122,7 @@ export function RecentTicketsPanel() {
return () => window.clearTimeout(timer) return () => window.clearTimeout(timer)
}, [enteringId]) }, [enteringId])
if (ticketsRaw === undefined) { if (convexUserId && !Array.isArray(ticketsResult)) {
return ( return (
<Card className="rounded-2xl border border-slate-200 bg-white shadow-sm"> <Card className="rounded-2xl border border-slate-200 bg-white shadow-sm">
<CardHeader className="pb-2"> <CardHeader className="pb-2">

View file

@ -70,14 +70,15 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
const [localBodies, setLocalBodies] = useState<Record<string, string>>({}) const [localBodies, setLocalBodies] = useState<Record<string, string>>({})
const [commentsOrder, setCommentsOrder] = useState<CommentsOrder>("descending") const [commentsOrder, setCommentsOrder] = useState<CommentsOrder>("descending")
const templateArgs = convexUserId && isStaff const templateArgs =
convexUserId && isStaff
? { tenantId: ticket.tenantId, viewerId: convexUserId as Id<"users">, kind: "comment" as const } ? { tenantId: ticket.tenantId, viewerId: convexUserId as Id<"users">, kind: "comment" as const }
: "skip" : undefined
const templatesResult = useQuery(convexUserId && isStaff ? api.commentTemplates.list : "skip", templateArgs) as const templatesResult = useQuery(convexUserId && isStaff ? api.commentTemplates.list : "skip", templateArgs)
| { id: string; title: string; body: string }[] const templates = Array.isArray(templatesResult)
| undefined ? (templatesResult as { id: string; title: string; body: string }[])
const templates = templatesResult ?? [] : []
const templatesLoading = Boolean(convexUserId && isStaff) && templatesResult === undefined const templatesLoading = Boolean(convexUserId && isStaff) && !Array.isArray(templatesResult)
const canUseTemplates = Boolean(convexUserId && isStaff) const canUseTemplates = Boolean(convexUserId && isStaff)
const insertTemplateIntoBody = (html: string) => { const insertTemplateIntoBody = (html: string) => {

View file

@ -16,11 +16,10 @@ interface TicketQueueSummaryProps {
export function TicketQueueSummaryCards({ queues }: TicketQueueSummaryProps) { export function TicketQueueSummaryCards({ queues }: TicketQueueSummaryProps) {
const { convexUserId, isStaff } = useAuth() const { convexUserId, isStaff } = useAuth()
const enabled = Boolean(isStaff && convexUserId) const enabled = Boolean(isStaff && convexUserId)
const queueArgs = enabled const queueArgs = enabled ? { tenantId: DEFAULT_TENANT_ID, viewerId: convexUserId as Id<"users"> } : undefined
? { tenantId: DEFAULT_TENANT_ID, viewerId: convexUserId as Id<"users"> }
: "skip"
const fromServer = useQuery(enabled ? api.queues.summary : "skip", queueArgs) const fromServer = useQuery(enabled ? api.queues.summary : "skip", queueArgs)
const data: TicketQueueSummary[] = (queues ?? (fromServer as TicketQueueSummary[] | undefined) ?? []) const serverData = Array.isArray(fromServer) ? fromServer : undefined
const data: TicketQueueSummary[] = queues ?? serverData ?? []
if (!queues && fromServer === undefined) { if (!queues && fromServer === undefined) {
return ( return (

View file

@ -167,42 +167,47 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
const updateCategories = useMutation(api.tickets.updateCategories) const updateCategories = useMutation(api.tickets.updateCategories)
const agents = (useQuery(api.users.listAgents, { tenantId: ticket.tenantId }) as Doc<"users">[] | undefined) ?? [] const agents = (useQuery(api.users.listAgents, { tenantId: ticket.tenantId }) as Doc<"users">[] | undefined) ?? []
const queuesEnabled = Boolean(isStaff && convexUserId) const queuesEnabled = Boolean(isStaff && convexUserId)
const companiesRaw = useQuery( const companiesArgs = convexUserId
convexUserId ? api.companies.list : "skip",
convexUserId
? { tenantId: ticket.tenantId, viewerId: convexUserId as Id<"users"> } ? { tenantId: ticket.tenantId, viewerId: convexUserId as Id<"users"> }
: "skip" : undefined
) as Array<{ id: string; name: string; slug?: string | null }> | undefined const companiesRemote = useQuery(
convexUserId ? api.companies.list : "skip",
companiesArgs
)
const companies = useMemo( const companies = useMemo(
() => () =>
(companiesRaw ?? []).map((company) => ({ (Array.isArray(companiesRemote) ? companiesRemote : []).map((company) => ({
id: String(company.id), id: String(company.id),
name: company.name, name: company.name,
slug: company.slug ?? null, slug: company.slug ?? null,
})), })),
[companiesRaw] [companiesRemote]
) )
const customersRaw = useQuery( const customersArgs = convexUserId
convexUserId ? api.users.listCustomers : "skip",
convexUserId
? { tenantId: ticket.tenantId, viewerId: convexUserId as Id<"users"> } ? { tenantId: ticket.tenantId, viewerId: convexUserId as Id<"users"> }
: "skip" : undefined
) as CustomerOption[] | undefined const customersRemote = useQuery(
const customers = useMemo(() => customersRaw ?? [], [customersRaw]) convexUserId ? api.users.listCustomers : "skip",
customersArgs
)
const customers = useMemo(
() => (Array.isArray(customersRemote) ? (customersRemote as CustomerOption[]) : []),
[customersRemote]
)
const queueArgs = queuesEnabled const queueArgs = queuesEnabled
? { tenantId: ticket.tenantId, viewerId: convexUserId as Id<"users"> } ? { tenantId: ticket.tenantId, viewerId: convexUserId as Id<"users"> }
: "skip" : undefined
const queues = ( const queuesResult = useQuery(queuesEnabled ? api.queues.summary : "skip", queueArgs)
useQuery(queuesEnabled ? api.queues.summary : "skip", queueArgs) as TicketQueueSummary[] | undefined const queues: TicketQueueSummary[] = Array.isArray(queuesResult) ? queuesResult : []
) ?? []
const { categories, isLoading: categoriesLoading } = useTicketCategories(ticket.tenantId) const { categories, isLoading: categoriesLoading } = useTicketCategories(ticket.tenantId)
const workSummaryRemote = useQuery( const workSummaryArgs = convexUserId
api.tickets.workSummary,
convexUserId
? { ticketId: ticket.id as Id<"tickets">, viewerId: convexUserId as Id<"users"> } ? { ticketId: ticket.id as Id<"tickets">, viewerId: convexUserId as Id<"users"> }
: "skip" : undefined
const workSummaryRemote = useQuery(
convexUserId ? api.tickets.workSummary : "skip",
workSummaryArgs
) as ) as
| { | {
ticketId: Id<"tickets"> ticketId: Id<"tickets">

View file

@ -65,14 +65,11 @@ export function TicketsView({ initialFilters }: TicketsViewProps = {}) {
useDefaultQueues(tenantId) useDefaultQueues(tenantId)
const queuesEnabled = Boolean(isStaff && convexUserId) const queuesEnabled = Boolean(isStaff && convexUserId)
const queues = useQuery( const queueArgs = queuesEnabled ? { tenantId, viewerId: convexUserId as Id<"users"> } : undefined
queuesEnabled ? api.queues.summary : "skip", const queuesResult = useQuery(queuesEnabled ? api.queues.summary : "skip", queueArgs)
queuesEnabled ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip" const queues: TicketQueueSummary[] = Array.isArray(queuesResult) ? queuesResult : []
) as TicketQueueSummary[] | undefined
const agents = useQuery(api.users.listAgents, { tenantId }) as { _id: string; name: string }[] | undefined const agents = useQuery(api.users.listAgents, { tenantId }) as { _id: string; name: string }[] | undefined
const ticketsRaw = useQuery( const ticketsArgs = convexUserId
api.tickets.list,
convexUserId
? { ? {
tenantId, tenantId,
viewerId: convexUserId as Id<"users">, viewerId: convexUserId as Id<"users">,
@ -83,10 +80,13 @@ export function TicketsView({ initialFilters }: TicketsViewProps = {}) {
assigneeId: filters.assigneeId ? (filters.assigneeId as unknown as Id<"users">) : undefined, assigneeId: filters.assigneeId ? (filters.assigneeId as unknown as Id<"users">) : undefined,
search: filters.search || undefined, search: filters.search || undefined,
} }
: "skip" : undefined
) const ticketsRaw = useQuery(convexUserId ? api.tickets.list : "skip", ticketsArgs)
const tickets = useMemo(() => mapTicketsFromServerList((ticketsRaw ?? []) as unknown[]), [ticketsRaw]) const tickets = useMemo(
() => mapTicketsFromServerList(Array.isArray(ticketsRaw) ? (ticketsRaw as unknown[]) : []),
[ticketsRaw]
)
const [companies, setCompanies] = useState<string[]>([]) const [companies, setCompanies] = useState<string[]>([])
useEffect(() => { useEffect(() => {
let aborted = false let aborted = false
@ -190,7 +190,7 @@ export function TicketsView({ initialFilters }: TicketsViewProps = {}) {
<div className="flex flex-col gap-6 px-4 lg:px-6"> <div className="flex flex-col gap-6 px-4 lg:px-6">
<TicketsFilters <TicketsFilters
onChange={setFilters} onChange={setFilters}
queues={(queues ?? []).map((q) => q.name)} queues={queues.map((q) => q.name)}
companies={companies} companies={companies}
assignees={(agents ?? []).map((a) => ({ id: a._id, name: a.name }))} assignees={(agents ?? []).map((a) => ({ id: a._id, name: a.name }))}
initialState={mergedInitialFilters} initialState={mergedInitialFilters}