changemaker.lite/api/src/modules/people/people.service.ts
bunker-admin d98488c1dc 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
2026-02-28 16:09:12 -07:00

2690 lines
99 KiB
TypeScript

import { prisma } from '../../config/database';
import { redis } from '../../config/redis';
import { logger } from '../../utils/logger';
import { AppError } from '../../middleware/error-handler';
import type { Prisma } from '@prisma/client';
import type {
ListPeopleInput,
ManagePersonInput,
CreateContactInput,
UpdateContactInput,
CreateConnectionInput,
MergeContactInput,
GraphQueryInput,
ActivityListInput,
AddContactAddressInput,
AddContactEmailInput,
AddContactPhoneInput,
} from './people.schemas';
// ---------------------------------------------------------------------------
// Shared types
// ---------------------------------------------------------------------------
interface UnifiedPerson {
id: string;
displayName: string;
email: string | null;
phone: string | null;
source: string;
supportLevel: string | null;
lastActivity: string | null;
isManaged: boolean;
contactId: string | null;
userId: string | null;
addressId: string | null;
tags: string[];
engagementScore: number | null;
userStatus?: string;
userRole?: string;
}
interface ActivityItem {
id: string;
type: string;
title: string;
description: string | null;
occurredAt: string;
metadata?: unknown;
}
interface GraphNode {
id: string;
contactId: string | null;
displayName: string;
email: string | null;
source: string;
supportLevel: string | null;
tags: string[];
engagementScore: number | null;
}
interface GraphEdge {
id: string;
source: string;
target: string;
type: string;
label: string | null;
isBidirectional: boolean;
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function normalizeEmail(e: string | null | undefined): string | null {
if (!e) return null;
return e.trim().toLowerCase();
}
function normalizePhone(p: string | null | undefined): string | null {
if (!p) return null;
return p.replace(/[^0-9+]/g, '') || null;
}
function buildDisplayName(first: string | null | undefined, last: string | null | undefined, email: string | null | undefined, fallback: string): string {
if (first && last) return `${first} ${last}`;
if (first) return first;
if (last) return last;
if (email) return email;
return fallback;
}
// ---------------------------------------------------------------------------
// Service
// ---------------------------------------------------------------------------
export const peopleService = {
// =========================================================================
// a) LIST PEOPLE — Virtual aggregation
// =========================================================================
async listPeople(params: ListPeopleInput) {
const { page, limit, search, source, supportLevel, tag, managedOnly } = params;
const searchLower = search?.toLowerCase();
// Fetch all sources in parallel
const [users, addresses, campaignEmails, shiftSignups, smsEntries, orders, contacts] = await Promise.all([
// 1. Users
(!source || source === 'USER') ? prisma.user.findMany({
select: { id: true, email: true, name: true, phone: true, lastLoginAt: true, contact: { select: { id: true } } },
where: search ? {
OR: [
{ name: { contains: search, mode: 'insensitive' } },
{ email: { contains: search, mode: 'insensitive' } },
{ phone: { contains: search, mode: 'insensitive' } },
],
} : undefined,
}) : [],
// 2. Addresses with occupant info
(!source || source === 'ADDRESS_OCCUPANT') ? prisma.address.findMany({
select: {
id: true, firstName: true, lastName: true, email: true, phone: true,
supportLevel: true, locationId: true, updatedAt: true,
contactAddresses: { select: { contactId: true } },
},
where: {
OR: [{ firstName: { not: null } }, { lastName: { not: null } }],
...(search ? {
AND: {
OR: [
{ firstName: { contains: search, mode: 'insensitive' } },
{ lastName: { contains: search, mode: 'insensitive' } },
{ email: { contains: search, mode: 'insensitive' } },
{ phone: { contains: search, mode: 'insensitive' } },
],
},
} : {}),
},
}) : [],
// 3. Distinct CampaignEmail senders
(!source || source === 'CAMPAIGN_SENDER') ? prisma.campaignEmail.findMany({
select: { userEmail: true, userName: true, sentAt: true },
where: {
userEmail: { not: null },
...(search ? {
OR: [
{ userEmail: { contains: search, mode: 'insensitive' } },
{ userName: { contains: search, mode: 'insensitive' } },
],
} : {}),
},
distinct: ['userEmail'],
orderBy: { sentAt: 'desc' },
}) : [],
// 4. Distinct ShiftSignup senders
(!source || source === 'SHIFT_SIGNUP') ? prisma.shiftSignup.findMany({
select: { userEmail: true, userName: true, userPhone: true, signupDate: true },
where: search ? {
OR: [
{ userEmail: { contains: search, mode: 'insensitive' } },
{ userName: { contains: search, mode: 'insensitive' } },
{ userPhone: { contains: search, mode: 'insensitive' } },
],
} : undefined,
distinct: ['userEmail'],
orderBy: { signupDate: 'desc' },
}) : [],
// 5. Distinct SmsContactListEntry by phone
(!source || source === 'SMS_CONTACT') ? prisma.smsContactListEntry.findMany({
select: { phone: true, name: true, email: true, createdAt: true },
where: search ? {
OR: [
{ phone: { contains: search, mode: 'insensitive' } },
{ name: { contains: search, mode: 'insensitive' } },
{ email: { contains: search, mode: 'insensitive' } },
],
} : undefined,
distinct: ['phone'],
orderBy: { createdAt: 'desc' },
}) : [],
// 6. Distinct Order by buyerEmail
(!source || source === 'DONATION') ? prisma.order.findMany({
select: { buyerEmail: true, buyerName: true, createdAt: true },
where: search ? {
OR: [
{ buyerEmail: { contains: search, mode: 'insensitive' } },
{ buyerName: { contains: search, mode: 'insensitive' } },
],
} : undefined,
distinct: ['buyerEmail'],
orderBy: { createdAt: 'desc' },
}) : [],
// All non-merged Contacts for overlay
prisma.contact.findMany({
where: { mergedIntoId: null },
select: {
id: true, displayName: true, email: true, phone: true,
tags: true, notes: true, supportLevel: true, signRequested: true,
emailOptOut: true, smsOptOut: true, doNotContact: true,
primarySource: true, userId: true, createdAt: true, updatedAt: true,
addresses: { select: { addressId: true } },
},
}),
]);
// Build index maps for Contact overlay
const contactByUserId = new Map<string, typeof contacts[number]>();
const contactByEmail = new Map<string, typeof contacts[number]>();
const contactByPhone = new Map<string, typeof contacts[number]>();
const contactByAddressId = new Map<string, typeof contacts[number]>();
for (const c of contacts) {
if (c.userId) contactByUserId.set(c.userId, c);
const ne = normalizeEmail(c.email);
if (ne) contactByEmail.set(ne, c);
const np = normalizePhone(c.phone);
if (np) contactByPhone.set(np, c);
for (const ca of c.addresses) {
contactByAddressId.set(ca.addressId, c);
}
}
// Deduplication map: keyed by normalized email or phone
const seen = new Map<string, UnifiedPerson>();
const people: UnifiedPerson[] = [];
function addPerson(p: UnifiedPerson) {
const ne = normalizeEmail(p.email);
const np = normalizePhone(p.phone);
const key = ne || np;
if (key && seen.has(key)) return; // already captured
if (key) seen.set(key, p);
// Also register both email and phone to catch cross-key dups
if (ne) seen.set(ne, p);
if (np) seen.set(np, p);
people.push(p);
}
function overlayContact(p: UnifiedPerson): UnifiedPerson {
const c = (p.userId ? contactByUserId.get(p.userId) : null)
|| (p.addressId ? contactByAddressId.get(p.addressId) : null)
|| (normalizeEmail(p.email) ? contactByEmail.get(normalizeEmail(p.email)!) : null)
|| (normalizePhone(p.phone) ? contactByPhone.get(normalizePhone(p.phone)!) : null);
if (!c) return p;
const tags = Array.isArray(c.tags) ? (c.tags as string[]) : [];
return {
...p,
isManaged: true,
contactId: c.id,
tags,
supportLevel: c.supportLevel || p.supportLevel,
displayName: c.displayName || p.displayName,
};
}
// 1. Users (highest priority)
for (const u of users) {
addPerson(overlayContact({
id: `user:${u.id}`,
displayName: u.name || u.email,
email: u.email,
phone: u.phone,
source: 'USER',
supportLevel: null,
lastActivity: u.lastLoginAt?.toISOString() || null,
isManaged: !!u.contact,
contactId: u.contact?.id || null,
userId: u.id,
addressId: null,
tags: [],
engagementScore: null,
}));
}
// 2. Addresses
for (const a of addresses) {
addPerson(overlayContact({
id: `addr:${a.id}`,
displayName: buildDisplayName(a.firstName, a.lastName, a.email, `Address ${a.id.slice(0, 6)}`),
email: a.email,
phone: a.phone,
source: 'ADDRESS_OCCUPANT',
supportLevel: a.supportLevel,
lastActivity: a.updatedAt?.toISOString() || null,
isManaged: false,
contactId: null,
userId: null,
addressId: a.id,
tags: [],
engagementScore: null,
}));
}
// 3. Campaign email senders
for (const ce of campaignEmails) {
if (!ce.userEmail) continue;
addPerson(overlayContact({
id: `cemail:${ce.userEmail}`,
displayName: ce.userName || ce.userEmail,
email: ce.userEmail,
phone: null,
source: 'CAMPAIGN_SENDER',
supportLevel: null,
lastActivity: ce.sentAt?.toISOString() || null,
isManaged: false,
contactId: null,
userId: null,
addressId: null,
tags: [],
engagementScore: null,
}));
}
// 4. Shift signups
for (const ss of shiftSignups) {
addPerson(overlayContact({
id: `signup:${ss.userEmail}`,
displayName: ss.userName || ss.userEmail,
email: ss.userEmail,
phone: ss.userPhone,
source: 'SHIFT_SIGNUP',
supportLevel: null,
lastActivity: ss.signupDate?.toISOString() || null,
isManaged: false,
contactId: null,
userId: null,
addressId: null,
tags: [],
engagementScore: null,
}));
}
// 5. SMS contacts
for (const sc of smsEntries) {
addPerson(overlayContact({
id: `sms:${sc.phone}`,
displayName: sc.name || sc.phone,
email: sc.email,
phone: sc.phone,
source: 'SMS_CONTACT',
supportLevel: null,
lastActivity: sc.createdAt?.toISOString() || null,
isManaged: false,
contactId: null,
userId: null,
addressId: null,
tags: [],
engagementScore: null,
}));
}
// 6. Donors / buyers
for (const o of orders) {
addPerson(overlayContact({
id: `donor:${o.buyerEmail}`,
displayName: o.buyerName || o.buyerEmail,
email: o.buyerEmail,
phone: null,
source: 'DONATION',
supportLevel: null,
lastActivity: o.createdAt?.toISOString() || null,
isManaged: false,
contactId: null,
userId: null,
addressId: null,
tags: [],
engagementScore: null,
}));
}
// 7. Contacts not already captured by sources above (e.g. MANUAL contacts)
const capturedContactIds = new Set(people.filter(p => p.contactId).map(p => p.contactId!));
for (const c of contacts) {
if (capturedContactIds.has(c.id)) continue;
if (search) {
const s = search.toLowerCase();
const matches = c.displayName?.toLowerCase().includes(s)
|| c.email?.toLowerCase().includes(s)
|| c.phone?.includes(s);
if (!matches) continue;
}
const tags = Array.isArray(c.tags) ? (c.tags as string[]) : [];
addPerson({
id: `contact:${c.id}`,
displayName: c.displayName,
email: c.email,
phone: c.phone,
source: c.primarySource,
supportLevel: c.supportLevel,
lastActivity: c.updatedAt?.toISOString() || null,
isManaged: true,
contactId: c.id,
userId: c.userId,
addressId: null,
tags,
engagementScore: null,
});
}
// Apply post-aggregation filters
let filtered = people;
if (source) {
filtered = filtered.filter(p => p.source === source);
}
if (supportLevel) {
filtered = filtered.filter(p => p.supportLevel === supportLevel);
}
if (tag) {
filtered = filtered.filter(p => p.tags.includes(tag));
}
if (managedOnly) {
filtered = filtered.filter(p => p.isManaged);
}
// Sort by display name
filtered.sort((a, b) => a.displayName.localeCompare(b.displayName));
// Paginate
const total = filtered.length;
const totalPages = Math.ceil(total / limit);
const start = (page - 1) * limit;
const paged = filtered.slice(start, start + limit);
return {
people: paged,
pagination: { page, limit, total, totalPages },
};
},
// =========================================================================
// b) GET PERSON DETAIL
// =========================================================================
async getPersonDetail(type: string, id: string) {
let person: Partial<UnifiedPerson> = {};
let contact: Awaited<ReturnType<typeof prisma.contact.findUnique>> | null = null;
if (type === 'user') {
const user = await prisma.user.findUnique({
where: { id },
select: { id: true, email: true, name: true, phone: true, lastLoginAt: true, status: true, role: true, contact: true },
});
if (!user) throw new AppError(404, 'User not found', 'NOT_FOUND');
contact = user.contact;
person = {
id: `user:${user.id}`,
displayName: user.name || user.email,
email: user.email,
phone: user.phone,
source: 'USER',
userId: user.id,
lastActivity: user.lastLoginAt?.toISOString() || null,
isManaged: !!contact,
contactId: contact?.id || null,
userStatus: user.status,
userRole: user.role,
};
} else if (type === 'addr') {
const addr = await prisma.address.findUnique({
where: { id },
select: {
id: true, firstName: true, lastName: true, email: true, phone: true,
supportLevel: true, locationId: true, updatedAt: true,
location: { select: { address: true } },
contactAddresses: { select: { contact: true } },
},
});
if (!addr) throw new AppError(404, 'Address not found', 'NOT_FOUND');
contact = addr.contactAddresses[0]?.contact || null;
person = {
id: `addr:${addr.id}`,
displayName: buildDisplayName(addr.firstName, addr.lastName, addr.email, addr.location?.address || `Address ${addr.id.slice(0, 6)}`),
email: addr.email,
phone: addr.phone,
source: 'ADDRESS_OCCUPANT',
addressId: addr.id,
supportLevel: addr.supportLevel,
lastActivity: addr.updatedAt?.toISOString() || null,
isManaged: !!contact,
contactId: contact?.id || null,
};
} else if (type === 'contact') {
contact = await prisma.contact.findUnique({
where: { id },
include: {
addresses: { include: { address: true } },
emails: { orderBy: [{ isPrimary: 'desc' }, { createdAt: 'asc' }] },
phones: { orderBy: [{ isPrimary: 'desc' }, { createdAt: 'asc' }] },
user: { select: { id: true, email: true, name: true } },
},
});
if (!contact) throw new AppError(404, 'Contact not found', 'NOT_FOUND');
person = {
id: `contact:${contact.id}`,
displayName: contact.displayName,
email: contact.email,
phone: contact.phone,
source: contact.primarySource,
userId: contact.userId,
isManaged: true,
contactId: contact.id,
tags: Array.isArray(contact.tags) ? (contact.tags as string[]) : [],
supportLevel: contact.supportLevel,
};
} else if (type === 'cemail') {
// Campaign email sender — id is the email address
const ce = await prisma.campaignEmail.findFirst({
where: { userEmail: { equals: id, mode: 'insensitive' } },
orderBy: { sentAt: 'desc' },
});
if (!ce) throw new AppError(404, 'Campaign sender not found', 'NOT_FOUND');
contact = await this.findContactByEmail(id);
person = {
id: `cemail:${ce.userEmail}`,
displayName: ce.userName || ce.userEmail || id,
email: ce.userEmail,
phone: null,
source: 'CAMPAIGN_SENDER',
lastActivity: ce.sentAt?.toISOString() || null,
isManaged: !!contact,
contactId: contact?.id || null,
};
} else if (type === 'signup') {
// Shift signup — id is the email address
const ss = await prisma.shiftSignup.findFirst({
where: { userEmail: { equals: id, mode: 'insensitive' } },
orderBy: { signupDate: 'desc' },
});
if (!ss) throw new AppError(404, 'Shift signup not found', 'NOT_FOUND');
contact = await this.findContactByEmail(id);
person = {
id: `signup:${ss.userEmail}`,
displayName: ss.userName || ss.userEmail,
email: ss.userEmail,
phone: ss.userPhone,
source: 'SHIFT_SIGNUP',
lastActivity: ss.signupDate?.toISOString() || null,
isManaged: !!contact,
contactId: contact?.id || null,
};
} else if (type === 'sms') {
// SMS contact — id is the phone number
const sc = await prisma.smsContactListEntry.findFirst({
where: { phone: id },
orderBy: { createdAt: 'desc' },
});
if (!sc) throw new AppError(404, 'SMS contact not found', 'NOT_FOUND');
contact = await this.findContactByPhone(id);
person = {
id: `sms:${sc.phone}`,
displayName: sc.name || sc.phone,
email: sc.email,
phone: sc.phone,
source: 'SMS_CONTACT',
lastActivity: sc.createdAt?.toISOString() || null,
isManaged: !!contact,
contactId: contact?.id || null,
};
} else if (type === 'donor') {
// Donor / buyer — id is the email address
const order = await prisma.order.findFirst({
where: { buyerEmail: { equals: id, mode: 'insensitive' } },
orderBy: { createdAt: 'desc' },
});
if (!order) throw new AppError(404, 'Donor not found', 'NOT_FOUND');
contact = await this.findContactByEmail(id);
person = {
id: `donor:${order.buyerEmail}`,
displayName: order.buyerName || order.buyerEmail,
email: order.buyerEmail,
phone: null,
source: 'DONATION',
lastActivity: order.createdAt?.toISOString() || null,
isManaged: !!contact,
contactId: contact?.id || null,
};
} else {
throw new AppError(400, 'Invalid person type', 'INVALID_TYPE');
}
// Compute engagement
const engagement = await this.getEngagementSummary(
person.email || null,
person.phone || null,
person.userId || null,
);
return { person, contact, engagement };
},
// =========================================================================
// Helpers
// =========================================================================
/** Create initial ContactEmail/ContactPhone junction records for a new Contact */
async createInitialEmailPhone(contactId: string, email: string | null, phone: string | null) {
try {
if (email) {
await prisma.contactEmail.create({
data: { contactId, email: email.trim().toLowerCase(), isPrimary: true },
});
}
if (phone) {
const normalized = phone.replace(/[^0-9+]/g, '') || phone.trim();
if (normalized) {
await prisma.contactPhone.create({
data: { contactId, phone: normalized, isPrimary: true },
});
}
}
} catch (err: unknown) {
logger.warn('Failed to create initial email/phone junction records:', err);
}
},
async findContactByEmail(email: string) {
const normalized = normalizeEmail(email);
if (!normalized) return null;
// Try denormalized field first (fast path)
const byField = await prisma.contact.findFirst({
where: { email: { equals: normalized, mode: 'insensitive' }, mergedIntoId: null },
});
if (byField) return byField;
// Fallback: search junction table for non-primary emails
const link = await prisma.contactEmail.findFirst({
where: { email: { equals: normalized, mode: 'insensitive' }, contact: { mergedIntoId: null } },
include: { contact: true },
});
return link?.contact || null;
},
async findContactByPhone(phone: string) {
const normalized = normalizePhone(phone);
if (!normalized) return null;
const byField = await prisma.contact.findFirst({
where: { phone: normalized, mergedIntoId: null },
});
if (byField) return byField;
// Fallback: search junction table
const link = await prisma.contactPhone.findFirst({
where: { phone: normalized, contact: { mergedIntoId: null } },
include: { contact: true },
});
return link?.contact || null;
},
// =========================================================================
// c) ENGAGEMENT SUMMARY
// =========================================================================
async getEngagementSummary(email: string | null, phone: string | null, userId: string | null) {
const identifier = normalizeEmail(email) || normalizePhone(phone) || userId || 'unknown';
const cacheKey = `people:engagement:${identifier}`;
// Check Redis cache
try {
const cached = await redis.get(cacheKey);
if (cached) return JSON.parse(cached);
} catch {
// Redis unavailable — proceed without cache
}
// Query modules in parallel
const [emailCount, responseCount, shiftCount, canvassCount, orderStats, smsMessageCount, smsConversationCount, canvassSessionCount, videoCount] = await Promise.all([
// Campaign emails sent
email
? prisma.campaignEmail.count({ where: { userEmail: { equals: email, mode: 'insensitive' } } })
: 0,
// Representative responses submitted
email
? prisma.representativeResponse.count({ where: { submittedByEmail: { equals: email, mode: 'insensitive' } } })
: 0,
// Shift signups
email
? prisma.shiftSignup.count({ where: { userEmail: { equals: email, mode: 'insensitive' } } })
: 0,
// Canvass visits
userId
? prisma.canvassVisit.count({ where: { userId } })
: 0,
// Orders: sum and count
email
? prisma.order.aggregate({
where: { buyerEmail: { equals: email, mode: 'insensitive' }, status: 'COMPLETED' },
_sum: { amountCAD: true },
_count: true,
})
: { _sum: { amountCAD: null }, _count: 0 },
// SMS messages received
phone
? prisma.smsMessage.count({ where: { phone: normalizePhone(phone)!, direction: 'INBOUND' } })
: 0,
// SMS conversations
phone
? prisma.smsConversation.count({ where: { phone: normalizePhone(phone)! } })
: 0,
// Canvass sessions
userId
? prisma.canvassSession.count({ where: { userId } })
: 0,
// Video views
userId
? prisma.videoView.count({ where: { userId } })
: 0,
]);
const donationTotal = orderStats._sum.amountCAD || 0;
const donationCount = orderStats._count;
// Calculate engagement score (0-100, weighted)
// Emails 10%, responses 15%, shifts 15%, canvass 20%, donations 15%, sms 10%, media 5%, recency 10%
const cap = (val: number, max: number) => Math.min(val / max, 1);
const emailScore = cap(emailCount, 10) * 10;
const responseScore = cap(responseCount, 5) * 15;
const shiftScore = cap(shiftCount, 5) * 15;
const canvassScore = cap(canvassCount, 20) * 20;
const donationScore = cap(donationCount, 5) * 15;
const smsScore = cap(smsMessageCount, 20) * 10;
const mediaScore = cap(videoCount, 10) * 5;
// Recency + first-seen: check activity dates across modules
let recencyScore = 0;
const dateChecks: Promise<{ first: Date | null; last: Date | null }>[] = [];
if (email) {
dateChecks.push(
Promise.all([
prisma.campaignEmail.findFirst({
where: { userEmail: { equals: email, mode: 'insensitive' } },
orderBy: { sentAt: 'asc' },
select: { sentAt: true },
}),
prisma.campaignEmail.findFirst({
where: { userEmail: { equals: email, mode: 'insensitive' } },
orderBy: { sentAt: 'desc' },
select: { sentAt: true },
}),
]).then(([first, last]) => ({ first: first?.sentAt || null, last: last?.sentAt || null })),
);
}
if (userId) {
dateChecks.push(
Promise.all([
prisma.canvassVisit.findFirst({
where: { userId },
orderBy: { visitedAt: 'asc' },
select: { visitedAt: true },
}),
prisma.canvassVisit.findFirst({
where: { userId },
orderBy: { visitedAt: 'desc' },
select: { visitedAt: true },
}),
]).then(([first, last]) => ({ first: first?.visitedAt || null, last: last?.visitedAt || null })),
);
}
const dateResults = await Promise.all(dateChecks);
const allFirstDates = dateResults.map(d => d.first).filter(Boolean) as Date[];
const allLastDates = dateResults.map(d => d.last).filter(Boolean) as Date[];
const firstSeen = allFirstDates.length > 0
? new Date(Math.min(...allFirstDates.map(d => d.getTime())))
: null;
const lastSeen = allLastDates.length > 0
? new Date(Math.max(...allLastDates.map(d => d.getTime())))
: null;
if (lastSeen) {
const daysSince = (Date.now() - lastSeen.getTime()) / (1000 * 60 * 60 * 24);
if (daysSince <= 7) recencyScore = 10;
else if (daysSince <= 30) recencyScore = 7;
else if (daysSince <= 90) recencyScore = 4;
else if (daysSince <= 365) recencyScore = 2;
}
const totalScore = Math.round(emailScore + responseScore + shiftScore + canvassScore + donationScore + smsScore + mediaScore + recencyScore);
const summary = {
influence: {
emailsSent: emailCount,
responsesSubmitted: responseCount,
campaignsParticipated: [] as string[],
},
map: {
shiftsSignedUp: shiftCount,
canvassVisits: canvassCount,
canvassSessions: canvassSessionCount,
},
payments: {
totalDonatedCAD: Number(donationTotal),
donationCount,
},
sms: {
conversations: smsConversationCount,
messagesReceived: smsMessageCount,
},
media: {
videoViews: videoCount,
},
overall: {
engagementScore: totalScore,
firstSeen: firstSeen?.toISOString() || null,
lastSeen: lastSeen?.toISOString() || null,
},
};
// Cache for 5 minutes
try {
await redis.set(cacheKey, JSON.stringify(summary), 'EX', 300);
} catch {
// Redis write failure — non-critical
}
return summary;
},
// =========================================================================
// c2) CREATE USER FROM CONTACT
// =========================================================================
async createUserFromContact(
contactId: string,
input: { password: string; role: string; sendWelcomeEmail: boolean },
adminUserId: string,
) {
const contact = await prisma.contact.findUnique({ where: { id: contactId } });
if (!contact) throw new AppError(404, 'Contact not found', 'NOT_FOUND');
if (!contact.email) throw new AppError(400, 'Contact has no email address', 'NO_EMAIL');
if (contact.mergedIntoId) throw new AppError(400, 'Contact has been merged', 'MERGED_CONTACT');
if (contact.userId) throw new AppError(409, 'Contact is already linked to a user account', 'ALREADY_LINKED');
// Check if a user with that email already exists
const existingUser = await prisma.user.findUnique({ where: { email: contact.email } });
if (existingUser) {
// Auto-link the existing user to this contact if their contact slot is empty
const existingContact = await prisma.contact.findFirst({
where: { userId: existingUser.id, mergedIntoId: null },
});
if (!existingContact) {
await prisma.contact.update({
where: { id: contactId },
data: { userId: existingUser.id },
});
// Log activity
await prisma.contactActivity.create({
data: {
contactId,
type: 'NOTE_ADDED',
title: 'Linked to existing user account',
description: `Auto-linked to existing user ${existingUser.email} (ID: ${existingUser.id})`,
},
});
return { user: existingUser, action: 'linked' as const, contactId };
}
throw new AppError(409, 'A user with that email already exists and is linked to another contact', 'EMAIL_EXISTS');
}
// Create user via usersService (handles password hashing, provisioning hooks)
const { usersService } = await import('../users/users.service');
const user = await usersService.create({
email: contact.email,
password: input.password,
name: contact.displayName,
phone: contact.phone || undefined,
role: input.role as any,
roles: [input.role as any],
status: 'ACTIVE',
});
// Link contact to the new user
await prisma.contact.update({
where: { id: contactId },
data: { userId: user.id },
});
// Log activity
await prisma.contactActivity.create({
data: {
contactId,
type: 'NOTE_ADDED',
title: 'User account created',
description: `User account created by admin with role ${input.role}`,
},
});
// Optionally send welcome email
if (input.sendWelcomeEmail && contact.email) {
try {
const { emailService } = await import('../../services/email.service');
const { env } = await import('../../config/env');
const adminUrl = env.ADMIN_URL || 'http://localhost:3000';
await emailService.sendAccountApprovedEmail({
recipientEmail: contact.email,
recipientName: contact.displayName,
loginUrl: `${adminUrl}/login`,
});
} catch (err) {
logger.warn('Failed to send welcome email for new user from contact:', err);
}
}
return { user, action: 'created' as const, contactId };
},
// =========================================================================
// d) MANAGE CONTACT — Promote virtual person
// =========================================================================
async manageContact(input: ManagePersonInput, createdByUserId: string) {
const { sourceType, sourceId, displayName, firstName, lastName, email, phone } = input;
if (sourceType === 'user') {
const user = await prisma.user.findUnique({ where: { id: sourceId }, select: { id: true, email: true, name: true, phone: true, contact: true } });
if (!user) throw new AppError(404, 'User not found', 'NOT_FOUND');
if (user.contact) throw new AppError(409, 'User is already a managed contact', 'ALREADY_MANAGED');
const nameParts = (user.name || '').split(' ');
const contact = await prisma.contact.create({
data: {
displayName: displayName || user.name || user.email,
firstName: firstName || nameParts[0] || null,
lastName: lastName || nameParts.slice(1).join(' ') || null,
email: email || user.email,
phone: phone || user.phone,
primarySource: 'USER',
userId: user.id,
createdByUserId,
tags: [] as unknown as Prisma.InputJsonValue,
},
});
await this.createInitialEmailPhone(contact.id, contact.email, contact.phone);
return contact;
} else if (sourceType === 'addr') {
const addr = await prisma.address.findUnique({
where: { id: sourceId },
select: { id: true, firstName: true, lastName: true, email: true, phone: true, supportLevel: true, contactAddresses: { select: { contactId: true } } },
});
if (!addr) throw new AppError(404, 'Address not found', 'NOT_FOUND');
if (addr.contactAddresses.length > 0) throw new AppError(409, 'Address is already linked to a managed contact', 'ALREADY_MANAGED');
const contact = await prisma.contact.create({
data: {
displayName: displayName || buildDisplayName(addr.firstName, addr.lastName, addr.email, `Address ${sourceId.slice(0, 6)}`),
firstName: firstName || addr.firstName,
lastName: lastName || addr.lastName,
email: email || addr.email,
phone: phone || addr.phone,
supportLevel: addr.supportLevel,
primarySource: 'ADDRESS_OCCUPANT',
createdByUserId,
tags: [] as unknown as Prisma.InputJsonValue,
addresses: {
create: { addressId: sourceId, isPrimary: true },
},
},
});
await this.createInitialEmailPhone(contact.id, contact.email, contact.phone);
return contact;
} else if (sourceType === 'contact') {
// Re-fetch or validate existing contact
const existing = await prisma.contact.findUnique({ where: { id: sourceId } });
if (!existing) throw new AppError(404, 'Contact not found', 'NOT_FOUND');
return existing;
} else if (['donor', 'cemail', 'signup', 'sms'].includes(sourceType)) {
// Virtual-only sources: create Contact from provided data
const sourceMap: Record<string, string> = {
donor: 'DONATION',
cemail: 'CAMPAIGN_SENDER',
signup: 'SHIFT_SIGNUP',
sms: 'SMS_CONTACT',
};
const contact = await prisma.contact.create({
data: {
displayName: displayName || email || phone || sourceId,
firstName: firstName || null,
lastName: lastName || null,
email: email || (sourceType !== 'sms' ? sourceId : null),
phone: phone || (sourceType === 'sms' ? sourceId : null),
primarySource: sourceMap[sourceType] as any,
createdByUserId,
tags: [] as unknown as Prisma.InputJsonValue,
},
});
await this.createInitialEmailPhone(contact.id, contact.email, contact.phone);
return contact;
} else {
throw new AppError(400, 'Invalid sourceType', 'INVALID_TYPE');
}
},
// =========================================================================
// d2) CREATE CONTACT — Direct manual creation
// =========================================================================
async createContact(input: CreateContactInput, createdByUserId: string) {
const contact = await prisma.contact.create({
data: {
displayName: input.displayName,
firstName: input.firstName || null,
lastName: input.lastName || null,
email: input.email || null,
phone: input.phone || null,
tags: (input.tags || []) as unknown as Prisma.InputJsonValue,
notes: input.notes || null,
supportLevel: input.supportLevel || null,
primarySource: 'MANUAL',
createdByUserId,
},
});
// Create initial ContactEmail/ContactPhone junction records
if (input.email) {
await prisma.contactEmail.create({
data: { contactId: contact.id, email: input.email.trim().toLowerCase(), isPrimary: true },
}).catch((err: unknown) => logger.warn('Failed to create initial ContactEmail:', err));
}
if (input.phone) {
const normalizedPhone = input.phone.replace(/[^0-9+]/g, '') || input.phone.trim();
await prisma.contactPhone.create({
data: { contactId: contact.id, phone: normalizedPhone, isPrimary: true },
}).catch((err: unknown) => logger.warn('Failed to create initial ContactPhone:', err));
}
// Fire-and-forget: tag count + Listmonk sync for initial tags
const initialTags = input.tags || [];
if (initialTags.length > 0) {
import('./tags.service').then(({ tagsService }) => {
tagsService.updateTagCounts(initialTags, []).catch(err =>
logger.warn('Failed to update tag counts on create:', err)
);
});
if (input.email) {
import('../../services/listmonk-event-sync.service').then(({ listmonkEventSyncService }) => {
listmonkEventSyncService.onContactTagsChanged({
email: input.email!,
name: contact.displayName || '',
addedTags: initialTags,
removedTags: [],
}).catch(err => logger.debug('Listmonk tag sync failed on create:', err));
});
}
}
return contact;
},
// =========================================================================
// e) UPDATE CONTACT
// =========================================================================
async updateContact(id: string, data: UpdateContactInput) {
const existing = await prisma.contact.findUnique({ where: { id } });
if (!existing) throw new AppError(404, 'Contact not found', 'NOT_FOUND');
const updateData: Prisma.ContactUncheckedUpdateInput = {};
if (data.displayName !== undefined) updateData.displayName = data.displayName;
if (data.firstName !== undefined) updateData.firstName = data.firstName;
if (data.lastName !== undefined) updateData.lastName = data.lastName;
if (data.email !== undefined) updateData.email = data.email === '' ? null : data.email;
if (data.phone !== undefined) updateData.phone = data.phone;
if (data.notes !== undefined) updateData.notes = data.notes;
if (data.supportLevel !== undefined) updateData.supportLevel = data.supportLevel;
if (data.signRequested !== undefined) updateData.signRequested = data.signRequested;
if (data.emailOptOut !== undefined) updateData.emailOptOut = data.emailOptOut;
if (data.smsOptOut !== undefined) updateData.smsOptOut = data.smsOptOut;
if (data.doNotContact !== undefined) updateData.doNotContact = data.doNotContact;
if (data.tags !== undefined) updateData.tags = data.tags as unknown as Prisma.InputJsonValue;
const contact = await prisma.contact.update({
where: { id },
data: updateData,
});
// Sync primary ContactEmail/ContactPhone when denormalized field changes
if (data.email !== undefined) {
const newEmail = data.email === '' ? null : data.email;
if (newEmail) {
const normalized = newEmail.trim().toLowerCase();
await prisma.contactEmail.upsert({
where: { contactId_email: { contactId: id, email: normalized } },
create: { contactId: id, email: normalized, isPrimary: true },
update: { isPrimary: true },
}).catch((err: unknown) => logger.warn('Failed to upsert primary ContactEmail:', err));
}
}
if (data.phone !== undefined && data.phone) {
const normalized = data.phone.replace(/[^0-9+]/g, '') || data.phone.trim();
if (normalized) {
await prisma.contactPhone.upsert({
where: { contactId_phone: { contactId: id, phone: normalized } },
create: { contactId: id, phone: normalized, isPrimary: true },
update: { isPrimary: true },
}).catch((err: unknown) => logger.warn('Failed to upsert primary ContactPhone:', err));
}
}
// Fire-and-forget: tag count updates + Listmonk sync on tag changes
if (data.tags !== undefined) {
const oldTags = Array.isArray(existing.tags) ? (existing.tags as string[]) : [];
const newTags = data.tags || [];
const addedTags = newTags.filter(t => !oldTags.includes(t));
const removedTags = oldTags.filter(t => !newTags.includes(t));
if (addedTags.length > 0 || removedTags.length > 0) {
import('./tags.service').then(({ tagsService }) => {
tagsService.updateTagCounts(addedTags, removedTags).catch(err =>
logger.warn('Failed to update tag counts:', err)
);
});
const email = (data.email !== undefined ? (data.email === '' ? null : data.email) : existing.email);
if (email) {
import('../../services/listmonk-event-sync.service').then(({ listmonkEventSyncService }) => {
listmonkEventSyncService.onContactTagsChanged({
email,
name: contact.displayName || '',
addedTags,
removedTags,
}).catch(err => logger.debug('Listmonk tag sync failed:', err));
});
}
}
}
return contact;
},
// =========================================================================
// f) DELETE CONTACT
// =========================================================================
async deleteContact(id: string) {
const existing = await prisma.contact.findUnique({ where: { id } });
if (!existing) throw new AppError(404, 'Contact not found', 'NOT_FOUND');
await prisma.contact.delete({ where: { id } });
},
// =========================================================================
// g) MERGE CONTACTS
// =========================================================================
async mergeContacts(targetId: string, input: MergeContactInput) {
const target = await prisma.contact.findUnique({ where: { id: targetId } });
if (!target) throw new AppError(404, 'Target contact not found', 'NOT_FOUND');
// Resolve or create source contact
let sourceContact: Awaited<ReturnType<typeof prisma.contact.findUnique>>;
if (input.sourceType === 'contact') {
sourceContact = await prisma.contact.findUnique({ where: { id: input.sourceId } });
if (!sourceContact) throw new AppError(404, 'Source contact not found', 'NOT_FOUND');
} else if (input.sourceType === 'user') {
// Auto-manage the user first
const user = await prisma.user.findUnique({ where: { id: input.sourceId }, select: { id: true, email: true, name: true, phone: true, contact: true } });
if (!user) throw new AppError(404, 'Source user not found', 'NOT_FOUND');
if (user.contact) {
sourceContact = user.contact;
} else {
sourceContact = await prisma.contact.create({
data: {
displayName: user.name || user.email,
email: user.email,
phone: user.phone,
primarySource: 'USER',
userId: user.id,
tags: [] as unknown as Prisma.InputJsonValue,
},
});
}
} else if (input.sourceType === 'addr') {
const addr = await prisma.address.findUnique({
where: { id: input.sourceId },
select: { id: true, firstName: true, lastName: true, email: true, phone: true, contactAddresses: { select: { contact: true } } },
});
if (!addr) throw new AppError(404, 'Source address not found', 'NOT_FOUND');
if (addr.contactAddresses[0]?.contact) {
sourceContact = addr.contactAddresses[0].contact;
} else {
sourceContact = await prisma.contact.create({
data: {
displayName: buildDisplayName(addr.firstName, addr.lastName, addr.email, `Address ${input.sourceId.slice(0, 6)}`),
firstName: addr.firstName,
lastName: addr.lastName,
email: addr.email,
phone: addr.phone,
primarySource: 'ADDRESS_OCCUPANT',
tags: [] as unknown as Prisma.InputJsonValue,
addresses: { create: { addressId: input.sourceId, isPrimary: false } },
},
});
}
} else {
throw new AppError(400, 'Invalid sourceType', 'INVALID_TYPE');
}
if (!sourceContact) throw new AppError(404, 'Source contact not found after resolution', 'NOT_FOUND');
if (sourceContact.id === targetId) throw new AppError(400, 'Cannot merge a contact into itself', 'SELF_MERGE');
// Compute tag union before transaction (needed for post-merge hooks)
const preTargetTags = Array.isArray(target.tags) ? (target.tags as string[]) : [];
const preSourceTags = Array.isArray(sourceContact!.tags) ? (sourceContact!.tags as string[]) : [];
const preMergedTags = [...new Set([...preTargetTags, ...preSourceTags])];
// Perform merge in a transaction
await prisma.$transaction(async (tx) => {
// Transfer ContactAddresses to target
await tx.contactAddress.updateMany({
where: { contactId: sourceContact!.id },
data: { contactId: targetId },
});
// Transfer ContactEmails to target (skip duplicates, demote to non-primary)
const sourceEmails = await tx.contactEmail.findMany({ where: { contactId: sourceContact!.id } });
for (const se of sourceEmails) {
const exists = await tx.contactEmail.findUnique({
where: { contactId_email: { contactId: targetId, email: se.email } },
});
if (!exists) {
await tx.contactEmail.update({
where: { id: se.id },
data: { contactId: targetId, isPrimary: false },
});
} else {
await tx.contactEmail.delete({ where: { id: se.id } });
}
}
// Transfer ContactPhones to target (skip duplicates, demote to non-primary)
const sourcePhones = await tx.contactPhone.findMany({ where: { contactId: sourceContact!.id } });
for (const sp of sourcePhones) {
const exists = await tx.contactPhone.findUnique({
where: { contactId_phone: { contactId: targetId, phone: sp.phone } },
});
if (!exists) {
await tx.contactPhone.update({
where: { id: sp.id },
data: { contactId: targetId, isPrimary: false },
});
} else {
await tx.contactPhone.delete({ where: { id: sp.id } });
}
}
// Transfer ContactConnections (from)
const connectionsFrom = await tx.contactConnection.findMany({ where: { fromContactId: sourceContact!.id } });
for (const conn of connectionsFrom) {
// Skip if connection to target already exists for this type
const exists = await tx.contactConnection.findUnique({
where: { fromContactId_toContactId_type: { fromContactId: targetId, toContactId: conn.toContactId, type: conn.type } },
});
if (!exists && conn.toContactId !== targetId) {
await tx.contactConnection.update({ where: { id: conn.id }, data: { fromContactId: targetId } });
} else {
await tx.contactConnection.delete({ where: { id: conn.id } });
}
}
// Transfer ContactConnections (to)
const connectionsTo = await tx.contactConnection.findMany({ where: { toContactId: sourceContact!.id } });
for (const conn of connectionsTo) {
const exists = await tx.contactConnection.findUnique({
where: { fromContactId_toContactId_type: { fromContactId: conn.fromContactId, toContactId: targetId, type: conn.type } },
});
if (!exists && conn.fromContactId !== targetId) {
await tx.contactConnection.update({ where: { id: conn.id }, data: { toContactId: targetId } });
} else {
await tx.contactConnection.delete({ where: { id: conn.id } });
}
}
// Transfer ContactActivities
await tx.contactActivity.updateMany({
where: { contactId: sourceContact!.id },
data: { contactId: targetId },
});
// Merge tags (union) — uses pre-computed values
const mergedTags = preMergedTags;
// Apply keepFields logic: if source preferred for a field, overwrite target
const keepFields = input.keepFields || {};
const fieldUpdate: Prisma.ContactUncheckedUpdateInput = {
tags: mergedTags as unknown as Prisma.InputJsonValue,
};
if (keepFields.displayName === 'source' && sourceContact!.displayName) fieldUpdate.displayName = sourceContact!.displayName;
if (keepFields.email === 'source' && sourceContact!.email) fieldUpdate.email = sourceContact!.email;
if (keepFields.phone === 'source' && sourceContact!.phone) fieldUpdate.phone = sourceContact!.phone;
if (keepFields.firstName === 'source' && sourceContact!.firstName) fieldUpdate.firstName = sourceContact!.firstName;
if (keepFields.lastName === 'source' && sourceContact!.lastName) fieldUpdate.lastName = sourceContact!.lastName;
if (keepFields.notes === 'source' && sourceContact!.notes) fieldUpdate.notes = sourceContact!.notes;
// Fill in blanks from source regardless
if (!target.email && sourceContact!.email && keepFields.email !== 'target') fieldUpdate.email = sourceContact!.email;
if (!target.phone && sourceContact!.phone && keepFields.phone !== 'target') fieldUpdate.phone = sourceContact!.phone;
if (!target.firstName && sourceContact!.firstName && keepFields.firstName !== 'target') fieldUpdate.firstName = sourceContact!.firstName;
if (!target.lastName && sourceContact!.lastName && keepFields.lastName !== 'target') fieldUpdate.lastName = sourceContact!.lastName;
// Transfer userId if source has one and target doesn't
if (sourceContact!.userId && !target.userId) {
fieldUpdate.userId = sourceContact!.userId;
}
await tx.contact.update({ where: { id: targetId }, data: fieldUpdate });
// Mark source as merged
await tx.contact.update({
where: { id: sourceContact!.id },
data: { mergedIntoId: targetId },
});
// Log activity
await tx.contactActivity.create({
data: {
contactId: targetId,
type: 'CONTACT_MERGED',
title: `Merged with ${sourceContact!.displayName}`,
description: `Contact "${sourceContact!.displayName}" (${sourceContact!.id}) was merged into this contact.`,
metadata: { sourceContactId: sourceContact!.id, sourceDisplayName: sourceContact!.displayName } as unknown as Prisma.InputJsonValue,
},
});
});
// Ensure primary email/phone are synced after merge
await this.syncPrimaryEmail(targetId).catch(err => logger.warn('Failed to sync primary email after merge:', err));
await this.syncPrimaryPhone(targetId).catch(err => logger.warn('Failed to sync primary phone after merge:', err));
// Fire-and-forget: tag count + Listmonk sync for merged tags
const addedToTarget = preMergedTags.filter((t: string) => !preTargetTags.includes(t));
if (addedToTarget.length > 0) {
import('./tags.service').then(({ tagsService }) => {
tagsService.updateTagCounts(addedToTarget, []).catch(err =>
logger.warn('Failed to update tag counts on merge:', err)
);
});
const mergedEmail = target.email || sourceContact?.email;
if (mergedEmail) {
import('../../services/listmonk-event-sync.service').then(({ listmonkEventSyncService }) => {
listmonkEventSyncService.onContactTagsChanged({
email: mergedEmail,
name: target.displayName,
addedTags: addedToTarget,
removedTags: [],
}).catch(err => logger.debug('Listmonk tag sync failed on merge:', err));
});
}
}
// Return updated target
return prisma.contact.findUnique({
where: { id: targetId },
include: { addresses: true, connectionsFrom: true, connectionsTo: true },
});
},
// =========================================================================
// h) GET CONNECTIONS
// =========================================================================
async getConnections(contactId: string) {
const contact = await prisma.contact.findUnique({ where: { id: contactId } });
if (!contact) throw new AppError(404, 'Contact not found', 'NOT_FOUND');
const [connectionsFrom, connectionsTo] = await Promise.all([
prisma.contactConnection.findMany({
where: { fromContactId: contactId },
include: { toContact: { select: { id: true, displayName: true, email: true, phone: true, primarySource: true } } },
}),
prisma.contactConnection.findMany({
where: { toContactId: contactId },
include: { fromContact: { select: { id: true, displayName: true, email: true, phone: true, primarySource: true } } },
}),
]);
const connections = [
...connectionsFrom.map(c => ({
id: c.id,
type: c.type,
label: c.label,
notes: c.notes,
isBidirectional: c.isBidirectional,
direction: 'outgoing' as const,
relatedContact: c.toContact,
createdAt: c.createdAt,
})),
...connectionsTo
.filter(c => !c.isBidirectional) // bidirectional already covered from outgoing
.map(c => ({
id: c.id,
type: c.type,
label: c.label,
notes: c.notes,
isBidirectional: c.isBidirectional,
direction: 'incoming' as const,
relatedContact: c.fromContact,
createdAt: c.createdAt,
})),
// Include bidirectional connections where this is the target (not already in "from")
...connectionsTo
.filter(c => c.isBidirectional)
.map(c => ({
id: c.id,
type: c.type,
label: c.label,
notes: c.notes,
isBidirectional: c.isBidirectional,
direction: 'bidirectional' as const,
relatedContact: c.fromContact,
createdAt: c.createdAt,
})),
];
return connections;
},
// =========================================================================
// i) CREATE CONNECTION
// =========================================================================
async createConnection(contactId: string, input: CreateConnectionInput, createdByUserId: string) {
const fromContact = await prisma.contact.findUnique({ where: { id: contactId } });
if (!fromContact) throw new AppError(404, 'Contact not found', 'NOT_FOUND');
// Auto-manage the target person if needed
let targetContactId: string;
if (input.toPersonType === 'contact') {
const target = await prisma.contact.findUnique({ where: { id: input.toPersonId } });
if (!target) throw new AppError(404, 'Target contact not found', 'NOT_FOUND');
targetContactId = target.id;
} else {
const managed = await this.manageContact({
sourceType: input.toPersonType,
sourceId: input.toPersonId,
}, createdByUserId);
targetContactId = managed.id;
}
if (targetContactId === contactId) {
throw new AppError(400, 'Cannot create a connection to self', 'SELF_CONNECTION');
}
// Check for existing connection
const existing = await prisma.contactConnection.findUnique({
where: {
fromContactId_toContactId_type: {
fromContactId: contactId,
toContactId: targetContactId,
type: input.type,
},
},
});
if (existing) throw new AppError(409, 'Connection already exists', 'CONNECTION_EXISTS');
const connection = await prisma.contactConnection.create({
data: {
fromContactId: contactId,
toContactId: targetContactId,
type: input.type,
label: input.label,
notes: input.notes,
isBidirectional: input.isBidirectional,
},
include: {
fromContact: { select: { id: true, displayName: true } },
toContact: { select: { id: true, displayName: true } },
},
});
return connection;
},
// =========================================================================
// j) DELETE CONNECTION
// =========================================================================
async deleteConnection(id: string) {
const conn = await prisma.contactConnection.findUnique({ where: { id } });
if (!conn) throw new AppError(404, 'Connection not found', 'NOT_FOUND');
await prisma.contactConnection.delete({ where: { id } });
},
// =========================================================================
// k) GRAPH DATA
// =========================================================================
async getGraphData(params: GraphQueryInput) {
const { center, depth, source, tag, minScore } = params;
const MAX_NODES = 200;
// Start from all managed contacts or a specific center
let rootContacts: { id: string; displayName: string; email: string | null; phone: string | null; primarySource: string; supportLevel: string | null; tags: unknown; userId: string | null }[];
if (center) {
const c = await prisma.contact.findUnique({
where: { id: center, mergedIntoId: null },
select: { id: true, displayName: true, email: true, phone: true, primarySource: true, supportLevel: true, tags: true, userId: true },
});
if (!c) throw new AppError(404, 'Center contact not found', 'NOT_FOUND');
rootContacts = [c];
} else {
const where: Prisma.ContactWhereInput = { mergedIntoId: null };
if (source && source !== 'USER') where.primarySource = source;
rootContacts = await prisma.contact.findMany({
where,
select: { id: true, displayName: true, email: true, phone: true, primarySource: true, supportLevel: true, tags: true, userId: true },
take: MAX_NODES,
orderBy: { updatedAt: 'desc' },
});
}
const nodesMap = new Map<string, GraphNode>();
const edges: GraphEdge[] = [];
const visited = new Set<string>();
// Track which user IDs already have Contact records
const contactUserIds = new Set<string>();
// BFS
let frontier = rootContacts.map(c => c.id);
let currentDepth = 0;
// Add initial contact nodes (using unified IDs)
for (const c of rootContacts) {
const tags = Array.isArray(c.tags) ? (c.tags as string[]) : [];
if (tag && !tags.includes(tag)) continue;
if (c.userId) contactUserIds.add(c.userId);
nodesMap.set(`contact:${c.id}`, {
id: `contact:${c.id}`,
contactId: c.id,
displayName: c.displayName,
email: c.email,
source: c.primarySource,
supportLevel: c.supportLevel,
tags,
engagementScore: null,
});
}
while (currentDepth < depth && frontier.length > 0 && nodesMap.size < MAX_NODES) {
const nextFrontier: string[] = [];
const connections = await prisma.contactConnection.findMany({
where: {
OR: [
{ fromContactId: { in: frontier } },
{ toContactId: { in: frontier } },
],
},
include: {
fromContact: { select: { id: true, displayName: true, email: true, phone: true, primarySource: true, supportLevel: true, tags: true, mergedIntoId: true, userId: true } },
toContact: { select: { id: true, displayName: true, email: true, phone: true, primarySource: true, supportLevel: true, tags: true, mergedIntoId: true, userId: true } },
},
});
for (const conn of connections) {
if (conn.fromContact.mergedIntoId || conn.toContact.mergedIntoId) continue;
// Add edge with unified IDs (dedup by id)
if (!edges.find(e => e.id === conn.id)) {
edges.push({
id: conn.id,
source: `contact:${conn.fromContactId}`,
target: `contact:${conn.toContactId}`,
type: conn.type,
label: conn.label,
isBidirectional: conn.isBidirectional,
});
}
// Add neighbor nodes
for (const neighbor of [conn.fromContact, conn.toContact]) {
const nodeId = `contact:${neighbor.id}`;
if (nodesMap.has(nodeId) || visited.has(neighbor.id)) continue;
if (nodesMap.size >= MAX_NODES) break;
const nTags = Array.isArray(neighbor.tags) ? (neighbor.tags as string[]) : [];
if (tag && !nTags.includes(tag)) continue;
if (source && source !== 'USER' && neighbor.primarySource !== source) continue;
if (neighbor.userId) contactUserIds.add(neighbor.userId);
nodesMap.set(nodeId, {
id: nodeId,
contactId: neighbor.id,
displayName: neighbor.displayName,
email: neighbor.email,
source: neighbor.primarySource,
supportLevel: neighbor.supportLevel,
tags: nTags,
engagementScore: null,
});
nextFrontier.push(neighbor.id);
}
}
for (const id of frontier) visited.add(id);
frontier = nextFrontier;
currentDepth++;
}
// -----------------------------------------------------------------------
// 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.
// -----------------------------------------------------------------------
// 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);
}
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);
}
}
}
// Apply minScore filter if set (requires computing engagement for each node)
let nodes = Array.from(nodesMap.values());
if (minScore !== undefined && minScore > 0) {
const scored = await Promise.all(
nodes.map(async (n) => {
const eng = await this.getEngagementSummary(n.email, null, null);
n.engagementScore = eng.overall.engagementScore;
return n;
})
);
nodes = scored.filter(n => (n.engagementScore || 0) >= minScore);
// Also filter edges to only include nodes that remain
const nodeIds = new Set(nodes.map(n => n.id));
const filteredEdges = edges.filter(e => nodeIds.has(e.source) && nodeIds.has(e.target));
return { nodes, edges: filteredEdges };
}
return { nodes, edges };
},
// =========================================================================
// l) STATS
// =========================================================================
async getStats() {
const [userCount, addressCount, campaignEmailCount, shiftSignupCount, smsCount, orderCount, manualContactCount, managedCount, withEmailCount, withPhoneCount] = await Promise.all([
prisma.user.count(),
prisma.address.count({ where: { OR: [{ firstName: { not: null } }, { lastName: { not: null } }] } }),
prisma.campaignEmail.count({ where: { userEmail: { not: null } } }),
prisma.shiftSignup.count(),
prisma.smsContactListEntry.count(),
prisma.order.count(),
// Standalone contacts not linked to any source table (manually created)
prisma.contact.count({ where: { mergedIntoId: null, primarySource: 'MANUAL' } }),
prisma.contact.count({ where: { mergedIntoId: null } }),
prisma.contact.count({ where: { email: { not: null }, mergedIntoId: null } }),
prisma.contact.count({ where: { phone: { not: null }, mergedIntoId: null } }),
]);
return {
total: userCount + addressCount + campaignEmailCount + shiftSignupCount + smsCount + orderCount + manualContactCount,
bySource: {
USER: userCount,
ADDRESS_OCCUPANT: addressCount,
CAMPAIGN_SENDER: campaignEmailCount,
SHIFT_SIGNUP: shiftSignupCount,
SMS_CONTACT: smsCount,
DONATION: orderCount,
MANUAL: manualContactCount,
},
managed: managedCount,
withEmail: withEmailCount,
withPhone: withPhoneCount,
};
},
// =========================================================================
// m) DUPLICATES
// =========================================================================
async getDuplicates() {
const duplicates: { personA: string; personB: string; matchType: string; confidence: number }[] = [];
// Find emails that appear in multiple source tables
// Get all User emails
const userEmails = await prisma.user.findMany({ select: { id: true, email: true, name: true } });
const userEmailSet = new Map(userEmails.map(u => [normalizeEmail(u.email)!, { id: u.id, name: u.name, email: u.email }]));
// Check addresses for matching user emails
const addressesWithEmail = await prisma.address.findMany({
where: { email: { not: null } },
select: { id: true, email: true, firstName: true, lastName: true },
});
for (const addr of addressesWithEmail) {
const ne = normalizeEmail(addr.email);
if (ne && userEmailSet.has(ne)) {
const user = userEmailSet.get(ne)!;
duplicates.push({
personA: `user:${user.id}`,
personB: `addr:${addr.id}`,
matchType: 'email',
confidence: 95,
});
}
}
// Check shift signups for matching user emails
const signupEmails = await prisma.shiftSignup.findMany({
distinct: ['userEmail'],
select: { userEmail: true, userName: true },
});
for (const su of signupEmails) {
const ne = normalizeEmail(su.userEmail);
if (ne && userEmailSet.has(ne)) continue; // User match is expected (linked accounts)
// Check if same email in addresses
const matchAddr = addressesWithEmail.find(a => normalizeEmail(a.email) === ne);
if (matchAddr) {
duplicates.push({
personA: `signup:${su.userEmail}`,
personB: `addr:${matchAddr.id}`,
matchType: 'email',
confidence: 85,
});
}
}
// Check for similar names across tables (User name matches Address firstName+lastName)
for (const user of userEmails) {
if (!user.name) continue;
const userNameLower = user.name.toLowerCase().trim();
for (const addr of addressesWithEmail) {
if (!addr.firstName && !addr.lastName) continue;
const addrName = buildDisplayName(addr.firstName, addr.lastName, null, '').toLowerCase().trim();
if (addrName && addrName === userNameLower && normalizeEmail(addr.email) !== normalizeEmail(user.email)) {
duplicates.push({
personA: `user:${user.id}`,
personB: `addr:${addr.id}`,
matchType: 'name',
confidence: 60,
});
}
}
}
// Limit results for performance
return duplicates.slice(0, 100);
},
// =========================================================================
// n) GET HOUSEHOLD
// =========================================================================
async getHousehold(locationId: string) {
const location = await prisma.location.findUnique({
where: { id: locationId },
select: { id: true, address: true },
});
if (!location) throw new AppError(404, 'Location not found', 'NOT_FOUND');
const addresses = await prisma.address.findMany({
where: { locationId },
select: {
id: true, unitNumber: true, firstName: true, lastName: true,
email: true, phone: true, supportLevel: true, notes: true,
contactAddresses: {
include: {
contact: {
select: {
id: true, displayName: true, email: true, phone: true,
tags: true, supportLevel: true, notes: true,
},
},
},
},
},
orderBy: { unitNumber: 'asc' },
});
const household = addresses.map(addr => ({
addressId: addr.id,
unitNumber: addr.unitNumber,
occupant: {
displayName: buildDisplayName(addr.firstName, addr.lastName, addr.email, `Unit ${addr.unitNumber || 'Main'}`),
firstName: addr.firstName,
lastName: addr.lastName,
email: addr.email,
phone: addr.phone,
supportLevel: addr.supportLevel,
notes: addr.notes,
},
contact: addr.contactAddresses[0]?.contact || null,
}));
return { location, household };
},
// =========================================================================
// o) DETECT HOUSEHOLD
// =========================================================================
async detectHousehold(locationId: string) {
const location = await prisma.location.findUnique({ where: { id: locationId } });
if (!location) throw new AppError(404, 'Location not found', 'NOT_FOUND');
// Find all managed contacts at this location
const contactAddresses = await prisma.contactAddress.findMany({
where: {
address: { locationId },
},
include: {
contact: { select: { id: true, displayName: true, mergedIntoId: true } },
},
});
const activeContacts = contactAddresses
.filter(ca => !ca.contact.mergedIntoId)
.map(ca => ca.contact);
if (activeContacts.length < 2) {
return { created: 0, message: 'Need at least 2 managed contacts at this location to detect household.' };
}
let created = 0;
// Create HOUSEHOLD connections between all pairs
for (let i = 0; i < activeContacts.length; i++) {
for (let j = i + 1; j < activeContacts.length; j++) {
const existing = await prisma.contactConnection.findUnique({
where: {
fromContactId_toContactId_type: {
fromContactId: activeContacts[i].id,
toContactId: activeContacts[j].id,
type: 'HOUSEHOLD',
},
},
});
if (!existing) {
// Also check reverse direction
const existingReverse = await prisma.contactConnection.findUnique({
where: {
fromContactId_toContactId_type: {
fromContactId: activeContacts[j].id,
toContactId: activeContacts[i].id,
type: 'HOUSEHOLD',
},
},
});
if (!existingReverse) {
await prisma.contactConnection.create({
data: {
fromContactId: activeContacts[i].id,
toContactId: activeContacts[j].id,
type: 'HOUSEHOLD',
label: `Household at ${location.address}`,
isBidirectional: true,
},
});
created++;
}
}
}
}
return { created, message: `Created ${created} household connection(s) between ${activeContacts.length} contacts.` };
},
// =========================================================================
// p) ACTIVITY TIMELINE
// =========================================================================
async getActivity(type: string, id: string, params: ActivityListInput) {
const { page, limit, type: activityType } = params;
// Resolve the person's identifiers
let email: string | null = null;
let phone: string | null = null;
let userId: string | null = null;
let contactId: string | null = null;
if (type === 'user') {
const user = await prisma.user.findUnique({ where: { id }, select: { id: true, email: true, phone: true, contact: true } });
if (!user) throw new AppError(404, 'User not found', 'NOT_FOUND');
email = user.email;
phone = user.phone;
userId = user.id;
contactId = user.contact?.id || null;
} else if (type === 'addr') {
const addr = await prisma.address.findUnique({
where: { id },
select: { id: true, email: true, phone: true, contactAddresses: { select: { contactId: true } } },
});
if (!addr) throw new AppError(404, 'Address not found', 'NOT_FOUND');
email = addr.email;
phone = addr.phone;
contactId = addr.contactAddresses[0]?.contactId || null;
} else if (type === 'contact') {
const contact = await prisma.contact.findUnique({ where: { id }, select: { id: true, email: true, phone: true, userId: true } });
if (!contact) throw new AppError(404, 'Contact not found', 'NOT_FOUND');
email = contact.email;
phone = contact.phone;
userId = contact.userId;
contactId = contact.id;
} else {
throw new AppError(400, 'Invalid person type', 'INVALID_TYPE');
}
// Query activities from multiple sources in parallel
const activities: ActivityItem[] = [];
const queries: Promise<void>[] = [];
// Campaign emails
if (email && (!activityType || activityType === 'EMAIL_SENT')) {
queries.push(
prisma.campaignEmail.findMany({
where: { userEmail: { equals: email, mode: 'insensitive' } },
select: { id: true, subject: true, recipientName: true, recipientEmail: true, sentAt: true, campaignSlug: true },
orderBy: { sentAt: 'desc' },
take: 50,
}).then(rows => {
for (const r of rows) {
activities.push({
id: `email:${r.id}`,
type: 'EMAIL_SENT',
title: `Sent email: ${r.subject}`,
description: `To ${r.recipientName || r.recipientEmail}`,
occurredAt: r.sentAt.toISOString(),
metadata: { campaignSlug: r.campaignSlug },
});
}
})
);
}
// Representative responses
if (email && (!activityType || activityType === 'RESPONSE_SUBMITTED')) {
queries.push(
prisma.representativeResponse.findMany({
where: { submittedByEmail: { equals: email, mode: 'insensitive' } },
select: { id: true, representativeName: true, responseType: true, createdAt: true, campaignSlug: true },
orderBy: { createdAt: 'desc' },
take: 50,
}).then(rows => {
for (const r of rows) {
activities.push({
id: `response:${r.id}`,
type: 'RESPONSE_SUBMITTED',
title: `Response from ${r.representativeName}`,
description: `Type: ${r.responseType}`,
occurredAt: r.createdAt.toISOString(),
metadata: { campaignSlug: r.campaignSlug },
});
}
})
);
}
// Shift signups
if (email && (!activityType || activityType === 'SHIFT_SIGNUP')) {
queries.push(
prisma.shiftSignup.findMany({
where: { userEmail: { equals: email, mode: 'insensitive' } },
select: { id: true, shiftTitle: true, signupDate: true, status: true },
orderBy: { signupDate: 'desc' },
take: 50,
}).then(rows => {
for (const r of rows) {
activities.push({
id: `shift:${r.id}`,
type: 'SHIFT_SIGNUP',
title: `Signed up for shift: ${r.shiftTitle || 'Untitled'}`,
description: `Status: ${r.status}`,
occurredAt: r.signupDate.toISOString(),
});
}
})
);
}
// Canvass visits
if (userId && (!activityType || activityType === 'CANVASS_VISIT')) {
queries.push(
prisma.canvassVisit.findMany({
where: { userId },
select: { id: true, outcome: true, visitedAt: true, notes: true, address: { select: { location: { select: { address: true } } } } },
orderBy: { visitedAt: 'desc' },
take: 50,
}).then(rows => {
for (const r of rows) {
activities.push({
id: `canvass:${r.id}`,
type: 'CANVASS_VISIT',
title: `Canvass visit: ${r.outcome}`,
description: r.address?.location?.address || null,
occurredAt: r.visitedAt.toISOString(),
});
}
})
);
}
// Orders/Donations
if (email && (!activityType || activityType === 'DONATION' || activityType === 'PURCHASE')) {
queries.push(
prisma.order.findMany({
where: { buyerEmail: { equals: email, mode: 'insensitive' } },
select: { id: true, type: true, amountCAD: true, status: true, completedAt: true, createdAt: true, buyerName: true },
orderBy: { createdAt: 'desc' },
take: 50,
}).then(rows => {
for (const r of rows) {
const isDonation = r.type === 'donation';
const actType = isDonation ? 'DONATION' : 'PURCHASE';
if (activityType && activityType !== actType) return;
activities.push({
id: `order:${r.id}`,
type: actType,
title: `${isDonation ? 'Donation' : 'Purchase'}: $${(r.amountCAD / 100).toFixed(2)}`,
description: `Status: ${r.status}`,
occurredAt: (r.completedAt || r.createdAt).toISOString(),
});
}
})
);
}
// SMS messages
if (phone && (!activityType || activityType === 'SMS_SENT' || activityType === 'SMS_RECEIVED')) {
queries.push(
prisma.smsMessage.findMany({
where: { phone: normalizePhone(phone)! },
select: { id: true, direction: true, message: true, sentAt: true, status: true },
orderBy: { sentAt: 'desc' },
take: 50,
}).then(rows => {
for (const r of rows) {
const smsType = r.direction === 'OUTBOUND' ? 'SMS_SENT' : 'SMS_RECEIVED';
if (activityType && activityType !== smsType) return;
activities.push({
id: `sms:${r.id}`,
type: smsType,
title: `SMS ${r.direction === 'OUTBOUND' ? 'sent' : 'received'}`,
description: r.message.length > 100 ? r.message.slice(0, 100) + '...' : r.message,
occurredAt: r.sentAt.toISOString(),
});
}
})
);
}
// Video views
if (userId && (!activityType || activityType === 'VIDEO_VIEW')) {
queries.push(
prisma.videoView.findMany({
where: { userId },
select: { id: true, watchTimeSeconds: true, completed: true, createdAt: true, video: { select: { title: true } } },
orderBy: { createdAt: 'desc' },
take: 50,
}).then(rows => {
for (const r of rows) {
activities.push({
id: `video:${r.id}`,
type: 'VIDEO_VIEW',
title: `Watched: ${r.video?.title || 'Unknown video'}`,
description: `${r.watchTimeSeconds}s watched${r.completed ? ' (completed)' : ''}`,
occurredAt: r.createdAt.toISOString(),
});
}
})
);
}
// ContactActivity records (notes, merges, etc.)
if (contactId && (!activityType || activityType === 'NOTE_ADDED' || activityType === 'CONTACT_MERGED')) {
queries.push(
prisma.contactActivity.findMany({
where: {
contactId,
...(activityType ? { type: activityType } : {}),
},
select: { id: true, type: true, title: true, description: true, occurredAt: true, metadata: true },
orderBy: { occurredAt: 'desc' },
take: 50,
}).then(rows => {
for (const r of rows) {
activities.push({
id: `activity:${r.id}`,
type: r.type,
title: r.title,
description: r.description,
occurredAt: r.occurredAt.toISOString(),
metadata: r.metadata,
});
}
})
);
}
await Promise.all(queries);
// Sort by date DESC and paginate
activities.sort((a, b) => new Date(b.occurredAt).getTime() - new Date(a.occurredAt).getTime());
const total = activities.length;
const totalPages = Math.ceil(total / limit);
const start = (page - 1) * limit;
const paged = activities.slice(start, start + limit);
return {
activities: paged,
pagination: { page, limit, total, totalPages },
};
},
// =========================================================================
// q-email) CONTACT EMAIL MANAGEMENT
// =========================================================================
/** Sync the denormalized Contact.email field with the primary ContactEmail */
async syncPrimaryEmail(contactId: string, tx?: Prisma.TransactionClient) {
const db = tx || prisma;
const primary = await db.contactEmail.findFirst({
where: { contactId, isPrimary: true },
});
await db.contact.update({
where: { id: contactId },
data: { email: primary?.email || null },
});
},
/** Sync the denormalized Contact.phone field with the primary ContactPhone */
async syncPrimaryPhone(contactId: string, tx?: Prisma.TransactionClient) {
const db = tx || prisma;
const primary = await db.contactPhone.findFirst({
where: { contactId, isPrimary: true },
});
await db.contact.update({
where: { id: contactId },
data: { phone: primary?.phone || null },
});
},
async getEmails(contactId: string) {
const contact = await prisma.contact.findUnique({ where: { id: contactId } });
if (!contact) throw new AppError(404, 'Contact not found', 'NOT_FOUND');
if (contact.mergedIntoId) throw new AppError(400, 'Contact has been merged', 'MERGED_CONTACT');
return prisma.contactEmail.findMany({
where: { contactId },
orderBy: [{ isPrimary: 'desc' }, { createdAt: 'asc' }],
});
},
async addEmail(contactId: string, input: AddContactEmailInput) {
const contact = await prisma.contact.findUnique({ where: { id: contactId } });
if (!contact) throw new AppError(404, 'Contact not found', 'NOT_FOUND');
if (contact.mergedIntoId) throw new AppError(400, 'Contact has been merged', 'MERGED_CONTACT');
// Cap at 10
const count = await prisma.contactEmail.count({ where: { contactId } });
if (count >= 10) throw new AppError(400, 'Maximum of 10 emails per contact', 'LIMIT_EXCEEDED');
const normalizedEmail = input.email.trim().toLowerCase();
// If first email or explicitly primary, set as primary
const shouldBePrimary = count === 0 || input.isPrimary === true;
await prisma.$transaction(async (tx) => {
if (shouldBePrimary) {
await tx.contactEmail.updateMany({
where: { contactId, isPrimary: true },
data: { isPrimary: false },
});
}
await tx.contactEmail.create({
data: {
contactId,
email: normalizedEmail,
label: input.label || null,
isPrimary: shouldBePrimary,
},
});
if (shouldBePrimary) {
await this.syncPrimaryEmail(contactId, tx);
}
});
return prisma.contactEmail.findMany({
where: { contactId },
orderBy: [{ isPrimary: 'desc' }, { createdAt: 'asc' }],
});
},
async removeEmail(contactId: string, emailId: string) {
const record = await prisma.contactEmail.findUnique({ where: { id: emailId } });
if (!record) throw new AppError(404, 'Email record not found', 'NOT_FOUND');
if (record.contactId !== contactId) throw new AppError(403, 'Email does not belong to this contact', 'FORBIDDEN');
const wasPrimary = record.isPrimary;
await prisma.$transaction(async (tx) => {
await tx.contactEmail.delete({ where: { id: emailId } });
// Auto-promote oldest remaining if was primary
if (wasPrimary) {
const oldest = await tx.contactEmail.findFirst({
where: { contactId },
orderBy: { createdAt: 'asc' },
});
if (oldest) {
await tx.contactEmail.update({
where: { id: oldest.id },
data: { isPrimary: true },
});
}
await this.syncPrimaryEmail(contactId, tx);
}
});
},
async setPrimaryEmail(contactId: string, emailId: string) {
const record = await prisma.contactEmail.findUnique({ where: { id: emailId } });
if (!record) throw new AppError(404, 'Email record not found', 'NOT_FOUND');
if (record.contactId !== contactId) throw new AppError(403, 'Email does not belong to this contact', 'FORBIDDEN');
await prisma.$transaction(async (tx) => {
await tx.contactEmail.updateMany({
where: { contactId, isPrimary: true },
data: { isPrimary: false },
});
await tx.contactEmail.update({
where: { id: emailId },
data: { isPrimary: true },
});
await this.syncPrimaryEmail(contactId, tx);
});
},
// =========================================================================
// q-phone) CONTACT PHONE MANAGEMENT
// =========================================================================
async getPhones(contactId: string) {
const contact = await prisma.contact.findUnique({ where: { id: contactId } });
if (!contact) throw new AppError(404, 'Contact not found', 'NOT_FOUND');
if (contact.mergedIntoId) throw new AppError(400, 'Contact has been merged', 'MERGED_CONTACT');
return prisma.contactPhone.findMany({
where: { contactId },
orderBy: [{ isPrimary: 'desc' }, { createdAt: 'asc' }],
});
},
async addPhone(contactId: string, input: AddContactPhoneInput) {
const contact = await prisma.contact.findUnique({ where: { id: contactId } });
if (!contact) throw new AppError(404, 'Contact not found', 'NOT_FOUND');
if (contact.mergedIntoId) throw new AppError(400, 'Contact has been merged', 'MERGED_CONTACT');
const count = await prisma.contactPhone.count({ where: { contactId } });
if (count >= 10) throw new AppError(400, 'Maximum of 10 phones per contact', 'LIMIT_EXCEEDED');
const normalizedPhone = input.phone.replace(/[^0-9+]/g, '') || input.phone.trim();
const shouldBePrimary = count === 0 || input.isPrimary === true;
await prisma.$transaction(async (tx) => {
if (shouldBePrimary) {
await tx.contactPhone.updateMany({
where: { contactId, isPrimary: true },
data: { isPrimary: false },
});
}
await tx.contactPhone.create({
data: {
contactId,
phone: normalizedPhone,
label: input.label || null,
isPrimary: shouldBePrimary,
},
});
if (shouldBePrimary) {
await this.syncPrimaryPhone(contactId, tx);
}
});
return prisma.contactPhone.findMany({
where: { contactId },
orderBy: [{ isPrimary: 'desc' }, { createdAt: 'asc' }],
});
},
async removePhone(contactId: string, phoneId: string) {
const record = await prisma.contactPhone.findUnique({ where: { id: phoneId } });
if (!record) throw new AppError(404, 'Phone record not found', 'NOT_FOUND');
if (record.contactId !== contactId) throw new AppError(403, 'Phone does not belong to this contact', 'FORBIDDEN');
const wasPrimary = record.isPrimary;
await prisma.$transaction(async (tx) => {
await tx.contactPhone.delete({ where: { id: phoneId } });
if (wasPrimary) {
const oldest = await tx.contactPhone.findFirst({
where: { contactId },
orderBy: { createdAt: 'asc' },
});
if (oldest) {
await tx.contactPhone.update({
where: { id: oldest.id },
data: { isPrimary: true },
});
}
await this.syncPrimaryPhone(contactId, tx);
}
});
},
async setPrimaryPhone(contactId: string, phoneId: string) {
const record = await prisma.contactPhone.findUnique({ where: { id: phoneId } });
if (!record) throw new AppError(404, 'Phone record not found', 'NOT_FOUND');
if (record.contactId !== contactId) throw new AppError(403, 'Phone does not belong to this contact', 'FORBIDDEN');
await prisma.$transaction(async (tx) => {
await tx.contactPhone.updateMany({
where: { contactId, isPrimary: true },
data: { isPrimary: false },
});
await tx.contactPhone.update({
where: { id: phoneId },
data: { isPrimary: true },
});
await this.syncPrimaryPhone(contactId, tx);
});
},
// =========================================================================
// q-addr) CONTACT ADDRESS MANAGEMENT
// =========================================================================
async getAddresses(contactId: string) {
const contact = await prisma.contact.findUnique({ where: { id: contactId } });
if (!contact) throw new AppError(404, 'Contact not found', 'NOT_FOUND');
if (contact.mergedIntoId) throw new AppError(400, 'Contact has been merged', 'MERGED_CONTACT');
const contactAddresses = await prisma.contactAddress.findMany({
where: { contactId },
include: {
address: {
include: {
location: {
select: {
id: true,
address: true,
latitude: true,
longitude: true,
postalCode: true,
province: true,
geocodeConfidence: true,
},
},
},
},
},
orderBy: { createdAt: 'asc' },
});
return contactAddresses;
},
async addAddress(contactId: string, input: AddContactAddressInput, userId: string | null) {
const contact = await prisma.contact.findUnique({ where: { id: contactId } });
if (!contact) throw new AppError(404, 'Contact not found', 'NOT_FOUND');
if (contact.mergedIntoId) throw new AppError(400, 'Contact has been merged', 'MERGED_CONTACT');
// Check autoSyncPeopleToMap setting
const settings = await prisma.siteSettings.findFirst();
const shouldAddToMap = input.addToMap || settings?.autoSyncPeopleToMap || false;
let addressId: string;
if (shouldAddToMap) {
// Use locationsService.create() to create Location + Address with auto-geocoding
const { locationsService } = await import('../map/locations/locations.service');
// Don't copy contact's personal info (name/email/phone) into the Address
// occupant fields — that would cause the address to appear as a separate
// "ADDRESS_OCCUPANT" person in listPeople, overriding the contact's
// original source. The ContactAddress junction table already links them.
const location = await locationsService.create(
{
address: input.address,
unitNumber: input.unitNumber,
sign: false,
},
userId,
);
// Get the newly created Address from this Location
const newAddress = await prisma.address.findFirst({
where: { locationId: location.id },
orderBy: { createdAt: 'desc' },
});
if (!newAddress) {
throw new AppError(500, 'Failed to create address record', 'ADDRESS_CREATE_FAILED');
}
addressId = newAddress.id;
} else {
// Create standalone Location (just address string + geocode) and Address
const { geocodingService } = await import('../map/geocoding/geocoding.service');
const geocodeResult = await geocodingService.geocode(input.address);
const location = await prisma.location.create({
data: {
address: input.address,
latitude: geocodeResult ? (geocodeResult.latitude as unknown as Prisma.Decimal) : (0 as unknown as Prisma.Decimal),
longitude: geocodeResult ? (geocodeResult.longitude as unknown as Prisma.Decimal) : (0 as unknown as Prisma.Decimal),
geocodeConfidence: geocodeResult?.confidence || null,
geocodeProvider: geocodeResult?.provider || null,
createdByUserId: userId,
},
});
const address = await prisma.address.create({
data: {
locationId: location.id,
unitNumber: input.unitNumber || null,
createdByUserId: userId,
},
});
addressId = address.id;
}
// If setting as primary, unset other primaries first
if (input.isPrimary) {
await prisma.contactAddress.updateMany({
where: { contactId, isPrimary: true },
data: { isPrimary: false },
});
}
// Create the ContactAddress junction record
const contactAddress = await prisma.contactAddress.create({
data: {
contactId,
addressId,
isPrimary: input.isPrimary,
},
include: {
address: {
include: {
location: {
select: {
id: true,
address: true,
latitude: true,
longitude: true,
postalCode: true,
province: true,
geocodeConfidence: true,
},
},
},
},
},
});
// Log activity
await prisma.contactActivity.create({
data: {
contactId,
type: 'NOTE_ADDED',
title: 'Address added',
description: `Address added: ${input.address}${input.unitNumber ? ` Unit ${input.unitNumber}` : ''}`,
},
});
return contactAddress;
},
async removeAddress(contactId: string, contactAddressId: string) {
const contactAddress = await prisma.contactAddress.findUnique({
where: { id: contactAddressId },
include: {
address: {
include: { location: { select: { address: true } } },
},
},
});
if (!contactAddress) throw new AppError(404, 'Contact address link not found', 'NOT_FOUND');
if (contactAddress.contactId !== contactId) {
throw new AppError(403, 'Contact address does not belong to this contact', 'FORBIDDEN');
}
const addressStr = contactAddress.address?.location?.address || 'Unknown';
// Delete only the junction record — Location/Address stay on the map
await prisma.contactAddress.delete({ where: { id: contactAddressId } });
// Log activity
await prisma.contactActivity.create({
data: {
contactId,
type: 'NOTE_ADDED',
title: 'Address unlinked',
description: `Address unlinked: ${addressStr}`,
},
});
},
};