Files
plunk/apps/api/src/services/ContactService.ts
T
2025-12-02 09:03:05 +01:00

472 lines
13 KiB
TypeScript

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<PaginatedContacts> {
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<Contact> {
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<Contact | null> {
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<Contact> {
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<Contact> {
// 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<void> {
// 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<number> {
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<string, unknown>,
subscribed?: boolean,
): Promise<Contact> {
// Find existing contact
const existing = await prisma.contact.findFirst({
where: {
projectId,
email,
},
});
// Process data to merge with existing data
let mergedData: Record<string, unknown> = {};
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<string, unknown>): Record<string, unknown> {
const mergedData: Record<string, unknown> = {
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<Contact> {
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<Contact> {
return prisma.contact.update({
where: {id: contactId},
data: {subscribed: true},
});
}
/**
* PUBLIC: Unsubscribe a contact
*/
public static async unsubscribe(contactId: string): Promise<Contact> {
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<Array<{field: string; type: 'string' | 'number' | 'boolean' | 'date'}>> {
// 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<Array<{key: string; sample_value: string; json_type: string}>>`
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<Array<string | number | boolean>> {
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<Array<{value: unknown}>>`
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);
}
}