Initial push of Plunk Next

This commit is contained in:
Dries Augustyns
2025-12-01 09:56:56 +01:00
parent 07cea20262
commit ff1876d580
566 changed files with 89036 additions and 28423 deletions
+420
View File
@@ -0,0 +1,420 @@
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
*
* @param projectId - The project ID to filter contacts
* @returns Array of field names (e.g., ["subscribed", "data.plan", "data.firstName"])
*/
public static async getAvailableFields(projectId: string): Promise<string[]> {
// Standard fields
const standardFields = ['subscribed'];
// Get custom fields from the data JSON column
// Use raw SQL to extract all keys from the JSON data column
const result = await prisma.$queryRaw<Array<{key: string}>>`
SELECT DISTINCT jsonb_object_keys(data) as key
FROM contacts
WHERE
"projectId" = ${projectId}
AND data IS NOT NULL
AND jsonb_typeof(data) = 'object'
`;
// Combine standard fields with custom fields (prefixed with "data.")
const customFields = result.map(row => `data.${row.key}`);
return [...standardFields, ...customFields].sort();
}
/**
* 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);
}
}