Fix people graph to include all source types and use grid layout for disconnected nodes

The graph view only showed managed Contacts and Users (5 nodes) while
the table/cards views showed all 94 people. Added SMS contacts, address
occupants, campaign senders, shift signups, and donations to the graph
API with email/phone deduplication. Updated the frontend layout to
arrange disconnected nodes in a grid instead of a single horizontal
line, while preserving dagre tree layout for connected components.

Bunker Admin
This commit is contained in:
bunker-admin 2026-02-28 16:09:12 -07:00
parent 41d86782b4
commit d98488c1dc
2 changed files with 218 additions and 41 deletions

View File

@ -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<string>();
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 {

View File

@ -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<string>();
const representedPhones = new Set<string>();
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);
}
}
}