diff --git a/admin/src/components/people/ConnectionGraph.tsx b/admin/src/components/people/ConnectionGraph.tsx index 3bdefca3..1a838ac5 100644 --- a/admin/src/components/people/ConnectionGraph.tsx +++ b/admin/src/components/people/ConnectionGraph.tsx @@ -106,30 +106,59 @@ const connectionTypeOptions = Object.entries(CONNECTION_TYPE_LABELS).map(([value })); function applyDagreLayout(nodes: Node[], edges: Edge[]): Node[] { - const g = new dagre.graphlib.Graph(); - g.setDefaultEdgeLabel(() => ({})); - g.setGraph({ rankdir: 'TB', nodesep: 80, ranksep: 100 }); + // Separate connected nodes (have at least one edge) from isolated ones + const connectedIds = new Set(); + for (const e of edges) { + connectedIds.add(e.source); + connectedIds.add(e.target); + } - nodes.forEach((node) => { - g.setNode(node.id, { width: NODE_WIDTH, height: NODE_HEIGHT }); - }); + const connectedNodes = nodes.filter((n) => connectedIds.has(n.id)); + const isolatedNodes = nodes.filter((n) => !connectedIds.has(n.id)); - edges.forEach((edge) => { - g.setEdge(edge.source, edge.target); - }); + // Layout connected nodes with dagre (tree layout) + let dagreMaxY = 0; + if (connectedNodes.length > 0) { + const g = new dagre.graphlib.Graph(); + g.setDefaultEdgeLabel(() => ({})); + g.setGraph({ rankdir: 'TB', nodesep: 80, ranksep: 100 }); - dagre.layout(g); + connectedNodes.forEach((node) => { + g.setNode(node.id, { width: NODE_WIDTH, height: NODE_HEIGHT }); + }); + edges.forEach((edge) => { + g.setEdge(edge.source, edge.target); + }); + dagre.layout(g); - return nodes.map((node) => { - const nodeWithPosition = g.node(node.id); - return { - ...node, - position: { - x: nodeWithPosition.x - NODE_WIDTH / 2, - y: nodeWithPosition.y - NODE_HEIGHT / 2, - }, - }; - }); + for (const node of connectedNodes) { + const pos = g.node(node.id); + node.position = { x: pos.x - NODE_WIDTH / 2, y: pos.y - NODE_HEIGHT / 2 }; + dagreMaxY = Math.max(dagreMaxY, pos.y + NODE_HEIGHT / 2); + } + } + + // Arrange isolated nodes in a grid below the connected graph + if (isolatedNodes.length > 0) { + const GAP_X = NODE_WIDTH + 30; + const GAP_Y = NODE_HEIGHT + 30; + const cols = Math.max(1, Math.ceil(Math.sqrt(isolatedNodes.length))); + const startY = connectedNodes.length > 0 ? dagreMaxY + 80 : 0; + // Center the grid horizontally + const gridWidth = cols * GAP_X; + const startX = -gridWidth / 2; + + isolatedNodes.forEach((node, i) => { + const col = i % cols; + const row = Math.floor(i / cols); + node.position = { + x: startX + col * GAP_X, + y: startY + row * GAP_Y, + }; + }); + } + + return [...connectedNodes, ...isolatedNodes]; } interface ConnectionGraphProps { diff --git a/api/src/modules/people/people.service.ts b/api/src/modules/people/people.service.ts index 1457249b..b04e76df 100644 --- a/api/src/modules/people/people.service.ts +++ b/api/src/modules/people/people.service.ts @@ -1618,30 +1618,178 @@ export const peopleService = { currentDepth++; } - // Include registered Users that don't have Contact records yet - // (so they appear in the graph alongside managed contacts) - if (!center && (!source || source === 'USER') && nodesMap.size < MAX_NODES) { - const users = await prisma.user.findMany({ - select: { id: true, name: true, email: true }, - take: MAX_NODES - nodesMap.size, - orderBy: { createdAt: 'desc' }, - }); + // ----------------------------------------------------------------------- + // Include all source types (not just Contacts) so the graph reflects + // the same universe of people shown in the table/cards views. + // We dedup by normalized email/phone to avoid double-counting people + // who already appear via a Contact or User node above. + // ----------------------------------------------------------------------- - for (const user of users) { - if (contactUserIds.has(user.id)) continue; // Already represented as a Contact node - if (nodesMap.size >= MAX_NODES) break; + // Build a set of emails/phones already represented in the graph + const representedEmails = new Set(); + const representedPhones = new Set(); + for (const n of nodesMap.values()) { + const ne = normalizeEmail(n.email); + if (ne) representedEmails.add(ne); + } + // Contacts have phones too — check rootContacts and connection neighbors + for (const c of rootContacts) { + const np = normalizePhone(c.phone); + if (np) representedPhones.add(np); + } - const nodeId = `user:${user.id}`; - nodesMap.set(nodeId, { - id: nodeId, - contactId: null, - displayName: user.name || user.email, - email: user.email, - source: 'USER', - supportLevel: null, - tags: [], - engagementScore: null, + function isAlreadyRepresented(email: string | null, phone: string | null): boolean { + const ne = normalizeEmail(email); + if (ne && representedEmails.has(ne)) return true; + const np = normalizePhone(phone); + if (np && representedPhones.has(np)) return true; + return false; + } + + function markRepresented(email: string | null, phone: string | null) { + const ne = normalizeEmail(email); + if (ne) representedEmails.add(ne); + const np = normalizePhone(phone); + if (np) representedPhones.add(np); + } + + // Mark all existing nodes + for (const n of nodesMap.values()) { + markRepresented(n.email, null); + } + + if (!center) { + // Users (not yet represented via a Contact node) + if ((!source || source === 'USER') && nodesMap.size < MAX_NODES) { + const users = await prisma.user.findMany({ + select: { id: true, name: true, email: true, phone: true }, + take: MAX_NODES - nodesMap.size, + orderBy: { createdAt: 'desc' }, }); + for (const user of users) { + if (contactUserIds.has(user.id)) continue; + if (isAlreadyRepresented(user.email, user.phone)) continue; + if (nodesMap.size >= MAX_NODES) break; + const nodeId = `user:${user.id}`; + nodesMap.set(nodeId, { + id: nodeId, contactId: null, + displayName: user.name || user.email, + email: user.email, source: 'USER', + supportLevel: null, tags: [], engagementScore: null, + }); + markRepresented(user.email, user.phone); + } + } + + // Address occupants + if ((!source || source === 'ADDRESS_OCCUPANT') && nodesMap.size < MAX_NODES) { + const addresses = await prisma.address.findMany({ + select: { id: true, firstName: true, lastName: true, email: true, phone: true, supportLevel: true }, + where: { OR: [{ firstName: { not: null } }, { lastName: { not: null } }] }, + take: MAX_NODES - nodesMap.size, + }); + for (const a of addresses) { + if (isAlreadyRepresented(a.email, a.phone)) continue; + if (nodesMap.size >= MAX_NODES) break; + const nodeId = `addr:${a.id}`; + nodesMap.set(nodeId, { + id: nodeId, contactId: null, + displayName: buildDisplayName(a.firstName, a.lastName, a.email, `Address ${a.id.slice(0, 6)}`), + email: a.email, source: 'ADDRESS_OCCUPANT', + supportLevel: a.supportLevel, tags: [], engagementScore: null, + }); + markRepresented(a.email, a.phone); + } + } + + // Campaign email senders + if ((!source || source === 'CAMPAIGN_SENDER') && nodesMap.size < MAX_NODES) { + const campaignEmails = await prisma.campaignEmail.findMany({ + select: { userEmail: true, userName: true }, + where: { userEmail: { not: null } }, + distinct: ['userEmail'], + take: MAX_NODES - nodesMap.size, + orderBy: { sentAt: 'desc' }, + }); + for (const ce of campaignEmails) { + if (!ce.userEmail) continue; + if (isAlreadyRepresented(ce.userEmail, null)) continue; + if (nodesMap.size >= MAX_NODES) break; + const nodeId = `cemail:${ce.userEmail}`; + nodesMap.set(nodeId, { + id: nodeId, contactId: null, + displayName: ce.userName || ce.userEmail, + email: ce.userEmail, source: 'CAMPAIGN_SENDER', + supportLevel: null, tags: [], engagementScore: null, + }); + markRepresented(ce.userEmail, null); + } + } + + // Shift signups + if ((!source || source === 'SHIFT_SIGNUP') && nodesMap.size < MAX_NODES) { + const shiftSignups = await prisma.shiftSignup.findMany({ + select: { userEmail: true, userName: true, userPhone: true }, + distinct: ['userEmail'], + take: MAX_NODES - nodesMap.size, + orderBy: { signupDate: 'desc' }, + }); + for (const ss of shiftSignups) { + if (isAlreadyRepresented(ss.userEmail, ss.userPhone)) continue; + if (nodesMap.size >= MAX_NODES) break; + const nodeId = `signup:${ss.userEmail}`; + nodesMap.set(nodeId, { + id: nodeId, contactId: null, + displayName: ss.userName || ss.userEmail, + email: ss.userEmail, source: 'SHIFT_SIGNUP', + supportLevel: null, tags: [], engagementScore: null, + }); + markRepresented(ss.userEmail, ss.userPhone); + } + } + + // SMS contacts + if ((!source || source === 'SMS_CONTACT') && nodesMap.size < MAX_NODES) { + const smsEntries = await prisma.smsContactListEntry.findMany({ + select: { phone: true, name: true, email: true }, + distinct: ['phone'], + take: MAX_NODES - nodesMap.size, + orderBy: { createdAt: 'desc' }, + }); + for (const sc of smsEntries) { + if (isAlreadyRepresented(sc.email, sc.phone)) continue; + if (nodesMap.size >= MAX_NODES) break; + const nodeId = `sms:${sc.phone}`; + nodesMap.set(nodeId, { + id: nodeId, contactId: null, + displayName: sc.name || sc.phone, + email: sc.email, source: 'SMS_CONTACT', + supportLevel: null, tags: [], engagementScore: null, + }); + markRepresented(sc.email, sc.phone); + } + } + + // Donations (Orders) + if ((!source || source === 'DONATION') && nodesMap.size < MAX_NODES) { + const orders = await prisma.order.findMany({ + select: { buyerEmail: true, buyerName: true }, + distinct: ['buyerEmail'], + take: MAX_NODES - nodesMap.size, + orderBy: { createdAt: 'desc' }, + }); + for (const o of orders) { + if (isAlreadyRepresented(o.buyerEmail, null)) continue; + if (nodesMap.size >= MAX_NODES) break; + const nodeId = `order:${o.buyerEmail}`; + nodesMap.set(nodeId, { + id: nodeId, contactId: null, + displayName: o.buyerName || o.buyerEmail || 'Unknown', + email: o.buyerEmail, source: 'DONATION', + supportLevel: null, tags: [], engagementScore: null, + }); + markRepresented(o.buyerEmail, null); + } } }