import {type Contact, Prisma} from '@plunk/db'; import type {CursorPaginatedResponse, FilterCondition, FilterGroup} from '@plunk/types'; import {toPrismaJson} from '@plunk/types'; import {prisma} from '../database/prisma.js'; import {HttpException} from '../exceptions/index.js'; import {EventService} from './EventService.js'; export class ContactService { /** * Get all contacts for a project with cursor-based pagination * Uses cursor pagination for better performance with large datasets */ public static async list( projectId: string, limit = 20, cursor?: string, search?: string, ): Promise> { const where: Prisma.ContactWhereInput = { projectId, ...(search ? { email: { contains: search, mode: 'insensitive' as const, }, } : {}), }; // Fetch one extra to determine if there are more results // Use composite ordering (createdAt + id) to ensure stable pagination // This prevents skipping records when multiple contacts have the same createdAt const contacts = await prisma.contact.findMany({ where, take: limit + 1, skip: cursor ? 1 : 0, cursor: cursor ? {id: cursor} : undefined, orderBy: [ {createdAt: 'desc'}, {id: 'desc'}, // Secondary sort by id for stable cursor pagination ], }); const hasMore = contacts.length > limit; const results = hasMore ? contacts.slice(0, -1) : contacts; const nextCursor = hasMore ? results[results.length - 1]?.id : undefined; // Get total count only on first page for better performance const total = !cursor ? await prisma.contact.count({where}) : 0; return { data: results, total, cursor: nextCursor, hasMore, }; } /** * Get a single contact by ID */ public static async get(projectId: string, contactId: string): Promise { const contact = await prisma.contact.findFirst({ where: { id: contactId, projectId, }, }); if (!contact) { throw new HttpException(404, 'Contact not found'); } return contact; } /** * Find a contact by email (returns null if not found) */ public static async findByEmail(projectId: string, email: string): Promise { return prisma.contact.findFirst({ where: { projectId, email, }, }); } /** * Create a new contact * Uses unique constraint violation to check for duplicates (more efficient) */ public static async create( projectId: string, data: {email: string; data?: Prisma.JsonValue; subscribed?: boolean}, ): Promise { try { return await prisma.contact.create({ data: { projectId, email: data.email, data: data.data ?? Prisma.JsonNull, subscribed: data.subscribed ?? true, }, }); } catch (error) { // Check if this is a unique constraint violation (P2002) if (error instanceof Error && 'code' in error && error.code === 'P2002') { throw new HttpException(409, 'Contact with this email already exists in this project'); } throw error; } } /** * Update a contact * Uses unique constraint violation to check for duplicates (more efficient) */ public static async update( projectId: string, contactId: string, data: {email?: string; data?: Prisma.JsonValue; subscribed?: boolean}, ): Promise { // First verify contact exists and belongs to project const existing = await this.get(projectId, contactId); const updateData: Prisma.ContactUpdateInput = {}; if (data.email !== undefined) { updateData.email = data.email; } if (data.data !== undefined) { updateData.data = data.data === null ? Prisma.JsonNull : data.data; } if (data.subscribed !== undefined) { updateData.subscribed = data.subscribed; } // Track subscription status change const isSubscriptionChanging = data.subscribed !== undefined && existing.subscribed !== data.subscribed; const wasSubscribed = existing.subscribed; try { const updated = await prisma.contact.update({ where: {id: contactId}, data: updateData, }); // Track subscription event if status changed if (isSubscriptionChanging) { if (data.subscribed && !wasSubscribed) { await EventService.trackEvent(projectId, 'contact.subscribed', contactId); } else if (!data.subscribed && wasSubscribed) { await EventService.trackEvent(projectId, 'contact.unsubscribed', contactId); } } return updated; } catch (error) { // Check if this is a unique constraint violation (P2002) if (error instanceof Error && 'code' in error && error.code === 'P2002') { throw new HttpException(409, 'Contact with this email already exists in this project'); } throw error; } } /** * Delete a contact */ public static async delete(projectId: string, contactId: string): Promise { // First verify contact exists and belongs to project await this.get(projectId, contactId); await prisma.contact.delete({ where: {id: contactId}, }); } /** * Get contact count for a project */ public static async count(projectId: string): Promise { return prisma.contact.count({ where: {projectId}, }); } /** * Upsert a contact (create or update) with metadata merging * Supports persistent and non-persistent data fields * Reserved fields: plunk_id, plunk_email */ public static async upsert( projectId: string, email: string, data?: Record, subscribed?: boolean, defaultSubscribed: boolean = true, ): Promise { // Find existing contact const existing = await prisma.contact.findFirst({ where: { projectId, email, }, }); // Process data to merge with existing data let mergedData: Record = {}; if (existing?.data && typeof existing.data === 'object' && !Array.isArray(existing.data)) { // Start with existing data mergedData = {...existing.data}; } // Merge new data (if provided) if (data) { for (const [key, value] of Object.entries(data)) { // Skip reserved system-generated fields // These fields are dynamically added during template rendering and cannot be overridden const reservedFields = [ 'plunk_id', 'plunk_email', 'id', 'email', 'unsubscribeUrl', 'subscribeUrl', 'manageUrl', ]; if (reservedFields.includes(key)) { continue; } // Validate locale field (special user-settable field) // Only validate type - any locale string is accepted since we default to English if unsupported if (key === 'locale') { if (value !== null && value !== undefined && typeof value !== 'string') { throw new HttpException(400, 'Locale must be a string'); } } // Handle non-persistent data format: { value: "...", persistent: false } if ( typeof value === 'object' && value !== null && 'value' in value && 'persistent' in value && value.persistent === false ) { // Non-persistent fields are not stored in contact data // They would be used only for the current operation (like email template rendering) continue; } // Store the value mergedData[key] = value; } } if (existing) { // Track subscription status change const isSubscriptionChanging = subscribed !== undefined && existing.subscribed !== subscribed; const wasSubscribed = existing.subscribed; const updated = await prisma.contact.update({ where: {id: existing.id}, data: { data: Object.keys(mergedData).length > 0 ? toPrismaJson(mergedData) : Prisma.JsonNull, ...(subscribed !== undefined ? {subscribed} : {}), }, }); // Track subscription event if status changed if (isSubscriptionChanging) { if (subscribed && !wasSubscribed) { await EventService.trackEvent(projectId, 'contact.subscribed', updated.id); } else if (!subscribed && wasSubscribed) { await EventService.trackEvent(projectId, 'contact.unsubscribed', updated.id); } } return updated; } else { return prisma.contact.create({ data: { projectId, email, data: Object.keys(mergedData).length > 0 ? toPrismaJson(mergedData) : Prisma.JsonNull, subscribed: subscribed ?? defaultSubscribed, }, }); } } /** * Get the full merged data for a contact including non-persistent fields * This is useful for template rendering */ public static getMergedData(contact: Contact, temporaryData?: Record): Record { const mergedData: Record = { plunk_id: contact.id, plunk_email: contact.email, }; // Add contact's persistent data if (contact.data && typeof contact.data === 'object' && !Array.isArray(contact.data)) { Object.assign(mergedData, contact.data); } // Explicitly expose locale as a predefined field (available in templates) // This ensures locale is always accessible even if not in contact.data if (mergedData.locale === undefined) { mergedData.locale = null; } // Add temporary (non-persistent) data if (temporaryData) { for (const [key, value] of Object.entries(temporaryData)) { // Skip reserved system-generated fields const reservedFields = ['plunk_id', 'plunk_email', 'email', 'unsubscribeUrl', 'subscribeUrl', 'manageUrl']; if (reservedFields.includes(key)) { continue; } // Handle non-persistent data format: { value: "...", persistent: false } if ( typeof value === 'object' && value !== null && 'value' in value && 'persistent' in value && value.persistent === false ) { mergedData[key] = value.value; } else { mergedData[key] = value; } } } return mergedData; } /** * PUBLIC: Get a contact by ID (no project authentication required) * This is used for public-facing pages like unsubscribe */ public static async getById(contactId: string): Promise { const contact = await prisma.contact.findUnique({ where: {id: contactId}, }); if (!contact) { throw new HttpException(404, 'Contact not found'); } return contact; } /** * Get project by contact ID * Used to fetch project settings for public endpoints */ public static async getProjectByContactId(contactId: string): Promise<{language: string} | null> { const contact = await prisma.contact.findUnique({ where: {id: contactId}, select: { project: { select: { language: true, }, }, }, }); return contact?.project || null; } /** * PUBLIC: Subscribe a contact */ public static async subscribe(contactId: string): Promise { const contact = await prisma.contact.update({ where: {id: contactId}, data: {subscribed: true}, }); // Track subscription event await EventService.trackEvent(contact.projectId, 'contact.subscribed', contactId); return contact; } /** * PUBLIC: Unsubscribe a contact */ public static async unsubscribe(contactId: string): Promise { const contact = await prisma.contact.update({ where: {id: contactId}, data: {subscribed: false}, }); // Track unsubscription event await EventService.trackEvent(contact.projectId, 'contact.unsubscribed', contactId); return contact; } /** * Get all available contact fields for a project * Returns both standard fields and custom fields from the data JSON column * Now includes type information inferred from actual data * * @param projectId - The project ID to filter contacts * @returns Array of field objects with name and type */ public static async getAvailableFields( projectId: string, ): Promise> { // Get total contact count for coverage calculation const totalContacts = await prisma.contact.count({ where: {projectId}, }); // Standard fields with known types (always 100% coverage) const standardFields = [ {field: 'email', type: 'string' as const, coverage: 100}, {field: 'subscribed', type: 'boolean' as const, coverage: 100}, {field: 'createdAt', type: 'date' as const, coverage: 100}, {field: 'updatedAt', type: 'date' as const, coverage: 100}, ]; // Get custom fields from the data JSON column with type inference and coverage // Use raw SQL to extract all keys, sample values, and contact counts from the JSON data column const result = await prisma.$queryRaw< Array<{key: string; sample_value: string; json_type: string; contact_count: bigint}> >` WITH field_keys AS ( SELECT DISTINCT jsonb_object_keys(data) as key FROM contacts WHERE "projectId" = ${projectId} AND data IS NOT NULL AND jsonb_typeof(data) = 'object' ), field_samples AS ( SELECT fk.key, jsonb_typeof(c.data->fk.key) as json_type, (c.data->>fk.key) as sample_value FROM field_keys fk CROSS JOIN LATERAL ( SELECT data FROM contacts WHERE "projectId" = ${projectId} AND data ? fk.key AND data->fk.key IS NOT NULL LIMIT 1 ) c ), field_counts AS ( SELECT fk.key, COUNT(*) as contact_count FROM field_keys fk JOIN contacts c ON c."projectId" = ${projectId} AND c.data ? fk.key AND c.data->fk.key IS NOT NULL GROUP BY fk.key ) SELECT fs.key, fs.sample_value, fs.json_type, fc.contact_count FROM field_samples fs JOIN field_counts fc ON fc.key = fs.key `; // Infer types from JSON types and sample values, calculate coverage const customFields = result.map(row => { let type: 'string' | 'number' | 'boolean' | 'date' = 'string'; // PostgreSQL jsonb_typeof returns: "object", "array", "string", "number", "boolean", "null" if (row.json_type === 'boolean') { type = 'boolean'; } else if (row.json_type === 'number') { type = 'number'; } else if (row.json_type === 'string' && row.sample_value) { // Try to detect dates (ISO 8601 format) const dateRegex = /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}:\d{2}(\.\d{3})?Z?)?$/; if (dateRegex.test(row.sample_value)) { type = 'date'; } } // Calculate coverage percentage const contactCount = Number(row.contact_count); const coverage = totalContacts > 0 ? Math.round((contactCount / totalContacts) * 100) : 0; return { field: `data.${row.key}`, type, coverage, }; }); return [...standardFields, ...customFields].sort((a, b) => a.field.localeCompare(b.field)); } /** * Get unique values for a contact field * Optimized for large datasets (1M+ contacts) - limits results and uses efficient queries * * @param projectId - The project ID to filter contacts * @param field - The field path (e.g., "subscribed", "email", "data.plan", "data.firstName") * @param limit - Maximum number of unique values to return (default: 100) * @returns Array of unique values, sorted alphabetically */ public static async getUniqueFieldValues( projectId: string, field: string, limit = 100, ): Promise> { if (field === 'subscribed') { // Boolean field - return both possible values return [true, false]; } if (field === 'email') { // Email is not useful for dropdowns, return empty return []; } // Handle JSON data fields (e.g., "data.plan" or just "plan") const jsonField = field.startsWith('data.') ? field.substring(5) : field; // Use raw SQL for performance with large datasets // Extract unique values from the JSON field using PostgreSQL's JSON operators const result = await prisma.$queryRaw>` SELECT DISTINCT data->>${jsonField} as value FROM contacts WHERE "projectId" = ${projectId} AND data ? ${jsonField} AND data->>${jsonField} IS NOT NULL AND data->>${jsonField} != '' ORDER BY value LIMIT ${limit} `; // Parse and return values, handling different data types return result .map(row => { const value = String(row.value); // Try to parse as boolean if (value === 'true') return true; if (value === 'false') return false; // Try to parse as number const numValue = Number(value); if (!isNaN(numValue) && value.trim() !== '') { return numValue; } // Return as string return value; }) .filter(v => v !== null && v !== undefined); } /** * Check if a contact field is used in any segments or campaigns * Returns usage information including which segments/campaigns use the field * * @param projectId - The project ID * @param field - The field to check (e.g., "data.plan", "email", "subscribed") * @returns Usage information */ public static async getFieldUsage( projectId: string, field: string, ): Promise<{ usedInSegments: Array<{id: string; name: string}>; usedInCampaigns: Array<{id: string; name: string}>; contactCount: number; canDelete: boolean; }> { // Get all segments for the project const segments = await prisma.segment.findMany({ where: {projectId}, select: {id: true, name: true, condition: true}, }); // Check which segments use this field const usedInSegments = segments.filter(segment => { const condition = segment.condition as FilterCondition | null; return this.fieldUsedInCondition(field, condition); }); // For now, we'll check if campaigns use the field in their subject or body // This is a simplified check - you might want to enhance this based on your campaign structure const usedInCampaigns: Array<{id: string; name: string}> = []; // Count contacts that have this field (for data fields) let contactCount = 0; if (field.startsWith('data.')) { const jsonField = field.substring(5); const result = await prisma.$queryRaw>` SELECT COUNT(*) as count FROM contacts WHERE "projectId" = ${projectId} AND data ? ${jsonField} AND data->${jsonField} IS NOT NULL `; contactCount = Number(result[0]?.count || 0); } else if (field === 'email' || field === 'subscribed' || field === 'createdAt' || field === 'updatedAt') { // Standard fields exist on all contacts const result = await prisma.contact.count({where: {projectId}}); contactCount = result; } const canDelete = usedInSegments.length === 0 && usedInCampaigns.length === 0; return { usedInSegments: usedInSegments.map(s => ({id: s.id, name: s.name})), usedInCampaigns, contactCount, canDelete, }; } /** * Delete a custom field from all contacts * WARNING: This is destructive and cannot be undone * Should only be called after verifying the field is not in use * * @param projectId - The project ID * @param field - The field to delete (must be a data.* field) */ public static async deleteField(projectId: string, field: string): Promise<{deletedFrom: number}> { // Only allow deleting custom data fields if (!field.startsWith('data.')) { throw new HttpException(400, 'Can only delete custom data fields (data.*)'); } // Check if field is in use const usage = await this.getFieldUsage(projectId, field); if (!usage.canDelete) { throw new HttpException( 400, `Cannot delete field: used in ${usage.usedInSegments.length} segment(s) and ${usage.usedInCampaigns.length} campaign(s)`, ); } const jsonField = field.substring(5); // Delete the field from all contacts using raw SQL // PostgreSQL's `-` operator removes a key from a JSON object const result = await prisma.$executeRaw` UPDATE contacts SET data = data - ${jsonField} WHERE "projectId" = ${projectId} AND data ? ${jsonField} `; return {deletedFrom: result}; } /** * Bulk subscribe contacts * Updates multiple contacts to subscribed=true in batches */ public static async bulkSubscribe(projectId: string, contactIds: string[]): Promise<{updated: number}> { // Verify all contacts belong to this project const contacts = await prisma.contact.findMany({ where: { id: {in: contactIds}, projectId, }, select: {id: true, subscribed: true}, }); const validIds = contacts.map(c => c.id); if (validIds.length === 0) { return {updated: 0}; } // Only update contacts that are currently unsubscribed const unsubscribedIds = contacts.filter(c => !c.subscribed).map(c => c.id); if (unsubscribedIds.length === 0) { return {updated: 0}; } // Update in a single query for performance const result = await prisma.contact.updateMany({ where: { id: {in: unsubscribedIds}, projectId, }, data: { subscribed: true, }, }); // Track events for changed contacts sequentially to avoid database deadlocks // Process in background to avoid blocking the API response this.trackEventsSequentially(projectId, 'contact.subscribed', unsubscribedIds).catch(error => { // Silently ignore errors in tests due to cleanup race conditions if (process.env.NODE_ENV !== 'test') { console.error('[ContactService] Failed to track bulk subscribe events:', error); } }); return {updated: result.count}; } /** * Bulk unsubscribe contacts */ public static async bulkUnsubscribe(projectId: string, contactIds: string[]): Promise<{updated: number}> { const contacts = await prisma.contact.findMany({ where: { id: {in: contactIds}, projectId, }, select: {id: true, subscribed: true}, }); const validIds = contacts.map(c => c.id); if (validIds.length === 0) { return {updated: 0}; } // Only update contacts that are currently subscribed const subscribedIds = contacts.filter(c => c.subscribed).map(c => c.id); if (subscribedIds.length === 0) { return {updated: 0}; } const result = await prisma.contact.updateMany({ where: { id: {in: subscribedIds}, projectId, }, data: { subscribed: false, }, }); // Track events for changed contacts sequentially to avoid database deadlocks // Process in background to avoid blocking the API response this.trackEventsSequentially(projectId, 'contact.unsubscribed', subscribedIds).catch(error => { // Silently ignore errors in tests due to cleanup race conditions if (process.env.NODE_ENV !== 'test') { console.error('[ContactService] Failed to track bulk unsubscribe events:', error); } }); return {updated: result.count}; } /** * Bulk delete contacts */ public static async bulkDelete(projectId: string, contactIds: string[]): Promise<{deleted: number}> { const result = await prisma.contact.deleteMany({ where: { id: {in: contactIds}, projectId, }, }); return {deleted: result.count}; } /** * Helper: Check if a field is used in a filter condition (recursive) */ private static fieldUsedInCondition(field: string, condition: FilterCondition | null): boolean { if (!condition || typeof condition !== 'object') { return false; } // Check groups in the condition if (Array.isArray(condition.groups)) { for (const group of condition.groups) { if (this.fieldUsedInGroup(field, group)) { return true; } } } return false; } /** * Helper: Check if a field is used in a filter group (recursive) */ private static fieldUsedInGroup(field: string, group: FilterGroup): boolean { if (!group || typeof group !== 'object') { return false; } // Check filters in the group if (Array.isArray(group.filters)) { for (const filter of group.filters) { if (filter.field === field) { return true; } } } // Check nested conditions if (group.conditions) { return this.fieldUsedInCondition(field, group.conditions); } return false; } /** * Track events sequentially to avoid database deadlocks * Processes events one at a time with error handling * * @private */ private static async trackEventsSequentially( projectId: string, eventName: string, contactIds: string[], ): Promise { for (const contactId of contactIds) { try { await EventService.trackEvent(projectId, eventName, contactId); } catch (error) { // Log error but continue processing remaining events // Suppress logging in test environments to reduce noise from cleanup race conditions if (process.env.NODE_ENV !== 'test') { console.error(`[ContactService] Failed to track event ${eventName} for contact ${contactId}:`, error); } } } } }