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(); const contactByEmail = new Map(); const contactByPhone = new Map(); const contactByAddressId = new Map(); 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(); 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 = {}; let contact: Awaited> | 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 = { 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>; 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(); const edges: GraphEdge[] = []; const visited = new Set(); // Track which user IDs already have Contact records const contactUserIds = new Set(); // 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(); const representedPhones = new Set(); 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[] = []; // 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}`, }, }); }, };