import {type Contact, Prisma} from '@plunk/db'; import {prisma} from '../database/prisma.js'; import {HttpException} from '../exceptions/index.js'; export interface PaginatedContacts { contacts: Contact[]; total: number; cursor?: string; hasMore: boolean; } 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 { contacts: 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 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; } try { return await prisma.contact.update({ where: {id: contactId}, data: updateData, }); } 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, ): 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 fields if (key === 'plunk_id' || key === 'plunk_email') { continue; } // 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) { return prisma.contact.update({ where: {id: existing.id}, data: { data: Object.keys(mergedData).length > 0 ? (mergedData as Prisma.InputJsonValue) : Prisma.JsonNull, ...(subscribed !== undefined ? {subscribed} : {}), }, }); } else { return prisma.contact.create({ data: { projectId, email, data: Object.keys(mergedData).length > 0 ? (mergedData as Prisma.InputJsonValue) : Prisma.JsonNull, subscribed: subscribed ?? true, }, }); } } /** * 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); } // Add temporary (non-persistent) data if (temporaryData) { for (const [key, value] of Object.entries(temporaryData)) { // Skip reserved fields if (key === 'plunk_id' || key === 'plunk_email') { 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; } /** * PUBLIC: Subscribe a contact */ public static async subscribe(contactId: string): Promise { return prisma.contact.update({ where: {id: contactId}, data: {subscribed: true}, }); } /** * PUBLIC: Unsubscribe a contact */ public static async unsubscribe(contactId: string): Promise { return prisma.contact.update({ where: {id: contactId}, data: {subscribed: false}, }); } /** * 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> { // Standard fields with known types const standardFields = [ {field: 'email', type: 'string' as const}, {field: 'subscribed', type: 'boolean' as const}, {field: 'createdAt', type: 'date' as const}, {field: 'updatedAt', type: 'date' as const}, ]; // Get custom fields from the data JSON column with type inference // Use raw SQL to extract all keys and sample values from the JSON data column const result = await prisma.$queryRaw>` 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 ) SELECT key, sample_value, json_type FROM field_samples `; // Infer types from JSON types and sample values 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'; } } return { field: `data.${row.key}`, type, }; }); 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); } }