/**
* GraphQL server setup with GraphQL Yoga and @neo4j/graphql
*/
import { createYoga } from 'graphql-yoga';
import { Neo4jGraphQL } from '@neo4j/graphql';
import { createServer } from 'http';
import neo4j from 'neo4j-driver';
import { typeDefs } from './schema.js';
import { getDriver } from './neo4j.js';
import { config } from './config.js';
import { fetchMPNews } from './utils/newsFetcher.js';
import { queryCache, createCacheKey } from './utils/cache.js';
import { validateLimit, DEFAULT_LIMITS } from './utils/validation.js';
import { initializeAPIKeys, authenticateRequest, type AuthContext } from './utils/auth.js';
import { checkRateLimit, formatResetTime } from './utils/rateLimiter.js';
export interface ServerContext {
req: Request;
auth: AuthContext;
}
// Scheduled meetings are now stored in Neo4j via data pipeline
// (see: packages/data-pipeline/run_scheduled_meetings_ingestion.py)
/**
* Create GraphQL schema with Neo4j integration
*/
export function createGraphQLSchema() {
const driver = getDriver();
const neoSchema = new Neo4jGraphQL({
typeDefs,
driver,
resolvers: {
// Field resolver to handle Neo4j integer objects for statute_chapter
Bill: {
statute_chapter: (parent: any) => {
const value = parent.statute_chapter;
if (value === null || value === undefined) {
return null;
}
// Handle Neo4j integer objects { low, high }
if (typeof value === 'object' && 'low' in value) {
return String(value.low);
}
// Already a string or number
return String(value);
},
},
Query: {
mpNews: async (_parent: unknown, args: { mpName: string; limit?: number }) => {
const { mpName } = args;
const limit = validateLimit(args.limit, DEFAULT_LIMITS.top);
// Create cache key with validated limit
const cacheKey = createCacheKey('mpNews', { mpName, limit });
const cached = queryCache.get(cacheKey);
if (cached) {
return cached;
}
const news = await fetchMPNews(mpName, limit);
// Cache for 5 minutes (300 seconds)
queryCache.set(cacheKey, news, 300);
return news;
},
// Cached randomMPs query (5 minute TTL)
randomMPs: async (_parent: unknown, args: { limit?: number; parties?: string[] }, context: any) => {
// Validate limit to prevent DoS attacks (max 1000)
const validatedLimit = validateLimit(args.limit, DEFAULT_LIMITS.random);
// Create cache key with validated limit to prevent cache pollution
const cacheKey = createCacheKey('randomMPs', { limit: validatedLimit, parties: args.parties });
const cached = queryCache.get(cacheKey);
if (cached) {
return cached;
}
// Execute the Cypher query directly
const session = driver.session();
try {
// Convert to Neo4j integer type to ensure proper type handling
const limit = neo4j.int(validatedLimit);
const result = await session.run(
`
MATCH (mp:MP)
WHERE mp.current = true
AND ($parties IS NULL OR size($parties) = 0 OR mp.party IN $parties)
WITH mp, rand() AS r
ORDER BY r
LIMIT $limit
RETURN mp
`,
{ limit, parties: args.parties || null }
);
const mps = result.records.map(record => record.get('mp').properties);
// Cache for 5 minutes (300 seconds)
queryCache.set(cacheKey, mps, 300);
return mps;
} finally {
await session.close();
}
},
// Cached topSpenders query (1 hour TTL)
topSpenders: async (_parent: unknown, args: { fiscalYear?: number; limit?: number }, context: any) => {
// Validate limit to prevent DoS attacks (max 1000)
const validatedLimit = validateLimit(args.limit, DEFAULT_LIMITS.top);
// Create cache key with validated limit to prevent cache pollution
const cacheKey = createCacheKey('topSpenders', { fiscalYear: args.fiscalYear, limit: validatedLimit });
const cached = queryCache.get(cacheKey);
if (cached) {
return cached;
}
// Execute the Cypher query directly
const session = driver.session();
try {
// Convert to Neo4j integer type to ensure proper type handling
const limit = neo4j.int(validatedLimit);
const fiscalYear = args.fiscalYear ? neo4j.int(Math.floor(args.fiscalYear)) : null;
const result = await session.run(
`
MATCH (mp:MP)-[:INCURRED]->(e:Expense)
WHERE $fiscalYear IS NULL OR e.fiscal_year = $fiscalYear
WITH mp, sum(e.amount) AS total_expenses
RETURN {
mp: properties(mp),
total_expenses: total_expenses
} AS summary
ORDER BY total_expenses DESC
LIMIT $limit
`,
{ fiscalYear, limit }
);
const summaries = result.records.map(record => {
const summary = record.get('summary');
return {
mp: summary.mp,
total_expenses: summary.total_expenses
};
});
// Cache for 1 hour (3600 seconds)
queryCache.set(cacheKey, summaries, 3600);
return summaries;
} finally {
await session.close();
}
},
// Custom resolver for calendar data with scheduled meetings (optimized - all from Neo4j)
debatesCalendarData: async (_parent: unknown, args: { startDate: string; endDate: string }, context: any) => {
const { startDate, endDate } = args;
const session = driver.session();
try {
// Combined query: fetch both historical debates and scheduled meetings from Neo4j
const result = await session.run(
`
// 1. Get historical debate data from Documents
MATCH (d:Document)
WHERE d.public = true
AND d.date >= $startDate
AND d.date <= $endDate
OPTIONAL MATCH (d)<-[:PART_OF]-(s:Statement)
WITH d,
ANY(stmt IN collect(s.h1_en) WHERE stmt CONTAINS 'Oral Question' OR stmt CONTAINS 'Question Period') AS has_qp_statements
WITH d.date AS debate_date,
collect({
doc_type: d.document_type,
has_qp: has_qp_statements
}) AS docs
WITH debate_date,
ANY(doc IN docs WHERE doc.doc_type = 'D' AND NOT doc.has_qp) AS hasHouseDebates,
ANY(doc IN docs WHERE doc.doc_type = 'D' AND doc.has_qp) AS hasQuestionPeriod,
ANY(doc IN docs WHERE doc.doc_type = 'E') AS hasCommittee
WHERE hasHouseDebates OR hasQuestionPeriod OR hasCommittee
WITH collect({
date: debate_date,
hasHouseDebates: hasHouseDebates,
hasQuestionPeriod: hasQuestionPeriod,
hasCommittee: hasCommittee
}) AS documentData
// 2. Get scheduled meetings from Meeting nodes (has_evidence = false)
OPTIONAL MATCH (m:Meeting)
WHERE m.has_evidence = false
AND m.date >= date($startDate)
AND m.date <= date($endDate)
OPTIONAL MATCH (c:Committee)-[:HELD_MEETING]->(m)
WITH documentData,
collect(CASE WHEN m IS NOT NULL THEN {
date: toString(m.date),
committee_code: m.committee_code,
committee_name: COALESCE(c.short_name, c.name, m.committee_code),
number: m.number,
in_camera: m.in_camera
} END) AS meetingData
RETURN documentData, meetingData
`,
{ startDate, endDate }
);
if (!result.records.length) {
return [];
}
const record = result.records[0];
const documentData = record.get('documentData') || [];
const meetingData = (record.get('meetingData') || []).filter((m: any) => m !== null);
// Convert document data to map
const neo4jDataMap = new Map<string, any>();
documentData.forEach((d: any) => {
neo4jDataMap.set(d.date, {
date: d.date,
hasHouseDebates: d.hasHouseDebates,
hasQuestionPeriod: d.hasQuestionPeriod,
hasCommittee: d.hasCommittee,
});
});
// Group scheduled meetings by date
const meetingsByDate = new Map<string, any[]>();
meetingData.forEach((meeting: any) => {
const date = meeting.date;
if (!meetingsByDate.has(date)) {
meetingsByDate.set(date, []);
}
meetingsByDate.get(date)!.push({
committee_code: meeting.committee_code,
committee_name: meeting.committee_name,
number: meeting.number,
in_camera: meeting.in_camera,
});
});
// Merge document data with scheduled meetings
const allDates = new Set([...neo4jDataMap.keys(), ...meetingsByDate.keys()]);
const mergedData = Array.from(allDates).map(date => {
const neo4jData = neo4jDataMap.get(date) || {
date,
hasHouseDebates: false,
hasQuestionPeriod: false,
hasCommittee: false,
};
const scheduled = meetingsByDate.get(date) || [];
return {
date,
hasHouseDebates: neo4jData.hasHouseDebates,
hasQuestionPeriod: neo4jData.hasQuestionPeriod,
hasCommittee: neo4jData.hasCommittee,
hasScheduledMeeting: scheduled.length > 0,
scheduledMeetings: scheduled,
};
});
// Sort by date
mergedData.sort((a, b) => a.date.localeCompare(b.date));
return mergedData;
} catch (error) {
console.error('Error in debatesCalendarData resolver:', error);
return [];
} finally {
await session.close();
}
},
},
},
features: {
authorization: {
key: config.auth.jwtSecret,
},
},
});
return neoSchema;
}
/**
* Create GraphQL Yoga server
*/
export async function createGraphQLServer() {
console.log('๐ Creating GraphQL server...');
console.log(`๐ CORS Origins (type: ${typeof config.cors.origins}, value:`, config.cors.origins);
// Initialize API keys from environment variables
initializeAPIKeys();
const neoSchema = createGraphQLSchema();
const schema = await neoSchema.getSchema();
const yoga = createYoga<ServerContext>({
schema,
context: async ({ request }) => {
// Authenticate request
const auth = await authenticateRequest(request);
// Enforce authentication if required
if (config.auth.required && !auth.authenticated) {
throw new Error(
'Authentication required. Provide a valid API key via X-API-Key header or Authorization: Bearer header.'
);
}
// Check rate limit
const rateLimit = checkRateLimit(auth);
if (!rateLimit.allowed) {
throw new Error(
`Rate limit exceeded. Try again in ${formatResetTime(rateLimit.resetTime)}. ` +
`Limit: ${rateLimit.limit} requests/hour`
);
}
return { req: request, auth };
},
graphqlEndpoint: '/graphql',
landingPage: config.graphql.playground,
graphiql: config.graphql.playground
? {
title: 'CanadaGPT GraphQL API',
defaultQuery: `# Welcome to CanadaGPT GraphQL API
#
# Example queries:
# 1. List MPs with pagination
query ListMPs {
mPs(options: { limit: 10, sort: [{ name: ASC }] }) {
id
name
party
riding
current
}
}
# 2. Get MP with relationships
query GetMP {
mPs(where: { name: "Pierre Poilievre" }) {
id
name
party
riding
memberOf {
name
code
}
represents {
name
province
}
sponsored {
number
title
status
}
}
}
# 3. MP Performance Scorecard
query MPScorecard {
mpScorecard(mpId: "pierre-poilievre") {
mp {
name
party
}
bills_sponsored
bills_passed
votes_participated
legislative_effectiveness
lobbyist_meetings
}
}
# 4. Top Spenders
query TopSpenders {
topSpenders(fiscalYear: 2025, limit: 10) {
mp {
name
party
}
total_expenses
}
}
# 5. Bill Lobbying Activity
query BillLobbying {
billLobbying(billNumber: "C-11", session: "44-1") {
bill {
title
status
}
organizations_lobbying
organizations {
name
industry
lobbying_count
}
}
}`,
}
: false,
cors: {
origin: config.cors.origins,
credentials: true,
},
maskedErrors: config.nodeEnv === 'production',
});
console.log('โ
GraphQL server created');
return yoga;
}
/**
* Start HTTP server
*/
export async function startServer() {
const yoga = await createGraphQLServer();
const server = createServer(yoga);
return new Promise<typeof server>((resolve, reject) => {
server.listen(config.server.port, config.server.host, () => {
console.log('');
console.log('โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ');
console.log('๐ CanadaGPT GraphQL API');
console.log('โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ');
console.log(`๐ก Server running at http://${config.server.host}:${config.server.port}/graphql`);
console.log(`๐ฎ GraphiQL: http://localhost:${config.server.port}/graphql`);
console.log(`๐ Environment: ${config.nodeEnv}`);
console.log('โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ');
console.log('');
resolve(server);
});
server.on('error', (error) => {
console.error('โ Server failed to start:', error);
reject(error);
});
});
}