From e1f739e294dfa2af0f16eb5cc2daf50d84d3e4c8 Mon Sep 17 00:00:00 2001 From: aandrx Date: Fri, 11 Sep 2026 15:29:29 -0400 Subject: [PATCH 1/2] search support tickets by creator name+message content --- src/app/api/tickets/route.ts | 3 +- src/app/api/tickets/search/route.ts | 46 +++++++++ src/components/tickets/TicketSearchBar.tsx | 2 +- src/screens/SupportScreen.tsx | 113 +++++++++++++++++++-- src/services/streamIoService.ts | 24 ++++- src/types/api/streamio.types.ts | 1 + 6 files changed, 176 insertions(+), 13 deletions(-) create mode 100644 src/app/api/tickets/search/route.ts diff --git a/src/app/api/tickets/route.ts b/src/app/api/tickets/route.ts index cef416e2..00e78d7e 100644 --- a/src/app/api/tickets/route.ts +++ b/src/app/api/tickets/route.ts @@ -99,7 +99,8 @@ export async function POST(req: Request) { partnerName!, session!.user.streamUserId!, streamUsers, - extraData + extraData, + session.user.name ?? undefined ); return NextResponse.json({ channelId: channel.id }); diff --git a/src/app/api/tickets/search/route.ts b/src/app/api/tickets/search/route.ts new file mode 100644 index 00000000..a95c1cc5 --- /dev/null +++ b/src/app/api/tickets/search/route.ts @@ -0,0 +1,46 @@ +import { auth } from "@/auth"; +import StreamIoService from "@/services/streamIoService"; +import UserService from "@/services/userService"; +import { + AuthenticationError, + ArgumentError, + errorResponse, +} from "@/util/errors"; +import { NextRequest, NextResponse } from "next/server"; + +export async function GET(request: NextRequest) { + try { + const session = await auth(); + if (!session?.user) { + throw new AuthenticationError("Session required"); + } + + const q = request.nextUrl.searchParams.get("q")?.trim() ?? ""; + if (!q) { + return NextResponse.json({ cids: [] }); + } + + if (UserService.isPartner(session.user)) { + if (!session.user.streamUserId) { + throw new ArgumentError("Account not linked to chat"); + } + const cids = await StreamIoService.searchMessageCids( + session.user.streamUserId, + q + ); + return NextResponse.json({ cids }); + } + + UserService.checkPermission(session.user, "supportRead"); + if (!session.user.streamUserId) { + throw new ArgumentError("Account not linked to chat"); + } + const cids = await StreamIoService.searchMessageCids( + session.user.streamUserId, + q + ); + return NextResponse.json({ cids }); + } catch (error) { + return errorResponse(error); + } +} diff --git a/src/components/tickets/TicketSearchBar.tsx b/src/components/tickets/TicketSearchBar.tsx index 37ebe0b9..e9208cc3 100644 --- a/src/components/tickets/TicketSearchBar.tsx +++ b/src/components/tickets/TicketSearchBar.tsx @@ -33,7 +33,7 @@ export default function TicketSearchBar({ /> setInputValue(e.target.value)} className="pl-10 pr-4 py-2 w-full border border-gray-primary/20 rounded-lg bg-sunken focus:outline-none focus:border-blue-primary/50" diff --git a/src/screens/SupportScreen.tsx b/src/screens/SupportScreen.tsx index 6ad71771..0a0055c7 100644 --- a/src/screens/SupportScreen.tsx +++ b/src/screens/SupportScreen.tsx @@ -260,6 +260,10 @@ export default function SupportScreen({ streamUserId, }: SupportScreenProps) { const { client, setActiveChannel, channel: activeChannel } = useChatContext(); + const [messageMatchCids, setMessageMatchCids] = useState>( + new Set() + ); + const [messageSearchPending, setMessageSearchPending] = useState(false); const [isSupportTutorialActive, setIsSupportTutorialActive] = useState(false); const [hasSupportTutorialEnded, setHasSupportTutorialEnded] = useState(false); const [activeTutorialStep, setActiveTutorialStep] = useState( @@ -598,18 +602,97 @@ export default function SupportScreen({ filters.closed = { $eq: true }; } - if (searchQuery.trim()) { - filters.name = { $autocomplete: searchQuery }; + const trimmedQuery = searchQuery.trim(); + + useEffect(() => { + if (!trimmedQuery) { + setMessageMatchCids(new Set()); + setMessageSearchPending(false); + return; + } + setMessageSearchPending(true); + let cancelled = false; + const timer = setTimeout(async () => { + try { + const res = await fetch( + `/api/tickets/search?q=${encodeURIComponent(trimmedQuery)}`, + { cache: "no-store" } + ); + if (!res.ok) throw new Error(await res.text()); + const data = (await res.json()) as { cids?: unknown }; + if (cancelled) return; + const cids = new Set(); + if (Array.isArray(data.cids)) { + for (const cid of data.cids) { + if (typeof cid === "string" && cid) cids.add(cid); + } + } + setMessageMatchCids(cids); + } catch { + if (!cancelled) setMessageMatchCids(new Set()); + } finally { + if (!cancelled) setMessageSearchPending(false); + } + }, 350); + return () => { + cancelled = true; + clearTimeout(timer); + }; + }, [trimmedQuery]); + + if (trimmedQuery) { + filters.$or = [ + { name: { $autocomplete: trimmedQuery } }, + { partnerName: { $autocomplete: trimmedQuery } }, + { createdByName: { $autocomplete: trimmedQuery } }, + ...(messageMatchCids.size > 0 + ? [{ cid: { $in: [...messageMatchCids] } }] + : []), + ]; } const channelRenderFilterFn = (channels: ChannelType[]) => { + const q = searchQuery.trim().toLowerCase(); return channels.filter((channel) => { const channelData = channel.data as ExtraChannelData; - if (activeTab === "Unresolved") { - return channelData?.closed !== true; - } else { - return channelData?.closed === true; + const isOpen = channelData?.closed !== true; + const matchesTab = + activeTab === "Unresolved" ? isOpen : !isOpen; + if (!matchesTab) { + return false; + } + if (!q) { + return true; + } + const title = String(channelData?.name ?? "").toLowerCase(); + const partner = String( + channelData?.partnerName ?? "" + ).toLowerCase(); + const creator = String( + channelData?.createdByName ?? "" + ).toLowerCase(); + if (title.includes(q) || partner.includes(q) || creator.includes(q)) { + return true; + } + const members = Object.values(channel.state?.members ?? {}); + for (const member of members) { + const memberName = String( + (member as { user?: { name?: unknown } })?.user?.name ?? "" + ).toLowerCase(); + if (memberName && memberName.includes(q)) { + return true; + } + } + const lastText = String( + channel.lastMessage?.()?.text ?? "" + ).toLowerCase(); + if (lastText && lastText.includes(q)) { + return true; + } + if (messageMatchCids.has(channel.cid)) { + return true; } + return false; }); }; @@ -647,8 +730,18 @@ export default function SupportScreen({ ]} Preview={ChannelPreviewWrapper} Paginator={InfiniteScroll} - EmptyStateIndicator={() => - isSupportTutorialActive ? null : ( + EmptyStateIndicator={() => { + if (isSupportTutorialActive) return null; + if (messageSearchPending && trimmedQuery) { + return ( +
+

+ Searching messages… +

+
+ ); + } + return (

No tickets found.

- ) - } + ); + }} /> diff --git a/src/services/streamIoService.ts b/src/services/streamIoService.ts index da96ff96..8e474614 100644 --- a/src/services/streamIoService.ts +++ b/src/services/streamIoService.ts @@ -110,7 +110,8 @@ export default class StreamIoService { partnerName: string, createdById: string, memberIds: string[], - extraData: ChannelData + extraData: ChannelData, + createdByName?: string ) { const channelId = this.channelNameToId(channelName); const channel = StreamIoService.client.channel("ticket", channelId, { @@ -119,6 +120,7 @@ export default class StreamIoService { name: channelName, image: channelImage, partnerName, + ...(createdByName ? { createdByName } : {}), ...extraData, } as any); // eslint-disable-line @typescript-eslint/no-explicit-any @@ -187,6 +189,26 @@ export default class StreamIoService { return response.members.map(member => member.user_id).filter(Boolean) as string[]; } + static async searchMessageCids( + memberStreamUserId: string, + query: string, + limit = 20 + ): Promise { + const res = await StreamIoService.client.search( + { members: { $in: [memberStreamUserId] }, type: "ticket" }, + { text: { $autocomplete: query } }, + { limit } + ); + const cids = new Set(); + for (const hit of res.results ?? []) { + const cid = + (hit.message as { cid?: unknown })?.cid ?? + (hit.message as { channel_cid?: unknown })?.channel_cid; + if (typeof cid === "string" && cid) cids.add(cid); + } + return [...cids]; + } + static async getChannelData(channelId: string): Promise<{ name: string; partnerName: string; diff --git a/src/types/api/streamio.types.ts b/src/types/api/streamio.types.ts index 0ba628e7..ce3b86f6 100644 --- a/src/types/api/streamio.types.ts +++ b/src/types/api/streamio.types.ts @@ -1,6 +1,7 @@ export type ExtraChannelData = { name: string; partnerName: string; + createdByName?: string; image: string; closed: boolean; }; From 02bde56e5ac36a43f0f923d6ec663fec2a1e99d1 Mon Sep 17 00:00:00 2001 From: aandrx Date: Sun, 13 Sep 2026 00:00:08 -0400 Subject: [PATCH 2/2] paginate message search to return distinct tickets not newest messages --- src/services/streamIoService.ts | 36 ++++++++++++++++++++++----------- 1 file changed, 24 insertions(+), 12 deletions(-) diff --git a/src/services/streamIoService.ts b/src/services/streamIoService.ts index 8e474614..9eeb4307 100644 --- a/src/services/streamIoService.ts +++ b/src/services/streamIoService.ts @@ -192,21 +192,33 @@ export default class StreamIoService { static async searchMessageCids( memberStreamUserId: string, query: string, - limit = 20 + distinctLimit = 20, + pageSize = 20, + maxPages = 3 ): Promise { - const res = await StreamIoService.client.search( - { members: { $in: [memberStreamUserId] }, type: "ticket" }, - { text: { $autocomplete: query } }, - { limit } - ); const cids = new Set(); - for (const hit of res.results ?? []) { - const cid = - (hit.message as { cid?: unknown })?.cid ?? - (hit.message as { channel_cid?: unknown })?.channel_cid; - if (typeof cid === "string" && cid) cids.add(cid); + let offset = 0; + for (let page = 0; page < maxPages; page++) { + const res = await StreamIoService.client.search( + { members: { $in: [memberStreamUserId] }, type: "ticket" }, + { text: { $autocomplete: query } }, + { limit: pageSize, offset } + ); + for (const hit of res.results ?? []) { + const cid = + (hit.message as { cid?: unknown })?.cid ?? + (hit.message as { channel_cid?: unknown })?.channel_cid; + if (typeof cid === "string" && cid) cids.add(cid); + if (cids.size >= distinctLimit) break; + } + if (cids.size >= distinctLimit) break; + if (!res.results || res.results.length < pageSize) break; + if ((res as { next?: string }).next) { + // If Stream returns a cursor, prefer it - but offset fallback is safe + } + offset += res.results.length; } - return [...cids]; + return [...cids].slice(0, distinctLimit); } static async getChannelData(channelId: string): Promise<{