'use client';
import { useState, useEffect, useMemo, useCallback } from 'react';
import { useApolloClient } from '@apollo/client';
import { gql } from '@apollo/client';
import type { MentionSuggestion, MentionEntityType } from '@/components/mentions';
/**
* GraphQL queries for mention search
* Updated to match actual schema fields
*/
// Use mps query with filter (fallback when fulltext index not available)
const SEARCH_MPS_FOR_MENTION = gql`
query SearchMPsForMention($limit: Int) {
mps(
where: { current: true }
options: { limit: $limit, sort: [{ name: ASC }] }
) {
id
name
party
riding
photo_url
}
}
`;
// Bill search - use number instead of id (Bill type doesn't have id field)
const SEARCH_BILLS_FOR_MENTION = gql`
query SearchBillsForMention($searchTerm: String, $limit: Int) {
searchBills(searchTerm: $searchTerm, limit: $limit) {
number
session
title
title_fr
status
status_fr
}
}
`;
// Committee search - simple query, filter client-side
const SEARCH_COMMITTEES_FOR_MENTION = gql`
query SearchCommitteesForMention($limit: Int) {
committees(
options: { limit: $limit, sort: [{ name: ASC }] }
) {
code
name
chamber
}
}
`;
// Debates - unchanged
const GET_RECENT_DEBATES_FOR_MENTION = gql`
query GetRecentDebatesForMention($limit: Int) {
documents(
options: { limit: $limit, sort: [{ date: DESC }] }
) {
id
date
number
session_id
}
}
`;
// Votes - use parliament_number and session_number instead of session
const SEARCH_VOTES_FOR_MENTION = gql`
query SearchVotesForMention($limit: Int) {
votes(
options: { limit: $limit, sort: [{ date: DESC }] }
) {
id
vote_number
date
result
description
bill_number
parliament_number
session_number
}
}
`;
interface UseMentionSearchOptions {
query: string;
types?: MentionEntityType[];
locale?: string;
maxResults?: number;
debounceMs?: number;
}
interface UseMentionSearchResult {
suggestions: MentionSuggestion[];
loading: boolean;
error: Error | null;
}
/**
* Hook for searching entities to mention
* Queries multiple entity types and returns unified suggestions
*/
export function useMentionSearch({
query,
types = ['bill', 'mp', 'committee', 'vote', 'debate'],
locale = 'en',
maxResults = 8,
debounceMs = 200,
}: UseMentionSearchOptions): UseMentionSearchResult {
const client = useApolloClient();
const [suggestions, setSuggestions] = useState<MentionSuggestion[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
// Parse query to extract type prefix
const { searchType, searchTerm } = useMemo(() => {
const cleanQuery = query.startsWith('@') ? query.slice(1) : query;
const colonIndex = cleanQuery.indexOf(':');
if (colonIndex > 0) {
const potentialType = cleanQuery.slice(0, colonIndex).toLowerCase();
const validTypes: MentionEntityType[] = ['bill', 'mp', 'committee', 'vote', 'debate', 'petition', 'user'];
if (validTypes.includes(potentialType as MentionEntityType)) {
return {
searchType: potentialType as MentionEntityType,
searchTerm: cleanQuery.slice(colonIndex + 1),
};
}
}
return { searchType: null, searchTerm: cleanQuery };
}, [query]);
// Determine which types to search
const typesToSearch = useMemo(() => {
if (searchType) return [searchType];
return types;
}, [searchType, types]);
// Search function
const performSearch = useCallback(async () => {
if (!searchTerm || searchTerm.length < 1) {
setSuggestions([]);
return;
}
setLoading(true);
setError(null);
try {
const results: MentionSuggestion[] = [];
const perTypeLimit = Math.ceil(maxResults / typesToSearch.length);
const searchTermLower = searchTerm.toLowerCase();
// Search MPs - fetch and filter client-side by name
if (typesToSearch.includes('mp')) {
try {
const { data } = await client.query({
query: SEARCH_MPS_FOR_MENTION,
variables: { limit: 100 }, // Fetch more to filter
fetchPolicy: 'cache-first',
});
// Client-side filter by name
const filteredMPs = data?.mps
?.filter((mp: any) => mp.name?.toLowerCase().includes(searchTermLower))
?.slice(0, perTypeLimit);
filteredMPs?.forEach((mp: any) => {
results.push({
type: 'mp',
id: mp.id,
label: mp.name,
secondary: `${mp.riding} - ${mp.party}`,
mentionString: `@mp:${mp.id}`,
url: `/${locale}/mps/${mp.id}`,
metadata: { party: mp.party, photo_url: mp.photo_url },
});
});
} catch (e) {
console.warn('MP search failed:', e);
}
}
// Search Bills
if (typesToSearch.includes('bill')) {
try {
const { data } = await client.query({
query: SEARCH_BILLS_FOR_MENTION,
variables: { searchTerm, limit: perTypeLimit },
fetchPolicy: 'cache-first',
});
data?.searchBills?.forEach((bill: any) => {
const title = locale === 'fr' && bill.title_fr ? bill.title_fr : bill.title;
const status = locale === 'fr' && bill.status_fr ? bill.status_fr : bill.status;
results.push({
type: 'bill',
id: bill.number,
label: `Bill ${bill.number.toUpperCase()}`,
secondary: title || status,
mentionString: `@bill:${bill.number.toLowerCase()}`,
url: `/${locale}/bills/${bill.session}/${bill.number}`,
metadata: { session: bill.session, status },
});
});
} catch (e) {
console.warn('Bill search failed:', e);
}
}
// Search Committees - fetch and filter client-side
if (typesToSearch.includes('committee')) {
try {
const { data } = await client.query({
query: SEARCH_COMMITTEES_FOR_MENTION,
variables: { limit: 50 }, // Fetch more to filter
fetchPolicy: 'cache-first',
});
// Client-side filter by code or name
const searchUpper = searchTerm.toUpperCase();
const filteredCommittees = data?.committees
?.filter((c: any) =>
c.code?.includes(searchUpper) ||
c.name?.toLowerCase().includes(searchTermLower)
)
?.slice(0, perTypeLimit);
filteredCommittees?.forEach((committee: any) => {
results.push({
type: 'committee',
id: committee.code,
label: committee.code,
secondary: committee.name,
mentionString: `@committee:${committee.code.toLowerCase()}`,
url: `/${locale}/committees/${committee.code.toLowerCase()}`,
metadata: { chamber: committee.chamber },
});
});
} catch (e) {
console.warn('Committee search failed:', e);
}
}
// Search Votes - fetch and filter client-side
if (typesToSearch.includes('vote')) {
try {
const { data } = await client.query({
query: SEARCH_VOTES_FOR_MENTION,
variables: { limit: 50 }, // Fetch more to filter
fetchPolicy: 'cache-first',
});
// Client-side filter by description or bill_number
const filteredVotes = data?.votes
?.filter((v: any) =>
v.description?.toLowerCase().includes(searchTermLower) ||
v.bill_number?.toLowerCase().includes(searchTermLower)
)
?.slice(0, perTypeLimit);
filteredVotes?.forEach((vote: any) => {
// Build session string from parliament_number and session_number
const session = vote.parliament_number && vote.session_number
? `${vote.parliament_number}-${vote.session_number}`
: '45-1'; // Default fallback
const voteId = `${session}-${vote.vote_number}`;
results.push({
type: 'vote',
id: voteId,
label: `Vote #${vote.vote_number}`,
secondary: `${vote.description || vote.bill_number || ''} - ${vote.result}`,
mentionString: `@vote:${voteId}`,
url: `/${locale}/votes/${session}/${vote.vote_number}`,
metadata: { date: vote.date, result: vote.result },
});
});
} catch (e) {
console.warn('Vote search failed:', e);
}
}
// Get recent debates (no text search, just recent)
if (typesToSearch.includes('debate') && searchTerm.match(/^\d{4}-\d{2}|\d{4}$/)) {
try {
const { data } = await client.query({
query: GET_RECENT_DEBATES_FOR_MENTION,
variables: { limit: perTypeLimit },
fetchPolicy: 'cache-first',
});
data?.documents
?.filter((doc: any) => doc.date?.includes(searchTerm))
?.forEach((doc: any) => {
results.push({
type: 'debate',
id: doc.date,
label: doc.date,
secondary: `Sitting #${doc.number}`,
mentionString: `@debate:${doc.date}`,
url: `/${locale}/debates/${doc.date}`,
metadata: { session_id: doc.session_id },
});
});
} catch (e) {
console.warn('Debate search failed:', e);
}
}
// Search Users (from Supabase via API)
if (typesToSearch.includes('user')) {
try {
const response = await fetch(`/api/users/search?q=${encodeURIComponent(searchTerm)}&limit=${perTypeLimit}`);
if (response.ok) {
const { users } = await response.json();
users?.forEach((user: { username: string; display_name: string | null; avatar_url: string | null }) => {
results.push({
type: 'user',
id: user.username,
label: user.display_name || `@${user.username}`,
secondary: user.display_name ? `@${user.username}` : undefined,
mentionString: `@${user.username}`,
url: `/${locale}/profile/${user.username}`,
metadata: { avatar_url: user.avatar_url },
});
});
}
} catch (e) {
console.warn('User search failed:', e);
}
}
// Sort by relevance (exact matches first, then by type priority)
const typePriority: Record<MentionEntityType, number> = {
user: 1, // Users first for @username mentions
bill: 2,
mp: 3,
committee: 4,
vote: 5,
debate: 6,
petition: 7,
};
results.sort((a, b) => {
// Exact ID match first
const aExact = a.id.toLowerCase() === searchTerm.toLowerCase();
const bExact = b.id.toLowerCase() === searchTerm.toLowerCase();
if (aExact && !bExact) return -1;
if (!aExact && bExact) return 1;
// Then by type priority
return typePriority[a.type] - typePriority[b.type];
});
setSuggestions(results.slice(0, maxResults));
} catch (e) {
setError(e instanceof Error ? e : new Error('Search failed'));
} finally {
setLoading(false);
}
}, [client, searchTerm, typesToSearch, locale, maxResults]);
// Debounced search effect
useEffect(() => {
const timer = setTimeout(performSearch, debounceMs);
return () => clearTimeout(timer);
}, [performSearch, debounceMs]);
return { suggestions, loading, error };
}
export default useMentionSearch;