Skip to content

Commit bdaa66a

Browse files
authored
Merge pull request #361 from GTBitsOfGood/Andrew/355-search-chat-tickets-by-name-or-message-content
Andrew/Search Chat Tickets By Name or Message Content - #355
2 parents 234ddb6 + 02bde56 commit bdaa66a

6 files changed

Lines changed: 188 additions & 13 deletions

File tree

‎src/app/api/tickets/route.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,8 @@ export async function POST(req: Request) {
9999
partnerName!,
100100
session!.user.streamUserId!,
101101
streamUsers,
102-
extraData
102+
extraData,
103+
session.user.name ?? undefined
103104
);
104105

105106
return NextResponse.json({ channelId: channel.id });
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { auth } from "@/auth";
2+
import StreamIoService from "@/services/streamIoService";
3+
import UserService from "@/services/userService";
4+
import {
5+
AuthenticationError,
6+
ArgumentError,
7+
errorResponse,
8+
} from "@/util/errors";
9+
import { NextRequest, NextResponse } from "next/server";
10+
11+
export async function GET(request: NextRequest) {
12+
try {
13+
const session = await auth();
14+
if (!session?.user) {
15+
throw new AuthenticationError("Session required");
16+
}
17+
18+
const q = request.nextUrl.searchParams.get("q")?.trim() ?? "";
19+
if (!q) {
20+
return NextResponse.json({ cids: [] });
21+
}
22+
23+
if (UserService.isPartner(session.user)) {
24+
if (!session.user.streamUserId) {
25+
throw new ArgumentError("Account not linked to chat");
26+
}
27+
const cids = await StreamIoService.searchMessageCids(
28+
session.user.streamUserId,
29+
q
30+
);
31+
return NextResponse.json({ cids });
32+
}
33+
34+
UserService.checkPermission(session.user, "supportRead");
35+
if (!session.user.streamUserId) {
36+
throw new ArgumentError("Account not linked to chat");
37+
}
38+
const cids = await StreamIoService.searchMessageCids(
39+
session.user.streamUserId,
40+
q
41+
);
42+
return NextResponse.json({ cids });
43+
} catch (error) {
44+
return errorResponse(error);
45+
}
46+
}

