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
2690 lines
99 KiB
TypeScript
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}`,
|
|
},
|
|
});
|
|
},
|
|
};
|