import { Injectable, Logger, OnModuleInit } from "@nestjs/common"; import { PrismaService } from "../prisma/prisma.service"; /** * Graph Node Types */ type GraphNode = | { type: "guest"; id: string; data: any } | { type: "podcast"; id: string; data: any } | { type: "host"; id: string; data: any }; type GraphEdge = { source: string; target: string; type: "appeared_on" | "hosted" | "booked" | "knows" | "similar_to"; weight: number; metadata?: Record; }; /** * Social Graph Service * * Manages the "who knows who" graph for the Verified Guest Network. * Enables features like: * - "Podcasters who booked X also booked Y" * - Guest recommendations based on network proximity * - Trust paths between hosts and guests * * African Context: Emphasizes community connections and tech hub networks */ @Injectable() export class GraphService implements OnModuleInit { private readonly logger = new Logger(GraphService.name); // In-memory adjacency list for fast traversal // Backed by the graph_edges table for persistence across restarts private adjacencyList: Map> = new Map(); constructor(private readonly prisma: PrismaService) {} async onModuleInit() { await this.loadEdgesFromDatabase(); } /** * Load all graph edges from the database into memory on startup. */ private async loadEdgesFromDatabase(): Promise { try { const edges = await this.prisma.graphEdge.findMany(); for (const edge of edges) { const sourceId = edge.source; const targetId = edge.target; const storedEdge: GraphEdge = { source: edge.source, target: edge.target, type: edge.type as GraphEdge["type"], weight: edge.weight, metadata: edge.metadata as Record, }; if (!this.adjacencyList.has(sourceId)) { this.adjacencyList.set(sourceId, new Map()); } this.adjacencyList.get(sourceId)!.set(targetId, storedEdge); // Restore reverse edges for bidirectional types if (edge.type === "knows" || edge.type === "similar_to" || edge.type === "appeared_on") { const reverseEdge = { ...storedEdge, source: edge.target, target: edge.source }; if (!this.adjacencyList.has(edge.target)) { this.adjacencyList.set(edge.target, new Map()); } this.adjacencyList.get(edge.target)!.set(edge.source, reverseEdge); } // Restore node data from metadata if available if (edge.source.startsWith("guest:") || edge.source.startsWith("podcast:")) { const edges = this.adjacencyList.get(edge.source)!; if (edge.metadata && typeof edge.metadata === "object") { const meta = edge.metadata as Record; if (meta.nodeData) { (edges as any).nodeData = meta.nodeData; } } } } this.logger.log(`Loaded ${edges.length} graph edges from database`); } catch (error) { this.logger.warn("Failed to load graph edges from database, starting with empty graph", error); } } /** * Ingest a guest profile into the graph */ async ingestProfile(profileId: string): Promise { this.logger.log(`Ingesting profile ${profileId} into graph`); const profile = await this.prisma.guestPublicProfile.findUnique({ where: { id: profileId }, include: { expertiseTags: true, bookingHistory: true, }, }); if (!profile) { this.logger.warn(`Profile ${profileId} not found`); return; } // Add guest node this.addNode({ type: "guest", id: `guest:${profileId}`, data: { name: profile.headline, country: profile.country, expertise: profile.expertiseTags.map((t) => t.name), tier: profile.tier, }, }); // Add appeared_on edges from booking history for (const booking of profile.bookingHistory) { const podcastNodeId = `podcast:${booking.podcastName}`; // Add podcast node if not exists this.addNode({ type: "podcast", id: podcastNodeId, data: { name: booking.podcastName }, }); // Add appeared_on edge this.addEdge({ source: `guest:${profileId}`, target: podcastNodeId, type: "appeared_on", weight: 1, metadata: { date: booking.appearedAt, topics: booking.topicsDiscussed, }, }); } // Calculate similarity edges to other guests await this.calculateSimilarityEdges(profileId, profile.expertiseTags); this.logger.log(`Profile ${profileId} ingested successfully`); } /** * Add a node to the graph */ private addNode(node: GraphNode): void { if (!this.adjacencyList.has(node.id)) { this.adjacencyList.set(node.id, new Map()); } // Store node data in the adjacency list entry const edges = this.adjacencyList.get(node.id)!; (edges as any).nodeData = node.data; } /** * Add an edge to the graph */ private addEdge(edge: GraphEdge): void { // Add forward edge if (!this.adjacencyList.has(edge.source)) { this.adjacencyList.set(edge.source, new Map()); } this.adjacencyList.get(edge.source)!.set(edge.target, edge); // Add reverse edge for undirected relationships (including appeared_on) if (edge.type === "knows" || edge.type === "similar_to" || edge.type === "appeared_on") { const reverseEdge = { ...edge, source: edge.target, target: edge.source }; if (!this.adjacencyList.has(edge.target)) { this.adjacencyList.set(edge.target, new Map()); } this.adjacencyList.get(edge.target)!.set(edge.source, reverseEdge); } // Persist to database this.persistEdge(edge).catch(err => this.logger.error(`Failed to persist graph edge`, err), ); } /** * Persist a graph edge to the database. */ private async persistEdge(edge: GraphEdge): Promise { try { await this.prisma.graphEdge.upsert({ where: { source_target_type: { source: edge.source, target: edge.target, type: edge.type, }, }, create: { source: edge.source, target: edge.target, type: edge.type, weight: edge.weight, metadata: (edge.metadata || {}) as any, }, update: { weight: edge.weight, metadata: (edge.metadata || {}) as any, }, }); } catch (error) { this.logger.error(`Failed to persist edge ${edge.source} -> ${edge.target} (${edge.type})`, error); } } /** * Calculate similarity edges based on shared expertise and geography */ private async calculateSimilarityEdges( profileId: string, expertiseTags: { name: string }[] ): Promise { const expertiseNames = expertiseTags.map((t) => t.name); if (expertiseNames.length === 0) return; // Find other guests with similar expertise const similarGuests = await this.prisma.guestPublicProfile.findMany({ where: { id: { not: profileId }, isPublic: true, expertiseTags: { some: { name: { in: expertiseNames }, }, }, }, include: { expertiseTags: true, }, take: 20, }); for (const other of similarGuests) { const otherExpertise = other.expertiseTags.map((t) => t.name); const sharedExpertise = expertiseNames.filter((e) => otherExpertise.includes(e) ); if (sharedExpertise.length > 0) { const similarityScore = sharedExpertise.length / Math.max(expertiseNames.length, otherExpertise.length); this.addEdge({ source: `guest:${profileId}`, target: `guest:${other.id}`, type: "similar_to", weight: similarityScore, metadata: { sharedExpertise, score: similarityScore, }, }); } } } /** * Get recommendations based on graph proximity * "Podcasters who booked X also booked Y" */ async getRecommendations( profileId: string, limit = 5 ): Promise> { const nodeId = `guest:${profileId}`; const scores = new Map(); const reasons = new Map(); // Get guests who appeared on the same podcasts const appearedOn = this.getNeighbors(nodeId, "appeared_on"); for (const podcastId of appearedOn) { const coGuests = this.getNeighbors(podcastId, "appeared_on") .filter((g) => g !== nodeId); for (const coGuest of coGuests) { const guestId = coGuest.replace("guest:", ""); const currentScore = scores.get(guestId) || 0; scores.set(guestId, currentScore + 1); if (!reasons.has(guestId)) { reasons.set(guestId, []); } const podcastName = this.getNodeData(podcastId)?.name || "a podcast"; reasons.get(guestId)!.push(`Appeared on ${podcastName}`); } } // Get similar guests const similar = this.getNeighbors(nodeId, "similar_to"); for (const simGuest of similar) { const guestId = simGuest.replace("guest:", ""); const edge = this.getEdge(nodeId, simGuest); const currentScore = scores.get(guestId) || 0; const weight = edge?.weight || 0.5; scores.set(guestId, currentScore + weight); if (!reasons.has(guestId)) { reasons.set(guestId, []); } const shared = edge?.metadata?.sharedExpertise || ["similar expertise"]; reasons.get(guestId)!.push(`Shares expertise in ${shared.slice(0, 2).join(", ")}`); } // Sort by score and return top recommendations const sorted = Array.from(scores.entries()) .sort((a, b) => b[1] - a[1]) .slice(0, limit); return sorted.map(([guestId, score]) => ({ guestId, reason: [...new Set(reasons.get(guestId) || [])].join("; "), score, })); } /** * Find the shortest path between two guests (trust path) */ async findTrustPath( fromProfileId: string, toProfileId: string ): Promise { const start = `guest:${fromProfileId}`; const end = `guest:${toProfileId}`; if (!this.adjacencyList.has(start) || !this.adjacencyList.has(end)) { return null; } // BFS for shortest path const queue: Array<{ node: string; path: string[] }> = [{ node: start, path: [start] }]; const visited = new Set(); while (queue.length > 0) { const { node, path } = queue.shift()!; if (node === end) { return path.map((n) => n.replace("guest:", "").replace("podcast:", "")); } if (visited.has(node)) continue; visited.add(node); const neighbors = this.adjacencyList.get(node); if (neighbors) { for (const [neighbor] of Array.from(neighbors.entries())) { if (!visited.has(neighbor)) { queue.push({ node: neighbor, path: [...path, neighbor] }); } } } } return null; // No path found } /** * Get network statistics for a profile */ async getNetworkStats(profileId: string): Promise<{ connections: number; reach: number; centrality: number; communities: string[]; }> { const nodeId = `guest:${profileId}`; if (!this.adjacencyList.has(nodeId)) { return { connections: 0, reach: 0, centrality: 0, communities: [] }; } const directConnections = this.adjacencyList.get(nodeId)!.size; // Calculate 2-hop reach const visited = new Set(); visited.add(nodeId); const neighbors = this.getAllNeighbors(nodeId); for (const n of Array.from(neighbors)) { visited.add(n); } for (const n of Array.from(neighbors)) { const secondHop = this.getAllNeighbors(n); for (const sh of Array.from(secondHop)) { visited.add(sh); } } // Identify communities (tech hubs, countries, expertise areas) const communities = this.identifyCommunities(nodeId); return { connections: directConnections, reach: visited.size - 1, // exclude self centrality: this.calculateCentrality(nodeId), communities, }; } /** * Get guests in the same network/community */ async getCommunityMembers( communityType: "country" | "expertise" | "hub", value: string, limit = 20 ): Promise { const members: string[] = []; for (const [nodeId, edges] of Array.from(this.adjacencyList.entries())) { if (!nodeId.startsWith("guest:")) continue; const data = (edges as any).nodeData; if (!data) continue; if (communityType === "country" && data.country === value) { members.push(nodeId.replace("guest:", "")); } else if (communityType === "expertise" && data.expertise?.includes(value)) { members.push(nodeId.replace("guest:", "")); } } return members.slice(0, limit); } /** * Get neighbors by edge type */ private getNeighbors(nodeId: string, edgeType: string): string[] { const edges = this.adjacencyList.get(nodeId); if (!edges) return []; return Array.from(edges.entries()) .filter(([, edge]) => edge.type === edgeType) .map(([target]) => target); } /** * Get all neighbors regardless of edge type */ private getAllNeighbors(nodeId: string): string[] { const edges = this.adjacencyList.get(nodeId); if (!edges) return []; return Array.from(edges.keys()); } /** * Get edge between two nodes */ private getEdge(source: string, target: string): GraphEdge | undefined { return this.adjacencyList.get(source)?.get(target); } /** * Get node data */ private getNodeData(nodeId: string): any { const edges = this.adjacencyList.get(nodeId); if (!edges) return null; return (edges as any).nodeData; } /** * Identify communities the node belongs to */ private identifyCommunities(nodeId: string): string[] { const data = this.getNodeData(nodeId); if (!data) return []; const communities: string[] = []; if (data.country) { communities.push(`${data.country} Community`); } if (data.expertise) { // Group by top expertise areas communities.push(...data.expertise.slice(0, 2).map((e: string) => `${e} Network`)); } return communities; } /** * Calculate a simple centrality score */ private calculateCentrality(nodeId: string): number { const direct = this.adjacencyList.get(nodeId)?.size || 0; // Count unique 2-hop connections const twoHop = new Set(); const neighbors = this.getAllNeighbors(nodeId); for (const n of neighbors) { const secondHop = this.getAllNeighbors(n); for (const sh of secondHop) { if (sh !== nodeId) { twoHop.add(sh); } } } // Simple centrality: combination of direct and 2-hop return direct + twoHop.size * 0.5; } /** * Get graph health stats */ getStats(): { nodes: number; edges: number; guests: number; podcasts: number; } { let edges = 0; let guests = 0; let podcasts = 0; for (const [nodeId] of Array.from(this.adjacencyList.entries())) { if (nodeId.startsWith("guest:")) guests++; if (nodeId.startsWith("podcast:")) podcasts++; edges += this.adjacencyList.get(nodeId)!.size; } return { nodes: this.adjacencyList.size, edges: edges / 2, // undirected guests, podcasts, }; } }