472 lines
13 KiB
TypeScript
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);
|
|
}
|
|
}
|