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:
2026-02-28 16:09:12 -07:00
parent 41d86782b4
commit d98488c1dc
2 changed files with 218 additions and 41 deletions

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);
}
}
}