‎src/components/tickets/TicketSearchBar.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ export default function TicketSearchBar({
3333
/>
3434
<input
3535
type="text"
36-
placeholder="Search tickets"
36+
placeholder="Search tickets, names, messages"
3737
value={inputValue}
3838
onChange={(e) => setInputValue(e.target.value)}
3939
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"

‎src/screens/SupportScreen.tsx‎

Lines changed: 103 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,10 @@ export default function SupportScreen({
260260
streamUserId,
261261
}: SupportScreenProps) {
262262
const { client, setActiveChannel, channel: activeChannel } = useChatContext();
263+
const [messageMatchCids, setMessageMatchCids] = useState<Set<string>>(
264+
new Set()
265+
);
266+
const [messageSearchPending, setMessageSearchPending] = useState(false);
263267
const [isSupportTutorialActive, setIsSupportTutorialActive] = useState(false);
264268
const [hasSupportTutorialEnded, setHasSupportTutorialEnded] = useState(false);
265269
const [activeTutorialStep, setActiveTutorialStep] = useState<number | null>(
@@ -598,18 +602,97 @@ export default function SupportScreen({
598602
filters.closed = { $eq: true };
599603
}
600604

601-
if (searchQuery.trim()) {
602-
filters.name = { $autocomplete: searchQuery };
605+
const trimmedQuery = searchQuery.trim();
606+
607+
useEffect(() => {
608+
if (!trimmedQuery) {
609+
setMessageMatchCids(new Set());
610+
setMessageSearchPending(false);
611+
return;
612+
}
613+
setMessageSearchPending(true);
614+
let cancelled = false;
615+
const timer = setTimeout(async () => {
616+
try {
617+
const res = await fetch(
618+
`/api/tickets/search?q=${encodeURIComponent(trimmedQuery)}`,
619+
{ cache: "no-store" }
620+
);
621+
if (!res.ok) throw new Error(await res.text());
622+
const data = (await res.json()) as { cids?: unknown };
623+
if (cancelled) return;
624+
const cids = new Set<string>();
625+
if (Array.isArray(data.cids)) {
626+
for (const cid of data.cids) {
627+
if (typeof cid === "string" && cid) cids.add(cid);
628+
}
629+
}
630+
setMessageMatchCids(cids);
631+
} catch {
632+
if (!cancelled) setMessageMatchCids(new Set());
633+
} finally {
634+
if (!cancelled) setMessageSearchPending(false);
635+
}
636+
}, 350);
637+
return () => {
638+
cancelled = true;
639+
clearTimeout(timer);
640+
};
641+
}, [trimmedQuery]);
642+
643+
if (trimmedQuery) {
644+
filters.$or = [
645+
{ name: { $autocomplete: trimmedQuery } },
646+
{ partnerName: { $autocomplete: trimmedQuery } },
647+
{ createdByName: { $autocomplete: trimmedQuery } },
648+
...(messageMatchCids.size > 0
649+
? [{ cid: { $in: [...messageMatchCids] } }]
650+
: []),
651+
];
603652
}
604653

605654
const channelRenderFilterFn = (channels: ChannelType[]) => {
655+
const q = searchQuery.trim().toLowerCase();
606656
return channels.filter((channel) => {
607657
const channelData = channel.data as ExtraChannelData;
608-
if (activeTab === "Unresolved") {
609-
return channelData?.closed !== true;
610-
} else {
611-
return channelData?.closed === true;
658+
const isOpen = channelData?.closed !== true;
659+
const matchesTab =
660+
activeTab === "Unresolved" ? isOpen : !isOpen;
661+
if (!matchesTab) {
662+
return false;
663+
}
664+
if (!q) {
665+
return true;
666+
}
667+
const title = String(channelData?.name ?? "").toLowerCase();
668+
const partner = String(
669+
channelData?.partnerName ?? ""
670+
).toLowerCase();
671+
const creator = String(
672+
channelData?.createdByName ?? ""
673+
).toLowerCase();
674+
if (title.includes(q) || partner.includes(q) || creator.includes(q)) {
675+
return true;
676+
}
677+
const members = Object.values(channel.state?.members ?? {});
678+
for (const member of members) {
679+
const memberName = String(
680+
(member as { user?: { name?: unknown } })?.user?.name ?? ""
681+
).toLowerCase();
682+
if (memberName && memberName.includes(q)) {
683+
return true;
684+
}
685+
}
686+
const lastText = String(
687+
channel.lastMessage?.()?.text ?? ""
688+
).toLowerCase();
689+
if (lastText && lastText.includes(q)) {
690+
return true;
691+
}
692+
if (messageMatchCids.has(channel.cid)) {
693+
return true;
612694
}
695+
return false;
613696
});
614697
};
615698

@@ -647,17 +730,27 @@ export default function SupportScreen({
647730
]}
648731
Preview={ChannelPreviewWrapper}
649732
Paginator={InfiniteScroll}
650-
EmptyStateIndicator={() =>
651-
isSupportTutorialActive ? null : (
733+
EmptyStateIndicator={() => {
734+
if (isSupportTutorialActive) return null;
735+
if (messageSearchPending && trimmedQuery) {
736+
return (
737+
<div className="w-full flex flex-col justify-center gap-2 my-8">
738+
<p className="text-center text-gray-primary/60">
739+
Searching messages…
740+
</p>
741+
</div>
742+
);
743+
}
744+
return (
652745
<div className="w-full flex flex-col justify-center gap-2 my-8">
653746
<ChatCircleSlash
654747
size={48}
655748
className="mx-auto text-gray-primary/40"
656749
/>
657750
<p className="text-center">No tickets found.</p>
658751
</div>
659-
)
660-
}
752+
);
753+
}}
661754
/>
662755
</div>
663756
</div>

‎src/services/streamIoService.ts‎

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,8 @@ export default class StreamIoService {
110110
partnerName: string,
111111
createdById: string,
112112
memberIds: string[],
113-
extraData: ChannelData
113+
extraData: ChannelData,
114+
createdByName?: string
114115
) {
115116
const channelId = this.channelNameToId(channelName);
116117
const channel = StreamIoService.client.channel("ticket", channelId, {
@@ -119,6 +120,7 @@ export default class StreamIoService {
119120
name: channelName,
120121
image: channelImage,
121122
partnerName,
123+
...(createdByName ? { createdByName } : {}),
122124
...extraData,
123125
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
124126

@@ -187,6 +189,38 @@ export default class StreamIoService {
187189
return response.members.map(member => member.user_id).filter(Boolean) as string[];
188190
}
189191

192+
static async searchMessageCids(
193+
memberStreamUserId: string,
194+
query: string,
195+
distinctLimit = 20,
196+
pageSize = 20,
197+
maxPages = 3
198+
): Promise<string[]> {
199+
const cids = new Set<string>();
200+
let offset = 0;
201+
for (let page = 0; page < maxPages; page++) {
202+
const res = await StreamIoService.client.search(
203+
{ members: { $in: [memberStreamUserId] }, type: "ticket" },
204+
{ text: { $autocomplete: query } },
205+
{ limit: pageSize, offset }
206+
);
207+
for (const hit of res.results ?? []) {
208+
const cid =
209+
(hit.message as { cid?: unknown })?.cid ??
210+
(hit.message as { channel_cid?: unknown })?.channel_cid;
211+
if (typeof cid === "string" && cid) cids.add(cid);
212+
if (cids.size >= distinctLimit) break;
213+
}
214+
if (cids.size >= distinctLimit) break;
215+
if (!res.results || res.results.length < pageSize) break;
216+
if ((res as { next?: string }).next) {
217+
// If Stream returns a cursor, prefer it - but offset fallback is safe
218+
}
219+
offset += res.results.length;
220+
}
221+
return [...cids].slice(0, distinctLimit);
222+
}
223+
190224
static async getChannelData(channelId: string): Promise<{
191225
name: string;
192226
partnerName: string;

‎src/types/api/streamio.types.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
export type ExtraChannelData = {
22
name: string;
33
partnerName: string;
4+
createdByName?: string;
45
image: string;
56
closed: boolean;
67
};

0 commit comments

Comments
 (0)