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:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user