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:
parent
41d86782b4
commit
d98488c1dc
@ -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 {
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user