899 lines
26 KiB
TypeScript
899 lines
26 KiB
TypeScript
import {type Contact, Prisma, type Segment} from '@plunk/db';
|
|
import type {FilterCondition, FilterGroup, SegmentFilter} from '@plunk/types';
|
|
import signale from 'signale';
|
|
|
|
import {prisma} from '../database/prisma.js';
|
|
import {HttpException} from '../exceptions/index.js';
|
|
|
|
import {EventService} from './EventService.js';
|
|
import {NtfyService} from './NtfyService.js';
|
|
|
|
// Re-export types for use in other services
|
|
export type {FilterCondition, FilterGroup, SegmentFilter} from '@plunk/types';
|
|
|
|
export interface PaginatedContacts {
|
|
contacts: Contact[];
|
|
total: number;
|
|
page: number;
|
|
pageSize: number;
|
|
totalPages: number;
|
|
}
|
|
|
|
/**
|
|
* Convert segment name to a URL-safe slug for event names
|
|
* Example: "VIP Customers" -> "vip-customers"
|
|
*/
|
|
function slugifySegmentName(name: string): string {
|
|
return name
|
|
.toLowerCase()
|
|
.trim()
|
|
.replace(/[^\w\s-]/g, '') // Remove special characters
|
|
.replace(/\s+/g, '-') // Replace spaces with hyphens
|
|
.replace(/-+/g, '-') // Replace multiple hyphens with single hyphen
|
|
.replace(/^-+|-+$/g, ''); // Remove leading/trailing hyphens
|
|
}
|
|
|
|
export class SegmentService {
|
|
/**
|
|
* Get all segments for a project
|
|
* Uses cached member counts for performance - counts are updated via background job
|
|
*/
|
|
public static async list(projectId: string): Promise<Segment[]> {
|
|
return prisma.segment.findMany({
|
|
where: {projectId},
|
|
orderBy: {createdAt: 'desc'},
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Get a single segment by ID
|
|
* Uses cached member count for performance - counts are updated via background job
|
|
*/
|
|
public static async get(projectId: string, segmentId: string): Promise<Segment> {
|
|
const segment = await prisma.segment.findFirst({
|
|
where: {
|
|
id: segmentId,
|
|
projectId,
|
|
},
|
|
});
|
|
|
|
if (!segment) {
|
|
throw new HttpException(404, 'Segment not found');
|
|
}
|
|
|
|
return segment;
|
|
}
|
|
|
|
/**
|
|
* Get contacts that match a segment's condition
|
|
*/
|
|
public static async getContacts(
|
|
projectId: string,
|
|
segmentId: string,
|
|
page = 1,
|
|
pageSize = 20,
|
|
): Promise<PaginatedContacts> {
|
|
const segment = await this.get(projectId, segmentId);
|
|
const condition = segment.condition as unknown as FilterCondition;
|
|
|
|
const where = this.buildWhereClause(projectId, condition);
|
|
const skip = (page - 1) * pageSize;
|
|
|
|
const [contacts, total] = await Promise.all([
|
|
prisma.contact.findMany({
|
|
where,
|
|
skip,
|
|
take: pageSize,
|
|
orderBy: {createdAt: 'desc'},
|
|
}),
|
|
prisma.contact.count({where}),
|
|
]);
|
|
|
|
return {
|
|
contacts,
|
|
total,
|
|
page,
|
|
pageSize,
|
|
totalPages: Math.ceil(total / pageSize),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Create a new segment
|
|
*/
|
|
public static async create(
|
|
projectId: string,
|
|
data: {
|
|
name: string;
|
|
description?: string;
|
|
condition: FilterCondition;
|
|
trackMembership?: boolean;
|
|
},
|
|
): Promise<Segment> {
|
|
// Validate condition
|
|
this.validateCondition(data.condition);
|
|
|
|
// Compute initial member count
|
|
const where = this.buildWhereClause(projectId, data.condition);
|
|
const memberCount = await prisma.contact.count({where});
|
|
|
|
const segment = await prisma.segment.create({
|
|
data: {
|
|
projectId,
|
|
name: data.name,
|
|
description: data.description,
|
|
condition: data.condition as unknown as Prisma.InputJsonValue,
|
|
trackMembership: data.trackMembership ?? false,
|
|
memberCount,
|
|
},
|
|
include: {
|
|
project: {
|
|
select: {name: true},
|
|
},
|
|
},
|
|
});
|
|
|
|
// Notify about segment creation
|
|
await NtfyService.notifySegmentCreated(segment.name, segment.project.name, projectId);
|
|
|
|
return segment;
|
|
}
|
|
|
|
/**
|
|
* Update a segment
|
|
*/
|
|
public static async update(
|
|
projectId: string,
|
|
segmentId: string,
|
|
data: {
|
|
name?: string;
|
|
description?: string;
|
|
condition?: FilterCondition;
|
|
trackMembership?: boolean;
|
|
},
|
|
): Promise<Segment> {
|
|
// First verify segment exists and belongs to project
|
|
await this.get(projectId, segmentId);
|
|
|
|
// Validate condition if provided
|
|
if (data.condition) {
|
|
this.validateCondition(data.condition);
|
|
}
|
|
|
|
const updateData: Prisma.SegmentUpdateInput = {};
|
|
|
|
if (data.name !== undefined) {
|
|
updateData.name = data.name;
|
|
}
|
|
if (data.description !== undefined) {
|
|
updateData.description = data.description;
|
|
}
|
|
if (data.condition !== undefined) {
|
|
updateData.condition = data.condition as unknown as Prisma.InputJsonValue;
|
|
|
|
// Recompute member count when condition changes
|
|
const where = this.buildWhereClause(projectId, data.condition);
|
|
updateData.memberCount = await prisma.contact.count({where});
|
|
}
|
|
if (data.trackMembership !== undefined) {
|
|
updateData.trackMembership = data.trackMembership;
|
|
}
|
|
|
|
return prisma.segment.update({
|
|
where: {id: segmentId},
|
|
data: updateData,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Delete a segment
|
|
*/
|
|
public static async delete(projectId: string, segmentId: string): Promise<void> {
|
|
// First verify segment exists and belongs to project
|
|
const segment = await prisma.segment.findFirst({
|
|
where: {
|
|
id: segmentId,
|
|
projectId,
|
|
},
|
|
include: {
|
|
project: {
|
|
select: {name: true},
|
|
},
|
|
},
|
|
});
|
|
|
|
if (!segment) {
|
|
throw new HttpException(404, 'Segment not found');
|
|
}
|
|
|
|
// Check if segment is used in any active campaigns
|
|
const campaignsUsingSegment = await prisma.campaign.count({
|
|
where: {
|
|
segmentId,
|
|
status: {
|
|
in: ['DRAFT', 'SCHEDULED', 'SENDING'],
|
|
},
|
|
},
|
|
});
|
|
|
|
if (campaignsUsingSegment > 0) {
|
|
throw new HttpException(
|
|
409,
|
|
`Cannot delete segment: it is currently used in ${campaignsUsingSegment} active campaign(s). Remove it from campaigns first or wait for them to complete.`,
|
|
);
|
|
}
|
|
|
|
await prisma.segment.delete({
|
|
where: {id: segmentId},
|
|
});
|
|
|
|
// Notify about segment deletion
|
|
await NtfyService.notifySegmentDeleted(segment.name, segment.project.name, projectId);
|
|
}
|
|
|
|
/**
|
|
* Refresh segment member count (for background jobs or manual refresh)
|
|
* This is now the primary way to update segment counts
|
|
*/
|
|
public static async refreshMemberCount(projectId: string, segmentId: string): Promise<number> {
|
|
const segment = await this.get(projectId, segmentId);
|
|
const condition = segment.condition as unknown as FilterCondition;
|
|
const where = this.buildWhereClause(projectId, condition);
|
|
|
|
const memberCount = await prisma.contact.count({where});
|
|
|
|
await prisma.segment.update({
|
|
where: {id: segmentId},
|
|
data: {memberCount},
|
|
});
|
|
|
|
return memberCount;
|
|
}
|
|
|
|
/**
|
|
* Refresh member counts for all segments in a project
|
|
* Should be called by a background job periodically
|
|
*/
|
|
public static async refreshAllMemberCounts(projectId: string): Promise<void> {
|
|
const segments = await prisma.segment.findMany({
|
|
where: {projectId},
|
|
select: {id: true, condition: true},
|
|
});
|
|
|
|
// Process in batches to avoid overwhelming the database
|
|
const BATCH_SIZE = 5;
|
|
for (let i = 0; i < segments.length; i += BATCH_SIZE) {
|
|
const batch = segments.slice(i, i + BATCH_SIZE);
|
|
|
|
await Promise.all(
|
|
batch.map(async segment => {
|
|
try {
|
|
const condition = segment.condition as unknown as FilterCondition;
|
|
const where = this.buildWhereClause(projectId, condition);
|
|
const memberCount = await prisma.contact.count({where});
|
|
|
|
await prisma.segment.update({
|
|
where: {id: segment.id},
|
|
data: {memberCount},
|
|
});
|
|
} catch (error) {
|
|
signale.error(`Failed to update count for segment ${segment.id}:`, error);
|
|
}
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Compute or recompute segment membership for all contacts
|
|
* Now uses cursor-based pagination for memory efficiency with large contact lists
|
|
*/
|
|
public static async computeMembership(
|
|
projectId: string,
|
|
segmentId: string,
|
|
): Promise<{added: number; removed: number; total: number}> {
|
|
const segment = await this.get(projectId, segmentId);
|
|
|
|
if (!segment.trackMembership) {
|
|
throw new HttpException(400, 'Segment does not have membership tracking enabled');
|
|
}
|
|
|
|
const condition = segment.condition as unknown as FilterCondition;
|
|
const where = this.buildWhereClause(projectId, condition);
|
|
|
|
// Get all matching contacts using cursor-based pagination to avoid memory issues
|
|
const BATCH_SIZE = 1000;
|
|
const matchingContactIds = new Set<string>();
|
|
let cursor: string | undefined = undefined;
|
|
let hasMore = true;
|
|
|
|
while (hasMore) {
|
|
const contacts: {id: string}[] = await prisma.contact.findMany({
|
|
where,
|
|
select: {id: true},
|
|
take: BATCH_SIZE,
|
|
skip: cursor ? 1 : 0,
|
|
cursor: cursor ? {id: cursor} : undefined,
|
|
orderBy: {id: 'asc'},
|
|
});
|
|
|
|
contacts.forEach((c: {id: string}) => matchingContactIds.add(c.id));
|
|
|
|
if (contacts.length < BATCH_SIZE) {
|
|
hasMore = false;
|
|
} else {
|
|
const lastContact = contacts[contacts.length - 1];
|
|
cursor = lastContact?.id;
|
|
}
|
|
}
|
|
|
|
// Get current active memberships using cursor pagination
|
|
const currentMemberIds = new Set<string>();
|
|
cursor = undefined;
|
|
hasMore = true;
|
|
|
|
while (hasMore) {
|
|
const memberships: {contactId: string}[] = await prisma.segmentMembership.findMany({
|
|
where: {
|
|
segmentId,
|
|
exitedAt: null,
|
|
},
|
|
select: {contactId: true},
|
|
take: BATCH_SIZE,
|
|
skip: cursor ? 1 : 0,
|
|
cursor: cursor ? {contactId_segmentId: {contactId: cursor, segmentId}} : undefined,
|
|
orderBy: {contactId: 'asc'},
|
|
});
|
|
|
|
memberships.forEach((m: {contactId: string}) => currentMemberIds.add(m.contactId));
|
|
|
|
if (memberships.length < BATCH_SIZE) {
|
|
hasMore = false;
|
|
} else {
|
|
const lastMembership = memberships[memberships.length - 1];
|
|
cursor = lastMembership?.contactId;
|
|
}
|
|
}
|
|
|
|
// Calculate changes
|
|
const toAdd = Array.from(matchingContactIds).filter(id => !currentMemberIds.has(id));
|
|
const toRemove = Array.from(currentMemberIds).filter(id => !matchingContactIds.has(id));
|
|
|
|
// Process additions in batches
|
|
const ADD_BATCH_SIZE = 500;
|
|
for (let i = 0; i < toAdd.length; i += ADD_BATCH_SIZE) {
|
|
const batch = toAdd.slice(i, i + ADD_BATCH_SIZE);
|
|
|
|
await prisma.segmentMembership.createMany({
|
|
data: batch.map(contactId => ({
|
|
segmentId,
|
|
contactId,
|
|
enteredAt: new Date(),
|
|
})),
|
|
skipDuplicates: true,
|
|
});
|
|
|
|
// Create segment-specific entry events for each contact in the batch
|
|
for (const contactId of batch) {
|
|
try {
|
|
// Create a human-readable event name using slugified segment name
|
|
const segmentSlug = slugifySegmentName(segment.name);
|
|
const eventName = `segment.${segmentSlug}.entry`;
|
|
await EventService.trackEvent(projectId, eventName, contactId, undefined, {
|
|
segmentId: segment.id,
|
|
segmentName: segment.name,
|
|
});
|
|
} catch (error) {
|
|
signale.error(`[SEGMENT] Failed to track segment entry event for contact ${contactId}:`, error);
|
|
}
|
|
}
|
|
}
|
|
|
|
const REMOVE_BATCH_SIZE = 500;
|
|
for (let i = 0; i < toRemove.length; i += REMOVE_BATCH_SIZE) {
|
|
const batch = toRemove.slice(i, i + REMOVE_BATCH_SIZE);
|
|
|
|
await prisma.segmentMembership.updateMany({
|
|
where: {
|
|
segmentId,
|
|
contactId: {in: batch},
|
|
exitedAt: null,
|
|
},
|
|
data: {
|
|
exitedAt: new Date(),
|
|
},
|
|
});
|
|
|
|
// Create segment-specific exit events for each contact in the batch
|
|
for (const contactId of batch) {
|
|
try {
|
|
// Create a human-readable event name using slugified segment name
|
|
const segmentSlug = slugifySegmentName(segment.name);
|
|
const eventName = `segment.${segmentSlug}.exit`;
|
|
await EventService.trackEvent(projectId, eventName, contactId, undefined, {
|
|
segmentId: segment.id,
|
|
segmentName: segment.name,
|
|
});
|
|
} catch (error) {
|
|
signale.error(`[SEGMENT] Failed to track segment exit event for contact ${contactId}:`, error);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Update member count on segment
|
|
await prisma.segment.update({
|
|
where: {id: segmentId},
|
|
data: {memberCount: matchingContactIds.size},
|
|
});
|
|
|
|
signale.info(
|
|
`[SEGMENT] Computed membership for segment ${segmentId}: added ${toAdd.length}, removed ${toRemove.length}, total ${matchingContactIds.size}`,
|
|
);
|
|
|
|
return {
|
|
added: toAdd.length,
|
|
removed: toRemove.length,
|
|
total: matchingContactIds.size,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Build a single filter condition
|
|
*/
|
|
public static buildFilterCondition(filter: SegmentFilter): Prisma.ContactWhereInput {
|
|
const {field, operator, value, unit} = filter;
|
|
|
|
// Handle event-based filters (e.g., "event.upgrade", "event.purchase")
|
|
if (field.startsWith('event.')) {
|
|
const eventName = field.substring(6); // Remove "event." prefix
|
|
return this.buildEventCondition(eventName, operator, value, unit);
|
|
}
|
|
|
|
// Handle email activity filters (e.g., "email.opened", "email.clicked")
|
|
if (field.startsWith('email.')) {
|
|
const activity = field.substring(6); // Remove "email." prefix
|
|
return this.buildEmailActivityCondition(activity, operator, value, unit);
|
|
}
|
|
|
|
// Handle JSON field paths (e.g., "data.plan")
|
|
if (field.startsWith('data.')) {
|
|
const jsonPath = field.substring(5); // Remove "data." prefix
|
|
return this.buildJsonFieldCondition(jsonPath, operator, value);
|
|
}
|
|
|
|
// Handle regular fields
|
|
switch (field) {
|
|
case 'email':
|
|
return this.buildStringFieldCondition('email', operator, value);
|
|
case 'subscribed':
|
|
return this.buildBooleanFieldCondition('subscribed', operator, value);
|
|
case 'createdAt':
|
|
case 'updatedAt':
|
|
return this.buildDateFieldCondition(field, operator, value, unit);
|
|
default:
|
|
throw new HttpException(400, `Unsupported filter field: ${field}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Validate segment condition (recursive)
|
|
*/
|
|
public static validateCondition(condition: FilterCondition): void {
|
|
if (!condition || typeof condition !== 'object') {
|
|
throw new HttpException(400, 'Condition must be an object');
|
|
}
|
|
|
|
if (!condition.logic || !['AND', 'OR'].includes(condition.logic)) {
|
|
throw new HttpException(400, 'Condition logic must be either "AND" or "OR"');
|
|
}
|
|
|
|
if (!Array.isArray(condition.groups) || condition.groups.length === 0) {
|
|
throw new HttpException(400, 'Condition must have at least one group');
|
|
}
|
|
|
|
for (const group of condition.groups) {
|
|
this.validateGroup(group);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Validate filter group (recursive)
|
|
*/
|
|
private static validateGroup(group: FilterGroup): void {
|
|
if (!group || typeof group !== 'object') {
|
|
throw new HttpException(400, 'Group must be an object');
|
|
}
|
|
|
|
if (!Array.isArray(group.filters)) {
|
|
throw new HttpException(400, 'Group filters must be an array');
|
|
}
|
|
|
|
// Groups can have filters, nested conditions, or both
|
|
const hasFilters = group.filters.length > 0;
|
|
const hasConditions = group.conditions !== undefined;
|
|
|
|
if (!hasFilters && !hasConditions) {
|
|
throw new HttpException(400, 'Group must have at least one filter or nested condition');
|
|
}
|
|
|
|
// Validate all filters in the group
|
|
for (const filter of group.filters) {
|
|
this.validateFilter(filter);
|
|
}
|
|
|
|
// Recursively validate nested conditions
|
|
if (group.conditions) {
|
|
this.validateCondition(group.conditions);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Validate individual filter
|
|
*/
|
|
private static validateFilter(filter: SegmentFilter): void {
|
|
if (!filter.field) {
|
|
throw new HttpException(400, 'Filter field is required');
|
|
}
|
|
|
|
if (!filter.operator) {
|
|
throw new HttpException(400, 'Filter operator is required');
|
|
}
|
|
|
|
const validOperators = [
|
|
'equals',
|
|
'notEquals',
|
|
'contains',
|
|
'notContains',
|
|
'greaterThan',
|
|
'lessThan',
|
|
'greaterThanOrEqual',
|
|
'lessThanOrEqual',
|
|
'exists',
|
|
'notExists',
|
|
'within',
|
|
'triggered',
|
|
'triggeredWithin',
|
|
'notTriggered',
|
|
];
|
|
|
|
if (!validOperators.includes(filter.operator)) {
|
|
throw new HttpException(400, `Invalid operator: ${filter.operator}`);
|
|
}
|
|
|
|
// Validate that operators that need a value have one
|
|
const operatorsNeedingValue = [
|
|
'equals',
|
|
'notEquals',
|
|
'contains',
|
|
'notContains',
|
|
'greaterThan',
|
|
'lessThan',
|
|
'greaterThanOrEqual',
|
|
'lessThanOrEqual',
|
|
'within',
|
|
'triggeredWithin',
|
|
];
|
|
|
|
if (operatorsNeedingValue.includes(filter.operator) && filter.value === undefined) {
|
|
throw new HttpException(400, `Operator "${filter.operator}" requires a value`);
|
|
}
|
|
|
|
// Validate unit for time-based operators
|
|
if (['within', 'triggeredWithin'].includes(filter.operator) && !filter.unit) {
|
|
throw new HttpException(400, `"${filter.operator}" operator requires a unit (days, hours, or minutes)`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Build Prisma where clause from filter condition (entry point)
|
|
*/
|
|
private static buildWhereClause(projectId: string, condition: FilterCondition): Prisma.ContactWhereInput {
|
|
return {
|
|
projectId,
|
|
...this.buildConditionClause(condition),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Build Prisma clause from filter condition (recursive)
|
|
*/
|
|
public static buildConditionClause(condition: FilterCondition): Prisma.ContactWhereInput {
|
|
const groupClauses = condition.groups.map(group => this.buildGroupClause(group));
|
|
|
|
if (condition.logic === 'AND') {
|
|
return {AND: groupClauses};
|
|
} else {
|
|
return {OR: groupClauses};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Build Prisma clause from filter group (recursive)
|
|
*/
|
|
private static buildGroupClause(group: FilterGroup): Prisma.ContactWhereInput {
|
|
const clauses: Prisma.ContactWhereInput[] = [];
|
|
|
|
// Add filter conditions from this group
|
|
if (group.filters.length > 0) {
|
|
clauses.push(...group.filters.map(filter => this.buildFilterCondition(filter)));
|
|
}
|
|
|
|
// Add nested condition if present
|
|
if (group.conditions) {
|
|
clauses.push(this.buildConditionClause(group.conditions));
|
|
}
|
|
|
|
// All conditions within a group are combined with AND
|
|
if (clauses.length === 0) {
|
|
return {}; // Empty group returns empty where clause
|
|
}
|
|
|
|
if (clauses.length === 1) {
|
|
return clauses[0]!; // Safe to use non-null assertion since we checked length
|
|
}
|
|
|
|
return {AND: clauses};
|
|
}
|
|
|
|
/**
|
|
* Build condition for JSON fields (stored in contact.data)
|
|
*/
|
|
private static buildJsonFieldCondition(jsonPath: string, operator: string, value: unknown): Prisma.ContactWhereInput {
|
|
const path = jsonPath.split('.');
|
|
|
|
switch (operator) {
|
|
case 'equals':
|
|
return {data: {path, equals: value as Prisma.InputJsonValue}};
|
|
case 'notEquals':
|
|
return {NOT: {data: {path, equals: value as Prisma.InputJsonValue}}};
|
|
case 'contains':
|
|
return {data: {path, string_contains: String(value)}};
|
|
case 'notContains':
|
|
return {NOT: {data: {path, string_contains: String(value)}}};
|
|
case 'greaterThan':
|
|
return {data: {path, gt: value as Prisma.InputJsonValue}};
|
|
case 'lessThan':
|
|
return {data: {path, lt: value as Prisma.InputJsonValue}};
|
|
case 'greaterThanOrEqual':
|
|
return {data: {path, gte: value as Prisma.InputJsonValue}};
|
|
case 'lessThanOrEqual':
|
|
return {data: {path, lte: value as Prisma.InputJsonValue}};
|
|
case 'exists':
|
|
// Exists = key present with a non-null JSON value (neither DbNull nor JsonNull)
|
|
return {
|
|
NOT: {
|
|
OR: [{data: {path, equals: Prisma.DbNull}}, {data: {path, equals: Prisma.JsonNull}}],
|
|
},
|
|
};
|
|
case 'notExists':
|
|
// Not exists = key is null / missing, represented as either DbNull or JsonNull
|
|
return {
|
|
OR: [{data: {path, equals: Prisma.DbNull}}, {data: {path, equals: Prisma.JsonNull}}],
|
|
};
|
|
default:
|
|
throw new HttpException(400, `Unsupported operator for JSON field: ${operator}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Build condition for string fields
|
|
*/
|
|
private static buildStringFieldCondition(field: 'email', operator: string, value: unknown): Prisma.ContactWhereInput {
|
|
switch (operator) {
|
|
case 'equals':
|
|
return {[field]: {equals: String(value), mode: 'insensitive'}};
|
|
case 'notEquals':
|
|
return {NOT: {[field]: {equals: String(value), mode: 'insensitive'}}};
|
|
case 'contains':
|
|
return {[field]: {contains: String(value), mode: 'insensitive'}};
|
|
case 'notContains':
|
|
return {NOT: {[field]: {contains: String(value), mode: 'insensitive'}}};
|
|
default:
|
|
throw new HttpException(400, `Unsupported operator for string field: ${operator}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Build condition for boolean fields
|
|
*/
|
|
private static buildBooleanFieldCondition(
|
|
field: 'subscribed',
|
|
operator: string,
|
|
value: unknown,
|
|
): Prisma.ContactWhereInput {
|
|
switch (operator) {
|
|
case 'equals':
|
|
return {[field]: value === true};
|
|
case 'notEquals':
|
|
return {[field]: value !== true};
|
|
default:
|
|
throw new HttpException(400, `Unsupported operator for boolean field: ${operator}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Build condition for date fields
|
|
*/
|
|
private static buildDateFieldCondition(
|
|
field: 'createdAt' | 'updatedAt',
|
|
operator: string,
|
|
value: unknown,
|
|
unit?: 'days' | 'hours' | 'minutes',
|
|
): Prisma.ContactWhereInput {
|
|
switch (operator) {
|
|
case 'greaterThan':
|
|
return {[field]: {gt: new Date(value as string | number | Date)}};
|
|
case 'lessThan':
|
|
return {[field]: {lt: new Date(value as string | number | Date)}};
|
|
case 'greaterThanOrEqual':
|
|
return {[field]: {gte: new Date(value as string | number | Date)}};
|
|
case 'lessThanOrEqual':
|
|
return {[field]: {lte: new Date(value as string | number | Date)}};
|
|
case 'within': {
|
|
// "within X days/hours/minutes" means in the past X time units
|
|
if (!unit) {
|
|
throw new HttpException(400, 'Unit is required for "within" operator');
|
|
}
|
|
|
|
const now = new Date();
|
|
const milliseconds = this.getMilliseconds(value as number, unit);
|
|
const since = new Date(now.getTime() - milliseconds);
|
|
|
|
return {[field]: {gte: since}};
|
|
}
|
|
default:
|
|
throw new HttpException(400, `Unsupported operator for date field: ${operator}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Convert time value and unit to milliseconds
|
|
*/
|
|
private static getMilliseconds(value: number, unit: 'days' | 'hours' | 'minutes'): number {
|
|
switch (unit) {
|
|
case 'days':
|
|
return value * 24 * 60 * 60 * 1000;
|
|
case 'hours':
|
|
return value * 60 * 60 * 1000;
|
|
case 'minutes':
|
|
return value * 60 * 1000;
|
|
default:
|
|
throw new HttpException(400, `Unsupported time unit: ${unit}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Build condition for event-based filters
|
|
* Uses Prisma relations to efficiently query contacts who triggered specific events
|
|
*/
|
|
private static buildEventCondition(
|
|
eventName: string,
|
|
operator: string,
|
|
value: unknown,
|
|
unit?: 'days' | 'hours' | 'minutes',
|
|
): Prisma.ContactWhereInput {
|
|
switch (operator) {
|
|
case 'triggered':
|
|
// Contact has triggered this event at any time
|
|
return {
|
|
events: {
|
|
some: {
|
|
name: eventName,
|
|
},
|
|
},
|
|
};
|
|
|
|
case 'triggeredWithin': {
|
|
// Contact has triggered this event within the specified timeframe
|
|
if (!unit) {
|
|
throw new HttpException(400, 'Unit is required for "triggeredWithin" operator');
|
|
}
|
|
|
|
const now = new Date();
|
|
const milliseconds = this.getMilliseconds(value as number, unit);
|
|
const since = new Date(now.getTime() - milliseconds);
|
|
|
|
return {
|
|
events: {
|
|
some: {
|
|
name: eventName,
|
|
createdAt: {
|
|
gte: since,
|
|
},
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
case 'notTriggered':
|
|
// Contact has never triggered this event
|
|
return {
|
|
events: {
|
|
none: {
|
|
name: eventName,
|
|
},
|
|
},
|
|
};
|
|
|
|
default:
|
|
throw new HttpException(400, `Unsupported operator for event field: ${operator}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Build condition for email activity filters
|
|
* Uses Prisma relations to efficiently query contacts based on email engagement
|
|
*/
|
|
private static buildEmailActivityCondition(
|
|
activity: string,
|
|
operator: string,
|
|
value: unknown,
|
|
unit?: 'days' | 'hours' | 'minutes',
|
|
): Prisma.ContactWhereInput {
|
|
// Map activity names to Email model fields
|
|
const fieldMap: Record<string, string> = {
|
|
opened: 'openedAt',
|
|
clicked: 'clickedAt',
|
|
bounced: 'bouncedAt',
|
|
complained: 'complainedAt',
|
|
sent: 'sentAt',
|
|
delivered: 'deliveredAt',
|
|
};
|
|
|
|
const field = fieldMap[activity];
|
|
if (!field) {
|
|
throw new HttpException(400, `Unsupported email activity: ${activity}`);
|
|
}
|
|
|
|
switch (operator) {
|
|
case 'triggered':
|
|
// Contact has this email activity at any time
|
|
return {
|
|
emails: {
|
|
some: {
|
|
[field]: {
|
|
not: null,
|
|
},
|
|
},
|
|
},
|
|
};
|
|
|
|
case 'triggeredWithin': {
|
|
// Contact has this email activity within the specified timeframe
|
|
if (!unit) {
|
|
throw new HttpException(400, 'Unit is required for "triggeredWithin" operator');
|
|
}
|
|
|
|
const now = new Date();
|
|
const milliseconds = this.getMilliseconds(value as number, unit);
|
|
const since = new Date(now.getTime() - milliseconds);
|
|
|
|
return {
|
|
emails: {
|
|
some: {
|
|
[field]: {
|
|
gte: since,
|
|
},
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
case 'notTriggered':
|
|
// Contact has never had this email activity
|
|
return {
|
|
emails: {
|
|
none: {
|
|
[field]: {
|
|
not: null,
|
|
},
|
|
},
|
|
},
|
|
};
|
|
|
|
default:
|
|
throw new HttpException(400, `Unsupported operator for email activity field: ${operator}`);
|
|
}
|
|
}
|
|
}
|