Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/app/api/tickets/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
46 changes: 46 additions & 0 deletions src/app/api/tickets/search/route.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
2 changes: 1 addition & 1 deletion src/components/tickets/TicketSearchBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ export default function TicketSearchBar({
/>
<input
type="text"
placeholder="Search tickets"
placeholder="Search tickets, names, messages"
value={inputValue}
onChange={(e) => 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"
Expand Down
113 changes: 103 additions & 10 deletions src/screens/SupportScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,10 @@ export default function SupportScreen({
streamUserId,
}: SupportScreenProps) {
const { client, setActiveChannel, channel: activeChannel } = useChatContext();
const [messageMatchCids, setMessageMatchCids] = useState<Set<string>>(
new Set()
);
const [messageSearchPending, setMessageSearchPending] = useState(false);
const [isSupportTutorialActive, setIsSupportTutorialActive] = useState(false);
const [hasSupportTutorialEnded, setHasSupportTutorialEnded] = useState(false);
const [activeTutorialStep, setActiveTutorialStep] = useState<number | null>(
Expand Down Expand Up @@ -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<string>();
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;
});
};

Expand Down Expand Up @@ -647,17 +730,27 @@ export default function SupportScreen({
]}
Preview={ChannelPreviewWrapper}
Paginator={InfiniteScroll}
EmptyStateIndicator={() =>
isSupportTutorialActive ? null : (
EmptyStateIndicator={() => {
if (isSupportTutorialActive) return null;
if (messageSearchPending && trimmedQuery) {
return (
<div className="w-full flex flex-col justify-center gap-2 my-8">
<p className="text-center text-gray-primary/60">
Searching messages…
</p>
</div>
);
}
return (
<div className="w-full flex flex-col justify-center gap-2 my-8">
<ChatCircleSlash
size={48}
className="mx-auto text-gray-primary/40"
/>
<p className="text-center">No tickets found.</p>
</div>
)
}
);
}}
/>
</div>
</div>
Expand Down
36 changes: 35 additions & 1 deletion src/services/streamIoService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, {
Expand All @@ -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

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

static async searchMessageCids(
memberStreamUserId: string,
query: string,
distinctLimit = 20,
pageSize = 20,
maxPages = 3
): Promise<string[]> {
const cids = new Set<string>();
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].slice(0, distinctLimit);
}

static async getChannelData(channelId: string): Promise<{
name: string;
partnerName: string;
Expand Down
1 change: 1 addition & 0 deletions src/types/api/streamio.types.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export type ExtraChannelData = {
name: string;
partnerName: string;
createdByName?: string;
image: string;
closed: boolean;
};
Loading