776 lines
22 KiB
TypeScript
776 lines
22 KiB
TypeScript
import type {Campaign, Contact, Prisma} from '@plunk/db';
|
|
import {CampaignAudienceType, CampaignStatus} from '@plunk/db';
|
|
import type {FilterCondition} from '@plunk/types';
|
|
|
|
import {prisma} from '../database/prisma.js';
|
|
import {HttpException} from '../exceptions/index.js';
|
|
import {buildEmailFieldsUpdate} from '../utils/modelUpdate.js';
|
|
|
|
import {DomainService} from './DomainService.js';
|
|
import {EmailService} from './EmailService.js';
|
|
import {NtfyService} from './NtfyService.js';
|
|
import {QueueService} from './QueueService.js';
|
|
import {SegmentService} from './SegmentService.js';
|
|
import {DASHBOARD_URI} from '../app/constants.js';
|
|
import {sendRawEmail} from './SESService.js';
|
|
|
|
const BATCH_SIZE = 500; // Number of emails to process per batch (increased for better performance)
|
|
|
|
export interface CreateCampaignData {
|
|
name: string;
|
|
description?: string;
|
|
subject: string;
|
|
body: string;
|
|
from: string;
|
|
fromName?: string;
|
|
replyTo?: string;
|
|
audienceType: CampaignAudienceType;
|
|
audienceCondition?: FilterCondition;
|
|
segmentId?: string;
|
|
}
|
|
|
|
export interface UpdateCampaignData {
|
|
name?: string;
|
|
description?: string;
|
|
subject?: string;
|
|
body?: string;
|
|
from?: string;
|
|
fromName?: string;
|
|
replyTo?: string;
|
|
audienceType?: CampaignAudienceType;
|
|
audienceCondition?: FilterCondition;
|
|
segmentId?: string;
|
|
}
|
|
|
|
export class CampaignService {
|
|
/**
|
|
* Create a new campaign
|
|
*/
|
|
public static async create(projectId: string, data: CreateCampaignData): Promise<Campaign> {
|
|
// Validate segment if provided
|
|
if (data.audienceType === CampaignAudienceType.SEGMENT) {
|
|
if (!data.segmentId) {
|
|
throw new HttpException(400, 'Segment ID is required for SEGMENT audience type');
|
|
}
|
|
|
|
const segment = await prisma.segment.findFirst({
|
|
where: {
|
|
id: data.segmentId,
|
|
projectId,
|
|
},
|
|
});
|
|
|
|
if (!segment) {
|
|
throw new HttpException(404, 'Segment not found');
|
|
}
|
|
}
|
|
|
|
// Validate condition if provided
|
|
if (data.audienceType === CampaignAudienceType.FILTERED && data.audienceCondition) {
|
|
// This will throw if condition is invalid
|
|
SegmentService.validateCondition(data.audienceCondition);
|
|
}
|
|
|
|
// Create campaign
|
|
const campaign = await prisma.campaign.create({
|
|
data: {
|
|
projectId,
|
|
name: data.name,
|
|
description: data.description,
|
|
subject: data.subject,
|
|
body: data.body,
|
|
from: data.from,
|
|
fromName: data.fromName,
|
|
replyTo: data.replyTo,
|
|
audienceType: data.audienceType,
|
|
audienceCondition: (data.audienceCondition || null) as unknown as Prisma.InputJsonValue,
|
|
segmentId: data.segmentId,
|
|
status: CampaignStatus.DRAFT,
|
|
},
|
|
include: {
|
|
project: {
|
|
select: {name: true},
|
|
},
|
|
},
|
|
});
|
|
|
|
// Send notification about campaign creation
|
|
await NtfyService.notifyCampaignCreated(campaign.name, campaign.project.name, projectId);
|
|
|
|
return campaign;
|
|
}
|
|
|
|
/**
|
|
* Update a campaign
|
|
*/
|
|
public static async update(projectId: string, campaignId: string, data: UpdateCampaignData): Promise<Campaign> {
|
|
const campaign = await this.get(projectId, campaignId);
|
|
|
|
// Can only update draft or scheduled campaigns
|
|
if (campaign.status !== CampaignStatus.DRAFT && campaign.status !== CampaignStatus.SCHEDULED) {
|
|
throw new HttpException(400, 'Cannot update campaign that is sending or has been sent');
|
|
}
|
|
|
|
// Build base update data using shared utility
|
|
const updateData: Prisma.CampaignUpdateInput = buildEmailFieldsUpdate(data) as Prisma.CampaignUpdateInput;
|
|
|
|
// Handle campaign-specific fields
|
|
if (data.audienceType !== undefined) {
|
|
updateData.audienceType = data.audienceType;
|
|
}
|
|
|
|
if (data.audienceCondition !== undefined) {
|
|
if (data.audienceCondition) {
|
|
SegmentService.validateCondition(data.audienceCondition);
|
|
}
|
|
updateData.audienceCondition = (data.audienceCondition || null) as unknown as Prisma.InputJsonValue;
|
|
}
|
|
|
|
if (data.segmentId !== undefined) {
|
|
// Prevent changing segment on scheduled campaigns
|
|
if (campaign.status === CampaignStatus.SCHEDULED) {
|
|
throw new HttpException(400, 'Cannot change segment for scheduled campaigns');
|
|
}
|
|
|
|
if (data.segmentId) {
|
|
const segment = await prisma.segment.findFirst({
|
|
where: {id: data.segmentId, projectId},
|
|
});
|
|
if (!segment) {
|
|
throw new HttpException(404, 'Segment not found');
|
|
}
|
|
updateData.segment = {connect: {id: data.segmentId}};
|
|
} else {
|
|
updateData.segment = {disconnect: true};
|
|
}
|
|
// Remove segmentId from updateData to avoid conflict with relation field
|
|
delete (updateData as Record<string, unknown>).segmentId;
|
|
}
|
|
|
|
return prisma.campaign.update({
|
|
where: {id: campaignId},
|
|
data: updateData,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Get a campaign
|
|
*/
|
|
public static async get(projectId: string, campaignId: string): Promise<Campaign> {
|
|
const campaign = await prisma.campaign.findFirst({
|
|
where: {
|
|
id: campaignId,
|
|
projectId,
|
|
},
|
|
include: {
|
|
segment: true,
|
|
},
|
|
});
|
|
|
|
if (!campaign) {
|
|
throw new HttpException(404, 'Campaign not found');
|
|
}
|
|
|
|
return campaign;
|
|
}
|
|
|
|
/**
|
|
* List campaigns for a project
|
|
*/
|
|
public static async list(
|
|
projectId: string,
|
|
options: {
|
|
status?: CampaignStatus;
|
|
page?: number;
|
|
pageSize?: number;
|
|
} = {},
|
|
): Promise<{campaigns: Campaign[]; total: number; page: number; pageSize: number; totalPages: number}> {
|
|
const {status, page = 1, pageSize = 20} = options;
|
|
const skip = (page - 1) * pageSize;
|
|
|
|
const where: Prisma.CampaignWhereInput = {
|
|
projectId,
|
|
...(status ? {status} : {}),
|
|
};
|
|
|
|
const [campaigns, total] = await Promise.all([
|
|
prisma.campaign.findMany({
|
|
where,
|
|
include: {
|
|
segment: true,
|
|
},
|
|
orderBy: {createdAt: 'desc'},
|
|
skip,
|
|
take: pageSize,
|
|
}),
|
|
prisma.campaign.count({where}),
|
|
]);
|
|
|
|
return {
|
|
campaigns,
|
|
total,
|
|
page,
|
|
pageSize,
|
|
totalPages: Math.ceil(total / pageSize),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Delete a campaign
|
|
*/
|
|
public static async delete(projectId: string, campaignId: string): Promise<void> {
|
|
const campaign = await prisma.campaign.findUnique({
|
|
where: {id: campaignId, projectId},
|
|
include: {
|
|
project: {
|
|
select: {name: true},
|
|
},
|
|
},
|
|
});
|
|
|
|
if (!campaign) {
|
|
throw new HttpException(404, 'Campaign not found');
|
|
}
|
|
|
|
// Can only delete draft campaigns
|
|
if (campaign.status !== CampaignStatus.DRAFT) {
|
|
throw new HttpException(400, 'Can only delete draft campaigns');
|
|
}
|
|
|
|
await prisma.campaign.delete({
|
|
where: {id: campaignId},
|
|
});
|
|
|
|
// Send notification about campaign deletion
|
|
await NtfyService.notifyCampaignDeleted(campaign.name, campaign.project.name, projectId);
|
|
}
|
|
|
|
/**
|
|
* Duplicate a campaign
|
|
*/
|
|
public static async duplicate(projectId: string, campaignId: string): Promise<Campaign> {
|
|
const campaign = await this.get(projectId, campaignId);
|
|
|
|
// Create a new campaign with the same data but reset status and stats
|
|
return prisma.campaign.create({
|
|
data: {
|
|
projectId,
|
|
name: `${campaign.name} (Copy)`,
|
|
description: campaign.description,
|
|
subject: campaign.subject,
|
|
body: campaign.body,
|
|
from: campaign.from,
|
|
fromName: campaign.fromName,
|
|
replyTo: campaign.replyTo,
|
|
audienceType: campaign.audienceType,
|
|
audienceCondition: campaign.audienceCondition as Prisma.InputJsonValue,
|
|
segmentId: campaign.segmentId,
|
|
status: CampaignStatus.DRAFT,
|
|
totalRecipients: 0,
|
|
sentCount: 0,
|
|
deliveredCount: 0,
|
|
openedCount: 0,
|
|
clickedCount: 0,
|
|
bouncedCount: 0,
|
|
},
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Send campaign immediately or schedule for later
|
|
*/
|
|
public static async send(projectId: string, campaignId: string, scheduledFor?: Date): Promise<Campaign> {
|
|
const campaign = await this.get(projectId, campaignId);
|
|
|
|
// Validate status
|
|
if (campaign.status !== CampaignStatus.DRAFT && campaign.status !== CampaignStatus.SCHEDULED) {
|
|
throw new HttpException(400, 'Campaign has already been sent or is currently sending');
|
|
}
|
|
|
|
// Get recipient count to validate there are contacts to send to
|
|
const recipientCount = await this.getRecipientCount(projectId, campaign);
|
|
|
|
if (recipientCount === 0) {
|
|
throw new HttpException(400, 'Campaign has no recipients');
|
|
}
|
|
|
|
if (scheduledFor) {
|
|
// Schedule for later
|
|
if (scheduledFor.getTime() <= Date.now()) {
|
|
throw new HttpException(400, 'Scheduled time must be in the future');
|
|
}
|
|
|
|
// Update campaign status
|
|
const updatedCampaign = await prisma.campaign.update({
|
|
where: {id: campaignId},
|
|
data: {
|
|
status: CampaignStatus.SCHEDULED,
|
|
scheduledFor,
|
|
totalRecipients: recipientCount,
|
|
},
|
|
include: {
|
|
project: {
|
|
select: {name: true},
|
|
},
|
|
},
|
|
});
|
|
|
|
// Queue for scheduled sending
|
|
await QueueService.scheduleCampaign(campaignId, scheduledFor);
|
|
|
|
// Send notification about campaign scheduled
|
|
await NtfyService.notifyCampaignScheduled(
|
|
updatedCampaign.name,
|
|
updatedCampaign.project.name,
|
|
projectId,
|
|
scheduledFor,
|
|
recipientCount,
|
|
);
|
|
|
|
return updatedCampaign;
|
|
} else {
|
|
// Send immediately - start the batch processing
|
|
await this.startSending(projectId, campaignId, recipientCount);
|
|
|
|
return this.get(projectId, campaignId);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Start sending campaign (called immediately or when scheduled time arrives)
|
|
* Now uses cursor-based pagination for better performance with large recipient lists
|
|
*/
|
|
public static async startSending(projectId: string, campaignId: string, recipientCount?: number): Promise<void> {
|
|
const campaign = await this.get(projectId, campaignId);
|
|
|
|
// Validate status
|
|
if (
|
|
campaign.status !== CampaignStatus.DRAFT &&
|
|
campaign.status !== CampaignStatus.SCHEDULED &&
|
|
campaign.status !== CampaignStatus.SENDING
|
|
) {
|
|
throw new HttpException(400, 'Campaign cannot be sent in its current status');
|
|
}
|
|
|
|
// Get recipient count if not provided
|
|
if (recipientCount === undefined) {
|
|
recipientCount = await this.getRecipientCount(projectId, campaign);
|
|
}
|
|
|
|
// Update campaign to SENDING status
|
|
const updatedCampaign = await prisma.campaign.update({
|
|
where: {id: campaignId},
|
|
data: {
|
|
status: CampaignStatus.SENDING,
|
|
totalRecipients: recipientCount,
|
|
sentAt: new Date(),
|
|
},
|
|
include: {
|
|
project: {
|
|
select: {name: true},
|
|
},
|
|
},
|
|
});
|
|
|
|
// Send notification about campaign sending started
|
|
await NtfyService.notifyCampaignSendingStarted(
|
|
updatedCampaign.name,
|
|
updatedCampaign.project.name,
|
|
projectId,
|
|
recipientCount,
|
|
);
|
|
|
|
// Queue first batch to start the cursor-based chain
|
|
await QueueService.queueCampaignBatch({
|
|
campaignId,
|
|
batchNumber: 1,
|
|
offset: 0,
|
|
limit: BATCH_SIZE,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Process a single batch of campaign emails
|
|
* Now uses cursor-based pagination for better performance
|
|
*/
|
|
public static async processBatch(
|
|
campaignId: string,
|
|
batchNumber: number,
|
|
offset: number,
|
|
limit: number,
|
|
cursor?: string,
|
|
): Promise<void> {
|
|
const campaign = await prisma.campaign.findUnique({
|
|
where: {id: campaignId},
|
|
include: {
|
|
project: true,
|
|
},
|
|
});
|
|
|
|
if (!campaign) {
|
|
throw new HttpException(404, 'Campaign not found');
|
|
}
|
|
|
|
if (campaign.status !== CampaignStatus.SENDING) {
|
|
console.warn(`[CAMPAIGN] Campaign ${campaignId} is not in SENDING status, skipping batch ${batchNumber}`);
|
|
return;
|
|
}
|
|
|
|
// Get batch of recipients using cursor-based pagination
|
|
const {contacts, nextCursor, hasMore} = await this.getRecipientsCursor(campaign.projectId, campaign, limit, cursor);
|
|
|
|
// Queue emails for each contact
|
|
for (const contact of contacts) {
|
|
try {
|
|
// Render template with contact data
|
|
const contactData =
|
|
contact.data && typeof contact.data === 'object' && !Array.isArray(contact.data) ? contact.data : {};
|
|
const variables = {
|
|
email: contact.email,
|
|
...contactData,
|
|
data: contactData,
|
|
unsubscribeUrl: `${DASHBOARD_URI}/unsubscribe/${contact.id}`,
|
|
subscribeUrl: `${DASHBOARD_URI}/subscribe/${contact.id}`,
|
|
manageUrl: `${DASHBOARD_URI}/manage/${contact.id}`,
|
|
};
|
|
|
|
const renderedSubject = EmailService.format({
|
|
subject: campaign.subject,
|
|
body: '',
|
|
data: variables,
|
|
}).subject;
|
|
|
|
const renderedBody = EmailService.format({
|
|
subject: '',
|
|
body: campaign.body,
|
|
data: variables,
|
|
}).body;
|
|
|
|
await EmailService.sendCampaignEmail({
|
|
projectId: campaign.projectId,
|
|
contactId: contact.id,
|
|
campaignId: campaign.id,
|
|
templateId: undefined,
|
|
subject: renderedSubject,
|
|
body: renderedBody,
|
|
from: campaign.from,
|
|
fromName: campaign.fromName || undefined,
|
|
replyTo: campaign.replyTo || undefined,
|
|
});
|
|
} catch (error) {
|
|
console.error(`[CAMPAIGN] Failed to queue email for contact ${contact.id}:`, error);
|
|
// Continue with other contacts even if one fails
|
|
}
|
|
}
|
|
|
|
// Update sent count
|
|
await prisma.campaign.update({
|
|
where: {id: campaignId},
|
|
data: {
|
|
sentCount: {
|
|
increment: contacts.length,
|
|
},
|
|
},
|
|
});
|
|
|
|
// Queue next batch if there are more contacts
|
|
if (hasMore && nextCursor) {
|
|
await QueueService.queueCampaignBatch({
|
|
campaignId,
|
|
batchNumber: batchNumber + 1,
|
|
offset: 0, // Not used with cursor pagination
|
|
limit,
|
|
cursor: nextCursor,
|
|
});
|
|
} else {
|
|
// All batches processed, mark campaign as SENT
|
|
const completedCampaign = await prisma.campaign.update({
|
|
where: {id: campaignId},
|
|
data: {
|
|
status: CampaignStatus.SENT,
|
|
},
|
|
include: {
|
|
project: {
|
|
select: {name: true},
|
|
},
|
|
},
|
|
});
|
|
|
|
// Send notification about campaign send completed
|
|
await NtfyService.notifyCampaignSendCompleted(
|
|
completedCampaign.name,
|
|
completedCampaign.project.name,
|
|
completedCampaign.projectId,
|
|
completedCampaign.totalRecipients || 0,
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Cancel a campaign
|
|
*/
|
|
public static async cancel(projectId: string, campaignId: string): Promise<Campaign> {
|
|
const campaign = await this.get(projectId, campaignId);
|
|
|
|
// Can only cancel scheduled or sending campaigns
|
|
if (campaign.status !== CampaignStatus.SCHEDULED && campaign.status !== CampaignStatus.SENDING) {
|
|
throw new HttpException(400, 'Can only cancel scheduled or sending campaigns');
|
|
}
|
|
|
|
// If scheduled, remove from queue
|
|
if (campaign.status === CampaignStatus.SCHEDULED) {
|
|
await QueueService.cancelScheduledCampaign(campaignId);
|
|
}
|
|
|
|
// Update status
|
|
const cancelledCampaign = await prisma.campaign.update({
|
|
where: {id: campaignId},
|
|
data: {
|
|
status: CampaignStatus.CANCELLED,
|
|
},
|
|
include: {
|
|
project: {
|
|
select: {name: true},
|
|
},
|
|
},
|
|
});
|
|
|
|
// Send notification about campaign cancellation
|
|
await NtfyService.notifyCampaignCancelled(cancelledCampaign.name, cancelledCampaign.project.name, projectId);
|
|
|
|
return cancelledCampaign;
|
|
}
|
|
|
|
/**
|
|
* Get campaign statistics
|
|
*/
|
|
public static async getStats(projectId: string, campaignId: string) {
|
|
const campaign = await this.get(projectId, campaignId);
|
|
|
|
// Get email stats from Email table
|
|
const [sentEmails, deliveredEmails, openedEmails, clickedEmails, bouncedEmails] = await Promise.all([
|
|
prisma.email.count({
|
|
where: {campaignId, sentAt: {not: null}},
|
|
}),
|
|
prisma.email.count({
|
|
where: {campaignId, deliveredAt: {not: null}},
|
|
}),
|
|
prisma.email.count({
|
|
where: {campaignId, openedAt: {not: null}},
|
|
}),
|
|
prisma.email.count({
|
|
where: {campaignId, clickedAt: {not: null}},
|
|
}),
|
|
prisma.email.count({
|
|
where: {campaignId, bouncedAt: {not: null}},
|
|
}),
|
|
]);
|
|
|
|
// Update campaign stats
|
|
await prisma.campaign.update({
|
|
where: {id: campaignId},
|
|
data: {
|
|
deliveredCount: deliveredEmails,
|
|
openedCount: openedEmails,
|
|
clickedCount: clickedEmails,
|
|
bouncedCount: bouncedEmails,
|
|
},
|
|
});
|
|
|
|
return {
|
|
totalRecipients: campaign.totalRecipients,
|
|
sentCount: sentEmails,
|
|
deliveredCount: deliveredEmails,
|
|
openedCount: openedEmails,
|
|
clickedCount: clickedEmails,
|
|
bouncedCount: bouncedEmails,
|
|
openRate: sentEmails > 0 ? (openedEmails / sentEmails) * 100 : 0,
|
|
clickRate: sentEmails > 0 ? (clickedEmails / sentEmails) * 100 : 0,
|
|
bounceRate: sentEmails > 0 ? (bouncedEmails / sentEmails) * 100 : 0,
|
|
deliveryRate: sentEmails > 0 ? (deliveredEmails / sentEmails) * 100 : 0,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Send a test email for a campaign
|
|
*/
|
|
public static async sendTest(projectId: string, campaignId: string, testEmail: string): Promise<void> {
|
|
const campaign = await this.get(projectId, campaignId);
|
|
|
|
// Validate that the test email belongs to a project member
|
|
const membership = await prisma.membership.findFirst({
|
|
where: {
|
|
projectId,
|
|
user: {
|
|
email: testEmail,
|
|
},
|
|
},
|
|
include: {
|
|
user: true,
|
|
},
|
|
});
|
|
|
|
if (!membership) {
|
|
throw new HttpException(403, 'Test emails can only be sent to project members');
|
|
}
|
|
|
|
// Verify domain is registered and verified before sending
|
|
await DomainService.verifyEmailDomain(campaign.from, projectId);
|
|
|
|
// Get project to validate from address
|
|
const project = await prisma.project.findUnique({
|
|
where: {id: projectId},
|
|
});
|
|
|
|
if (!project) {
|
|
throw new HttpException(404, 'Project not found');
|
|
}
|
|
|
|
// Prepare the email content (no variable replacement for test emails)
|
|
await sendRawEmail({
|
|
from: {
|
|
name: campaign.fromName || project.name || 'Plunk',
|
|
email: campaign.from,
|
|
},
|
|
to: [testEmail],
|
|
content: {
|
|
subject: `[TEST] ${campaign.subject}`,
|
|
html: campaign.body,
|
|
},
|
|
reply: campaign.replyTo || undefined,
|
|
headers: {
|
|
'X-Plunk-Test': 'true',
|
|
},
|
|
tracking: false, // Disable tracking for test emails
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Get recipient count for a campaign
|
|
*/
|
|
private static async getRecipientCount(projectId: string, campaign: Campaign): Promise<number> {
|
|
const where = await this.buildRecipientWhereAsync(projectId, campaign);
|
|
return prisma.contact.count({where});
|
|
}
|
|
|
|
/**
|
|
* Get recipients for a campaign (legacy offset-based, kept for compatibility)
|
|
*/
|
|
private static async getRecipients(
|
|
projectId: string,
|
|
campaign: Campaign,
|
|
offset: number,
|
|
limit: number,
|
|
): Promise<Contact[]> {
|
|
const where = await this.buildRecipientWhereAsync(projectId, campaign);
|
|
|
|
return prisma.contact.findMany({
|
|
where,
|
|
skip: offset,
|
|
take: limit,
|
|
orderBy: {createdAt: 'asc'}, // Consistent ordering for batching
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Get recipients for a campaign using cursor-based pagination
|
|
*/
|
|
private static async getRecipientsCursor(
|
|
projectId: string,
|
|
campaign: Campaign,
|
|
limit: number,
|
|
cursor?: string,
|
|
): Promise<{contacts: Contact[]; nextCursor?: string; hasMore: boolean}> {
|
|
const where = await this.buildRecipientWhereAsync(projectId, campaign);
|
|
|
|
// Fetch one extra to determine if there are more results
|
|
const contacts = await prisma.contact.findMany({
|
|
where,
|
|
take: limit + 1,
|
|
skip: cursor ? 1 : 0,
|
|
cursor: cursor ? {id: cursor} : undefined,
|
|
orderBy: {id: 'asc'}, // Use ID for consistent cursor ordering
|
|
});
|
|
|
|
const hasMore = contacts.length > limit;
|
|
const results = hasMore ? contacts.slice(0, -1) : contacts;
|
|
const nextCursor = hasMore ? results[results.length - 1]?.id : undefined;
|
|
|
|
return {
|
|
contacts: results,
|
|
nextCursor,
|
|
hasMore,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Build WHERE clause for campaign recipients (async for segment lookups)
|
|
*/
|
|
private static async buildRecipientWhereAsync(
|
|
projectId: string,
|
|
campaign: Campaign,
|
|
): Promise<Prisma.ContactWhereInput> {
|
|
const baseWhere: Prisma.ContactWhereInput = {
|
|
projectId,
|
|
subscribed: true, // Only send to subscribed contacts
|
|
};
|
|
|
|
switch (campaign.audienceType) {
|
|
case CampaignAudienceType.ALL:
|
|
return baseWhere;
|
|
|
|
case CampaignAudienceType.SEGMENT:
|
|
if (!campaign.segmentId) {
|
|
throw new HttpException(400, 'Segment ID is required for SEGMENT audience type');
|
|
}
|
|
|
|
// Get segment and use its filters
|
|
return this.buildSegmentWhereAsync(projectId, campaign.segmentId, baseWhere);
|
|
|
|
case CampaignAudienceType.FILTERED: {
|
|
const condition = campaign.audienceCondition as unknown as FilterCondition;
|
|
if (!condition) {
|
|
throw new HttpException(400, 'Audience condition is required for FILTERED audience type');
|
|
}
|
|
|
|
// Use the SegmentService to build the where clause from the condition
|
|
const segmentWhere = SegmentService.buildConditionClause(condition);
|
|
|
|
return {
|
|
...baseWhere,
|
|
...segmentWhere,
|
|
};
|
|
}
|
|
|
|
default:
|
|
throw new HttpException(400, 'Invalid audience type');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Build WHERE clause for segment-based campaigns
|
|
*/
|
|
private static async buildSegmentWhereAsync(
|
|
projectId: string,
|
|
segmentId: string,
|
|
baseWhere: Prisma.ContactWhereInput,
|
|
): Promise<Prisma.ContactWhereInput> {
|
|
// Fetch the segment to get its condition
|
|
const segment = await prisma.segment.findUnique({
|
|
where: {id: segmentId},
|
|
});
|
|
|
|
if (!segment) {
|
|
throw new HttpException(404, 'Segment not found');
|
|
}
|
|
|
|
const condition = segment.condition as unknown as FilterCondition;
|
|
const segmentWhere = SegmentService.buildConditionClause(condition);
|
|
|
|
return {
|
|
...baseWhere,
|
|
...segmentWhere,
|
|
};
|
|
}
|
|
}
|