Initial push of Plunk Next
This commit is contained in:
@@ -0,0 +1,344 @@
|
||||
import {describe, it, expect, beforeEach, vi} from 'vitest';
|
||||
import {EmailStatus} from '@plunk/db';
|
||||
import {factories, getPrismaClient, createServiceMocks} from '../../../../../test/helpers';
|
||||
import type {Prisma} from '@plunk/db';
|
||||
|
||||
// Mock MeterService
|
||||
vi.mock('../../services/MeterService.js', () => ({
|
||||
MeterService: {
|
||||
recordEmailSent: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('Email Processor', () => {
|
||||
let projectId: string;
|
||||
const prisma = getPrismaClient();
|
||||
const _serviceMocks = createServiceMocks();
|
||||
|
||||
beforeEach(async () => {
|
||||
const {project} = await factories.createUserWithProject({}, {trackingEnabled: true});
|
||||
projectId = project.id;
|
||||
});
|
||||
|
||||
describe('Email Processing', () => {
|
||||
it('should process a pending email', async () => {
|
||||
const contact = await factories.createContact({projectId});
|
||||
const email = await factories.createEmail(projectId, contact.id, {
|
||||
subject: 'Test Email',
|
||||
body: '<p>Hello {{firstName}}</p>',
|
||||
status: EmailStatus.PENDING,
|
||||
});
|
||||
|
||||
// Mock the email processor logic
|
||||
// In a real implementation, you would:
|
||||
// 1. Create job tester
|
||||
// 2. Mock SES service
|
||||
// 3. Process the job
|
||||
// 4. Verify status changes
|
||||
|
||||
// Simulate processing
|
||||
await prisma.email.update({
|
||||
where: {id: email.id},
|
||||
data: {status: EmailStatus.SENDING},
|
||||
});
|
||||
|
||||
// Simulate successful send
|
||||
await prisma.email.update({
|
||||
where: {id: email.id},
|
||||
data: {
|
||||
status: EmailStatus.SENT,
|
||||
sentAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
const processed = await prisma.email.findUnique({where: {id: email.id}});
|
||||
expect(processed?.status).toBe(EmailStatus.SENT);
|
||||
expect(processed?.sentAt).toBeDefined();
|
||||
});
|
||||
|
||||
it('should skip emails that are not pending', async () => {
|
||||
const contact = await factories.createContact({projectId});
|
||||
const email = await factories.createEmail(projectId, contact.id, {
|
||||
status: EmailStatus.SENT, // Already sent
|
||||
});
|
||||
|
||||
// Processor should skip this email
|
||||
const shouldProcess = email.status === EmailStatus.PENDING;
|
||||
expect(shouldProcess).toBe(false);
|
||||
});
|
||||
|
||||
it('should fail email if project is disabled', async () => {
|
||||
// Create project with disabled flag
|
||||
const {project: disabledProject} = await factories.createUserWithProject({}, {disabled: true});
|
||||
|
||||
const contact = await factories.createContact({projectId: disabledProject.id});
|
||||
const email = await factories.createEmail(disabledProject.id, contact.id, {
|
||||
status: EmailStatus.PENDING,
|
||||
});
|
||||
|
||||
// Verify project is disabled
|
||||
const project = await prisma.project.findUnique({
|
||||
where: {id: disabledProject.id},
|
||||
});
|
||||
expect(project?.disabled).toBe(true);
|
||||
|
||||
// Processor should fail this email
|
||||
await prisma.email.update({
|
||||
where: {id: email.id},
|
||||
data: {
|
||||
status: EmailStatus.FAILED,
|
||||
error: 'Project is disabled',
|
||||
},
|
||||
});
|
||||
|
||||
const failed = await prisma.email.findUnique({where: {id: email.id}});
|
||||
expect(failed?.status).toBe(EmailStatus.FAILED);
|
||||
expect(failed?.error).toBe('Project is disabled');
|
||||
});
|
||||
|
||||
it('should handle campaign emails', async () => {
|
||||
const contact = await factories.createContact({projectId});
|
||||
const campaign = await factories.createCampaign({projectId});
|
||||
const email = await factories.createEmail(projectId, contact.id, {
|
||||
campaignId: campaign.id,
|
||||
status: EmailStatus.PENDING,
|
||||
});
|
||||
|
||||
expect(email.campaignId).toBe(campaign.id);
|
||||
|
||||
// Process the email
|
||||
await prisma.email.update({
|
||||
where: {id: email.id},
|
||||
data: {status: EmailStatus.SENT, sentAt: new Date()},
|
||||
});
|
||||
|
||||
const sent = await prisma.email.findUnique({where: {id: email.id}});
|
||||
expect(sent?.status).toBe(EmailStatus.SENT);
|
||||
});
|
||||
|
||||
it('should handle transactional emails without unsubscribe', async () => {
|
||||
const contact = await factories.createContact({projectId});
|
||||
const template = await factories.createTemplate({
|
||||
projectId,
|
||||
type: 'TRANSACTIONAL',
|
||||
});
|
||||
|
||||
await factories.createEmail(projectId, contact.id, {
|
||||
templateId: template.id,
|
||||
status: EmailStatus.PENDING,
|
||||
});
|
||||
|
||||
expect(template.type).toBe('TRANSACTIONAL');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Email Status Transitions', () => {
|
||||
it('should transition PENDING -> SENDING -> SENT', async () => {
|
||||
const contact = await factories.createContact({projectId});
|
||||
const email = await factories.createEmail(projectId, contact.id, {
|
||||
status: EmailStatus.PENDING,
|
||||
});
|
||||
|
||||
expect(email.status).toBe(EmailStatus.PENDING);
|
||||
|
||||
// Transition to SENDING
|
||||
await prisma.email.update({
|
||||
where: {id: email.id},
|
||||
data: {status: EmailStatus.SENDING},
|
||||
});
|
||||
|
||||
let updated = await prisma.email.findUnique({where: {id: email.id}});
|
||||
expect(updated?.status).toBe(EmailStatus.SENDING);
|
||||
|
||||
// Transition to SENT
|
||||
await prisma.email.update({
|
||||
where: {id: email.id},
|
||||
data: {status: EmailStatus.SENT, sentAt: new Date()},
|
||||
});
|
||||
|
||||
updated = await prisma.email.findUnique({where: {id: email.id}});
|
||||
expect(updated?.status).toBe(EmailStatus.SENT);
|
||||
expect(updated?.sentAt).toBeDefined();
|
||||
});
|
||||
|
||||
it('should handle PENDING -> SENDING -> FAILED', async () => {
|
||||
const contact = await factories.createContact({projectId});
|
||||
const email = await factories.createEmail(projectId, contact.id, {
|
||||
status: EmailStatus.PENDING,
|
||||
});
|
||||
|
||||
// Transition to SENDING
|
||||
await prisma.email.update({
|
||||
where: {id: email.id},
|
||||
data: {status: EmailStatus.SENDING},
|
||||
});
|
||||
|
||||
// Fail with error
|
||||
await prisma.email.update({
|
||||
where: {id: email.id},
|
||||
data: {
|
||||
status: EmailStatus.FAILED,
|
||||
error: 'SES send failed: Invalid email address',
|
||||
},
|
||||
});
|
||||
|
||||
const failed = await prisma.email.findUnique({where: {id: email.id}});
|
||||
expect(failed?.status).toBe(EmailStatus.FAILED);
|
||||
expect(failed?.error).toContain('SES send failed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Batch Processing', () => {
|
||||
it('should handle multiple emails from a campaign', async () => {
|
||||
const campaign = await factories.createCampaign({projectId});
|
||||
const contacts = await factories.createContacts(projectId, 10);
|
||||
|
||||
// Create emails for all contacts
|
||||
const emails = await Promise.all(
|
||||
contacts.map(contact =>
|
||||
factories.createEmail(projectId, contact.id, {
|
||||
campaignId: campaign.id,
|
||||
status: EmailStatus.PENDING,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
expect(emails).toHaveLength(10);
|
||||
expect(emails.every(e => e.campaignId === campaign.id)).toBe(true);
|
||||
|
||||
// Simulate processing all emails
|
||||
await Promise.all(
|
||||
emails.map(email =>
|
||||
prisma.email.update({
|
||||
where: {id: email.id},
|
||||
data: {status: EmailStatus.SENT, sentAt: new Date()},
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const processed = await prisma.email.findMany({
|
||||
where: {campaignId: campaign.id},
|
||||
});
|
||||
|
||||
expect(processed.every(e => e.status === EmailStatus.SENT)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling', () => {
|
||||
it('should record error message on failure', async () => {
|
||||
const contact = await factories.createContact({projectId});
|
||||
const email = await factories.createEmail(projectId, contact.id, {
|
||||
status: EmailStatus.PENDING,
|
||||
});
|
||||
|
||||
// Simulate failure
|
||||
const errorMessage = 'Failed to send: Rate limit exceeded';
|
||||
await prisma.email.update({
|
||||
where: {id: email.id},
|
||||
data: {
|
||||
status: EmailStatus.FAILED,
|
||||
error: errorMessage,
|
||||
},
|
||||
});
|
||||
|
||||
const failed = await prisma.email.findUnique({where: {id: email.id}});
|
||||
expect(failed?.status).toBe(EmailStatus.FAILED);
|
||||
expect(failed?.error).toBe(errorMessage);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Attachment Billing', () => {
|
||||
it('should verify emails with attachments have attachment data', async () => {
|
||||
const contact = await factories.createContact({projectId});
|
||||
|
||||
// Create email with attachments
|
||||
const emailWithAttachments = await prisma.email.create({
|
||||
data: {
|
||||
projectId,
|
||||
contactId: contact.id,
|
||||
subject: 'Email with attachments',
|
||||
body: '<p>Test email with attachments</p>',
|
||||
from: '[email protected]',
|
||||
status: EmailStatus.PENDING,
|
||||
attachments: [
|
||||
{
|
||||
filename: 'document.pdf',
|
||||
content: 'base64encodedcontent',
|
||||
contentType: 'application/pdf',
|
||||
},
|
||||
] as unknown as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
|
||||
// Create email without attachments
|
||||
const emailWithoutAttachments = await factories.createEmail(projectId, contact.id, {
|
||||
status: EmailStatus.PENDING,
|
||||
});
|
||||
|
||||
// Verify attachments are stored correctly
|
||||
const emailWithAttachmentsData = await prisma.email.findUnique({
|
||||
where: {id: emailWithAttachments.id},
|
||||
});
|
||||
const emailWithoutAttachmentsData = await prisma.email.findUnique({
|
||||
where: {id: emailWithoutAttachments.id},
|
||||
});
|
||||
|
||||
expect(emailWithAttachmentsData?.attachments).toBeDefined();
|
||||
expect(Array.isArray(emailWithAttachmentsData?.attachments)).toBe(true);
|
||||
expect((emailWithAttachmentsData?.attachments as any[]).length).toBeGreaterThan(0);
|
||||
|
||||
expect(emailWithoutAttachmentsData?.attachments).toBeNull();
|
||||
});
|
||||
|
||||
it('should verify attachment logic determines charging correctly', async () => {
|
||||
const contact = await factories.createContact({projectId});
|
||||
|
||||
// Create email with attachments
|
||||
const emailWithAttachments = await prisma.email.create({
|
||||
data: {
|
||||
projectId,
|
||||
contactId: contact.id,
|
||||
subject: 'Email with attachments',
|
||||
body: '<p>Test</p>',
|
||||
from: '[email protected]',
|
||||
status: EmailStatus.PENDING,
|
||||
attachments: [
|
||||
{filename: 'file.pdf', content: 'base64', contentType: 'application/pdf'},
|
||||
] as unknown as Prisma.InputJsonValue,
|
||||
},
|
||||
include: {
|
||||
project: true,
|
||||
contact: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Simulate the logic from email-processor.ts
|
||||
const hasAttachments =
|
||||
emailWithAttachments.attachments &&
|
||||
Array.isArray(emailWithAttachments.attachments) &&
|
||||
emailWithAttachments.attachments.length > 0;
|
||||
const emailCount = hasAttachments ? 2 : 1;
|
||||
|
||||
// Verify logic correctly identifies attachments
|
||||
expect(hasAttachments).toBe(true);
|
||||
expect(emailCount).toBe(2);
|
||||
|
||||
// Test without attachments
|
||||
const emailWithoutAttachments = await factories.createEmail(projectId, contact.id, {
|
||||
status: EmailStatus.PENDING,
|
||||
});
|
||||
const emailWithoutAttachmentsData = await prisma.email.findUnique({
|
||||
where: {id: emailWithoutAttachments.id},
|
||||
});
|
||||
|
||||
const hasNoAttachments =
|
||||
emailWithoutAttachmentsData?.attachments &&
|
||||
Array.isArray(emailWithoutAttachmentsData.attachments) &&
|
||||
emailWithoutAttachmentsData.attachments.length > 0;
|
||||
const emailCountNoAttachments = hasNoAttachments ? 2 : 1;
|
||||
|
||||
expect(hasNoAttachments).toBeFalsy();
|
||||
expect(emailCountNoAttachments).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,227 @@
|
||||
import {describe, it, expect, beforeEach, afterEach} from 'vitest';
|
||||
import {CampaignStatus} from '@plunk/db';
|
||||
import {factories, getPrismaClient, createTimeControl} from '../../../../../test/helpers';
|
||||
|
||||
describe('Scheduled Campaign Processor', () => {
|
||||
let projectId: string;
|
||||
const prisma = getPrismaClient();
|
||||
const timeControl = createTimeControl();
|
||||
|
||||
beforeEach(async () => {
|
||||
const {project} = await factories.createUserWithProject();
|
||||
projectId = project.id;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Ensure time is always restored between tests
|
||||
timeControl.restore();
|
||||
});
|
||||
|
||||
describe('Campaign Scheduling', () => {
|
||||
it('should execute campaign at scheduled time', async () => {
|
||||
// Freeze time at a specific moment
|
||||
timeControl.freeze(new Date('2025-01-20T09:00:00Z'));
|
||||
|
||||
// Schedule campaign for 1 hour from now
|
||||
const scheduledTime = timeControl.helpers.relative(1, 'hour');
|
||||
const campaign = await factories.createScheduledCampaign(projectId, scheduledTime, {
|
||||
name: 'Scheduled Newsletter',
|
||||
subject: 'Weekly Update',
|
||||
});
|
||||
|
||||
expect(campaign.status).toBe(CampaignStatus.SCHEDULED);
|
||||
expect(campaign.scheduledFor).toEqual(scheduledTime);
|
||||
|
||||
// Advance time to scheduled time
|
||||
timeControl.advanceTo(scheduledTime);
|
||||
|
||||
// Verify we're at the right time
|
||||
expect(timeControl.now()).toEqual(scheduledTime);
|
||||
|
||||
// At this point, the scheduled processor would pick up this campaign
|
||||
// and change its status to SENDING
|
||||
await prisma.campaign.update({
|
||||
where: {id: campaign.id},
|
||||
data: {status: CampaignStatus.SENDING},
|
||||
});
|
||||
|
||||
const updatedCampaign = await prisma.campaign.findUnique({
|
||||
where: {id: campaign.id},
|
||||
});
|
||||
|
||||
expect(updatedCampaign?.status).toBe(CampaignStatus.SENDING);
|
||||
|
||||
timeControl.restore();
|
||||
});
|
||||
|
||||
it('should not execute campaign before scheduled time', async () => {
|
||||
timeControl.freeze(new Date('2025-01-20T09:00:00Z'));
|
||||
|
||||
// Schedule campaign for 2 hours from now
|
||||
const scheduledTime = timeControl.helpers.relative(2, 'hour');
|
||||
const campaign = await factories.createScheduledCampaign(projectId, scheduledTime);
|
||||
|
||||
// Advance time by only 1 hour (before scheduled time)
|
||||
timeControl.helpers.advanceHours(1);
|
||||
|
||||
// Campaign should still be scheduled
|
||||
const stillScheduled = await prisma.campaign.findUnique({
|
||||
where: {id: campaign.id},
|
||||
});
|
||||
|
||||
expect(stillScheduled?.status).toBe(CampaignStatus.SCHEDULED);
|
||||
expect(stillScheduled?.scheduledFor).toEqual(scheduledTime);
|
||||
|
||||
timeControl.restore();
|
||||
});
|
||||
|
||||
it('should handle multiple scheduled campaigns at different times', async () => {
|
||||
timeControl.freeze(new Date('2025-01-20T10:00:00Z'));
|
||||
|
||||
// Create campaigns scheduled at different times
|
||||
await factories.createScheduledCampaign(
|
||||
projectId,
|
||||
timeControl.helpers.relative(1, 'hour'), // 11:00
|
||||
{name: 'Campaign 1'},
|
||||
);
|
||||
|
||||
await factories.createScheduledCampaign(
|
||||
projectId,
|
||||
timeControl.helpers.relative(2, 'hour'), // 12:00
|
||||
{name: 'Campaign 2'},
|
||||
);
|
||||
|
||||
await factories.createScheduledCampaign(
|
||||
projectId,
|
||||
timeControl.helpers.relative(3, 'hour'), // 13:00
|
||||
{name: 'Campaign 3'},
|
||||
);
|
||||
|
||||
// Advance to 11:00 - first campaign should be processable
|
||||
timeControl.helpers.advanceHours(1);
|
||||
expect(timeControl.now().getUTCHours()).toBe(11);
|
||||
|
||||
// Advance to 12:00 - second campaign should be processable
|
||||
timeControl.helpers.advanceHours(1);
|
||||
expect(timeControl.now().getUTCHours()).toBe(12);
|
||||
|
||||
// Advance to 13:00 - third campaign should be processable
|
||||
timeControl.helpers.advanceHours(1);
|
||||
expect(timeControl.now().getUTCHours()).toBe(13);
|
||||
|
||||
// All campaigns should still be in scheduled state (until processor runs)
|
||||
const campaigns = await prisma.campaign.findMany({
|
||||
where: {projectId},
|
||||
orderBy: {scheduledFor: 'asc'},
|
||||
});
|
||||
|
||||
expect(campaigns).toHaveLength(3);
|
||||
expect(campaigns.every(c => c.status === CampaignStatus.SCHEDULED)).toBe(true);
|
||||
|
||||
timeControl.restore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Campaign Status Transitions', () => {
|
||||
it('should transition from SCHEDULED to SENDING', async () => {
|
||||
timeControl.freeze(new Date('2025-01-20T10:00:00Z'));
|
||||
const scheduledTime = timeControl.helpers.relative(30, 'minute');
|
||||
|
||||
const campaign = await factories.createCampaign({
|
||||
projectId,
|
||||
status: CampaignStatus.SCHEDULED,
|
||||
scheduledFor: scheduledTime,
|
||||
});
|
||||
|
||||
// Advance past scheduled time
|
||||
timeControl.helpers.advanceMinutes(31);
|
||||
|
||||
// Processor would transition to SENDING
|
||||
await prisma.campaign.update({
|
||||
where: {id: campaign.id},
|
||||
data: {status: CampaignStatus.SENDING},
|
||||
});
|
||||
|
||||
const sending = await prisma.campaign.findUnique({
|
||||
where: {id: campaign.id},
|
||||
});
|
||||
|
||||
expect(sending?.status).toBe(CampaignStatus.SENDING);
|
||||
|
||||
timeControl.restore();
|
||||
});
|
||||
|
||||
it('should eventually transition from SENDING to SENT', async () => {
|
||||
const campaign = await factories.createCampaign({
|
||||
projectId,
|
||||
status: CampaignStatus.SENDING,
|
||||
});
|
||||
|
||||
// Create some contacts and emails
|
||||
const contact = await factories.createContact({projectId});
|
||||
await factories.createEmail(projectId, contact.id, {
|
||||
campaignId: campaign.id,
|
||||
});
|
||||
|
||||
// Mark campaign as sent
|
||||
await prisma.campaign.update({
|
||||
where: {id: campaign.id},
|
||||
data: {
|
||||
status: CampaignStatus.SENT,
|
||||
sentAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
const sent = await prisma.campaign.findUnique({
|
||||
where: {id: campaign.id},
|
||||
});
|
||||
|
||||
expect(sent?.status).toBe(CampaignStatus.SENT);
|
||||
expect(sent?.sentAt).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle campaigns scheduled in the past', async () => {
|
||||
timeControl.freeze(new Date('2025-01-20T10:00:00Z'));
|
||||
|
||||
// Schedule campaign in the past
|
||||
const pastTime = new Date('2025-01-19T10:00:00Z');
|
||||
const campaign = await factories.createCampaign({
|
||||
projectId,
|
||||
status: CampaignStatus.SCHEDULED,
|
||||
scheduledFor: pastTime,
|
||||
});
|
||||
|
||||
// Processor should immediately pick this up
|
||||
const shouldRun = campaign.scheduledFor && campaign.scheduledFor <= timeControl.now();
|
||||
expect(shouldRun).toBe(true);
|
||||
|
||||
timeControl.restore();
|
||||
});
|
||||
|
||||
it('should handle campaigns scheduled far in the future', async () => {
|
||||
timeControl.freeze(new Date('2025-01-20T10:00:00Z'));
|
||||
|
||||
// Schedule campaign 30 days in the future
|
||||
const futureTime = timeControl.helpers.relative(30, 'day');
|
||||
const campaign = await factories.createScheduledCampaign(projectId, futureTime);
|
||||
|
||||
expect(campaign.status).toBe(CampaignStatus.SCHEDULED);
|
||||
|
||||
// Campaign should not be processable yet
|
||||
const shouldNotRun = campaign.scheduledFor && campaign.scheduledFor > timeControl.now();
|
||||
expect(shouldNotRun).toBe(true);
|
||||
|
||||
// Advance time by 29 days - still not ready
|
||||
timeControl.helpers.advanceDays(29);
|
||||
expect(campaign.scheduledFor! > timeControl.now()).toBe(true);
|
||||
|
||||
// Advance to exactly the scheduled time
|
||||
timeControl.advanceTo(futureTime);
|
||||
expect(timeControl.now()).toEqual(futureTime);
|
||||
|
||||
timeControl.restore();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
import type {Job} from 'bullmq';
|
||||
import {Worker} from 'bullmq';
|
||||
import type {RedisOptions} from 'ioredis';
|
||||
import signale from 'signale';
|
||||
|
||||
import {REDIS_URL} from '../app/constants.js';
|
||||
import {prisma} from '../database/prisma.js';
|
||||
import type {ApiRequestCleanupJobData} from '../services/QueueService.js';
|
||||
|
||||
/**
|
||||
* API Request Cleanup Worker
|
||||
* Deletes old API request logs to prevent unbounded table growth
|
||||
* Runs daily to clean up logs older than 30 days (configurable)
|
||||
*/
|
||||
|
||||
const RETENTION_DAYS = 30; // Keep logs for 30 days
|
||||
const BATCH_SIZE = 10000; // Delete in batches for performance
|
||||
|
||||
/**
|
||||
* Process API request cleanup job
|
||||
*/
|
||||
async function processCleanup(job: Job<ApiRequestCleanupJobData>): Promise<{deleted: number}> {
|
||||
signale.info('[API-REQUEST-CLEANUP] Starting cleanup of old API request logs...');
|
||||
|
||||
const cutoffDate = new Date();
|
||||
cutoffDate.setDate(cutoffDate.getDate() - RETENTION_DAYS);
|
||||
|
||||
let totalDeleted = 0;
|
||||
let hasMore = true;
|
||||
|
||||
try {
|
||||
// Delete in batches to avoid locking the table for too long
|
||||
while (hasMore) {
|
||||
const result = await prisma.apiRequest.deleteMany({
|
||||
where: {
|
||||
createdAt: {
|
||||
lt: cutoffDate,
|
||||
},
|
||||
},
|
||||
// Note: Prisma doesn't support LIMIT in deleteMany, so we use a different approach
|
||||
});
|
||||
|
||||
totalDeleted += result.count;
|
||||
|
||||
// If we deleted fewer than batch size, we're done
|
||||
hasMore = result.count >= BATCH_SIZE;
|
||||
|
||||
if (hasMore) {
|
||||
signale.info(`[API-REQUEST-CLEANUP] Deleted ${totalDeleted} records so far, continuing...`);
|
||||
// Small delay between batches to reduce database load
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
}
|
||||
}
|
||||
|
||||
signale.success(
|
||||
`[API-REQUEST-CLEANUP] Cleanup complete. Deleted ${totalDeleted} records older than ${RETENTION_DAYS} days (before ${cutoffDate.toISOString()})`,
|
||||
);
|
||||
|
||||
// Update job progress
|
||||
await job.updateProgress(100);
|
||||
|
||||
return {deleted: totalDeleted};
|
||||
} catch (error) {
|
||||
signale.error('[API-REQUEST-CLEANUP] Error during cleanup:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the API request cleanup worker
|
||||
*/
|
||||
export function createApiRequestCleanupWorker(): Worker<ApiRequestCleanupJobData> {
|
||||
const redisConnection: RedisOptions = {
|
||||
maxRetriesPerRequest: null,
|
||||
enableReadyCheck: false,
|
||||
...parseRedisUrl(REDIS_URL),
|
||||
};
|
||||
|
||||
const worker = new Worker<ApiRequestCleanupJobData>('api-request-cleanup', processCleanup, {
|
||||
connection: redisConnection,
|
||||
concurrency: 1, // Only run one cleanup job at a time
|
||||
});
|
||||
|
||||
worker.on('completed', job => {
|
||||
signale.success(`[API-REQUEST-CLEANUP] Job ${job.id} completed:`, job.returnvalue);
|
||||
});
|
||||
|
||||
worker.on('failed', (job, err) => {
|
||||
signale.error(`[API-REQUEST-CLEANUP] Job ${job?.id} failed:`, err);
|
||||
});
|
||||
|
||||
worker.on('error', err => {
|
||||
signale.error('[API-REQUEST-CLEANUP] Worker error:', err);
|
||||
});
|
||||
|
||||
return worker;
|
||||
}
|
||||
|
||||
function parseRedisUrl(url: string): {host: string; port: number; password?: string; db?: number} {
|
||||
const urlObj = new URL(url);
|
||||
return {
|
||||
host: urlObj.hostname,
|
||||
port: parseInt(urlObj.port || '6379', 10),
|
||||
password: urlObj.password || undefined,
|
||||
db: parseInt(urlObj.pathname.slice(1) || '0', 10),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Background Job: Campaign Processor
|
||||
* Processes campaign batches (queues emails for each contact in the batch)
|
||||
*/
|
||||
|
||||
import {type Job, Worker} from 'bullmq';
|
||||
|
||||
import {CampaignService} from '../services/CampaignService.js';
|
||||
import {type CampaignBatchJobData, campaignQueue} from '../services/QueueService.js';
|
||||
|
||||
export function createCampaignWorker() {
|
||||
const worker = new Worker<CampaignBatchJobData>(
|
||||
campaignQueue.name,
|
||||
async (job: Job<CampaignBatchJobData>) => {
|
||||
const {campaignId, batchNumber, offset, limit, cursor} = job.data;
|
||||
|
||||
console.log(`[CAMPAIGN-PROCESSOR] Processing batch ${batchNumber} for campaign ${campaignId}`);
|
||||
|
||||
await CampaignService.processBatch(campaignId, batchNumber, offset, limit, cursor);
|
||||
|
||||
console.log(`[CAMPAIGN-PROCESSOR] Completed batch ${batchNumber} for campaign ${campaignId}`);
|
||||
},
|
||||
{
|
||||
connection: campaignQueue.opts.connection,
|
||||
concurrency: 5, // Process up to 5 batches concurrently
|
||||
},
|
||||
);
|
||||
|
||||
worker.on('completed', job => {
|
||||
console.log(`[CAMPAIGN-PROCESSOR] Job ${job.id} completed`);
|
||||
});
|
||||
|
||||
worker.on('failed', (job, err) => {
|
||||
console.error(`[CAMPAIGN-PROCESSOR] Job ${job?.id} failed:`, err.message);
|
||||
});
|
||||
|
||||
worker.on('error', err => {
|
||||
console.error('[CAMPAIGN-PROCESSOR] Worker error:', err);
|
||||
});
|
||||
|
||||
return worker;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Domain Verification Worker
|
||||
* Processes domain verification jobs from the BullMQ queue
|
||||
*/
|
||||
|
||||
import {type Job, Worker} from 'bullmq';
|
||||
import signale from 'signale';
|
||||
|
||||
import {type DomainVerificationJobData, domainVerificationQueue} from '../services/QueueService.js';
|
||||
|
||||
import {checkDomainVerifications} from './domain-verification.js';
|
||||
|
||||
/**
|
||||
* Process domain verification job
|
||||
*/
|
||||
async function processDomainVerification(job: Job<DomainVerificationJobData>): Promise<void> {
|
||||
signale.info(`[DOMAIN-VERIFICATION-WORKER] Starting domain verification job ${job.id}`);
|
||||
|
||||
try {
|
||||
await checkDomainVerifications();
|
||||
signale.success(`[DOMAIN-VERIFICATION-WORKER] Completed domain verification job ${job.id}`);
|
||||
} catch (error) {
|
||||
signale.error(`[DOMAIN-VERIFICATION-WORKER] Error processing job ${job.id}:`, error);
|
||||
throw error; // Re-throw to trigger retry
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create and export the domain verification worker
|
||||
*/
|
||||
export function createDomainVerificationWorker(): Worker {
|
||||
const worker = new Worker<DomainVerificationJobData>(
|
||||
domainVerificationQueue.name,
|
||||
async (job: Job<DomainVerificationJobData>) => {
|
||||
await processDomainVerification(job);
|
||||
},
|
||||
{
|
||||
connection: domainVerificationQueue.opts.connection,
|
||||
concurrency: 1, // Process one domain verification job at a time
|
||||
limiter: {
|
||||
max: 1, // Max 1 job per duration
|
||||
duration: 60000, // Per minute (prevents rapid-fire verification checks)
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
worker.on('completed', job => {
|
||||
signale.success(`[DOMAIN-VERIFICATION-WORKER] Job ${job.id} completed`);
|
||||
});
|
||||
|
||||
worker.on('failed', (job, error) => {
|
||||
signale.error(`[DOMAIN-VERIFICATION-WORKER] Job ${job?.id} failed:`, error);
|
||||
});
|
||||
|
||||
worker.on('error', error => {
|
||||
signale.error('[DOMAIN-VERIFICATION-WORKER] Worker error:', error);
|
||||
});
|
||||
|
||||
return worker;
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* Background Job: Domain Verification Checker
|
||||
* Checks domain verification status with AWS SES
|
||||
*
|
||||
* This is processed by BullMQ workers (see domain-verification-processor.ts)
|
||||
* Scheduled to run every 5 minutes via repeatable jobs
|
||||
*/
|
||||
|
||||
import signale from 'signale';
|
||||
|
||||
import {prisma} from '../database/prisma.js';
|
||||
import {redis} from '../database/redis.js';
|
||||
import {disableFeedbackForwarding, getIdentities, verifyDomain} from '../services/SESService.js';
|
||||
import {Keys} from '../services/keys.js';
|
||||
|
||||
/**
|
||||
* Check verification status for all domains in the database
|
||||
*/
|
||||
export async function checkDomainVerifications() {
|
||||
signale.info('[DOMAIN-VERIFICATION] Starting domain verification check...');
|
||||
|
||||
try {
|
||||
const count = await prisma.domain.count();
|
||||
signale.info(`[DOMAIN-VERIFICATION] Found ${count} domains to check`);
|
||||
|
||||
// Process domains in batches of 99 (AWS SES limit is 100)
|
||||
for (let i = 0; i < count; i += 99) {
|
||||
const domains = await prisma.domain.findMany({
|
||||
select: {id: true, domain: true, projectId: true, verified: true},
|
||||
skip: i,
|
||||
take: 99,
|
||||
});
|
||||
|
||||
if (domains.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get verification status from AWS SES
|
||||
const sesIdentities = await getIdentities(domains.map(d => d.domain));
|
||||
|
||||
// Update each domain based on SES status
|
||||
for (const sesIdentity of sesIdentities) {
|
||||
const dbDomain = domains.find(d => d.domain === sesIdentity.domain);
|
||||
|
||||
if (!dbDomain) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const isVerified = sesIdentity.status === 'Success';
|
||||
|
||||
// If domain failed verification, retry
|
||||
if (sesIdentity.status === 'Failed') {
|
||||
signale.warn(`[DOMAIN-VERIFICATION] Restarting verification for ${sesIdentity.domain}`);
|
||||
|
||||
let attempt = 0;
|
||||
const maxAttempts = 5;
|
||||
let success = false;
|
||||
let delay = 5000;
|
||||
|
||||
while (attempt < maxAttempts && !success) {
|
||||
try {
|
||||
await verifyDomain(sesIdentity.domain);
|
||||
success = true;
|
||||
signale.success(`[DOMAIN-VERIFICATION] Restarted verification for ${sesIdentity.domain}`);
|
||||
} catch (e: unknown) {
|
||||
const error = e as {Code?: string; name?: string; message?: string};
|
||||
if (error?.Code === 'Throttling' || error?.name === 'Throttling' || error?.message?.includes('Throttling')) {
|
||||
signale.warn(
|
||||
`[DOMAIN-VERIFICATION] Throttling detected, waiting ${delay / 1000} seconds (attempt ${attempt + 1})`,
|
||||
);
|
||||
await new Promise(r => setTimeout(r, delay));
|
||||
delay *= 2; // Exponential backoff
|
||||
attempt++;
|
||||
} else {
|
||||
signale.error(`[DOMAIN-VERIFICATION] Error restarting verification: ${error?.message || 'Unknown error'}`);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!success) {
|
||||
signale.error(
|
||||
`[DOMAIN-VERIFICATION] Failed to verify ${sesIdentity.domain} after ${maxAttempts} attempts due to throttling`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Update verification status in database
|
||||
await prisma.domain.update({
|
||||
where: {id: dbDomain.id},
|
||||
data: {verified: isVerified},
|
||||
});
|
||||
|
||||
// If domain was just verified, disable feedback forwarding
|
||||
if (!dbDomain.verified && isVerified) {
|
||||
signale.success(`[DOMAIN-VERIFICATION] Domain ${sesIdentity.domain} is now verified!`);
|
||||
|
||||
try {
|
||||
await disableFeedbackForwarding(sesIdentity.domain);
|
||||
signale.info(`[DOMAIN-VERIFICATION] Disabled feedback forwarding for ${sesIdentity.domain}`);
|
||||
} catch (error) {
|
||||
signale.error(`[DOMAIN-VERIFICATION] Error disabling feedback forwarding: ${error}`);
|
||||
}
|
||||
|
||||
// Invalidate cache
|
||||
await redis.del(Keys.Domain.id(dbDomain.id));
|
||||
await redis.del(Keys.Domain.project(dbDomain.projectId));
|
||||
}
|
||||
|
||||
// If domain was unverified, invalidate cache
|
||||
if (dbDomain.verified && !isVerified) {
|
||||
signale.warn(`[DOMAIN-VERIFICATION] Domain ${sesIdentity.domain} is no longer verified`);
|
||||
|
||||
await redis.del(Keys.Domain.id(dbDomain.id));
|
||||
await redis.del(Keys.Domain.project(dbDomain.projectId));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
signale.success('[DOMAIN-VERIFICATION] Domain verification check completed');
|
||||
} catch (error) {
|
||||
signale.error('[DOMAIN-VERIFICATION] Error checking domain verifications:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Main processor function
|
||||
* Call this from your scheduler/cron
|
||||
*/
|
||||
export async function runDomainVerificationJob() {
|
||||
await checkDomainVerifications();
|
||||
}
|
||||
|
||||
// If running this file directly (for testing or manual execution)
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
signale.info('[DOMAIN-VERIFICATION] Running domain verification job manually...');
|
||||
runDomainVerificationJob()
|
||||
.then(() => {
|
||||
signale.success('[DOMAIN-VERIFICATION] Completed successfully');
|
||||
process.exit(0);
|
||||
})
|
||||
.catch(error => {
|
||||
signale.error('[DOMAIN-VERIFICATION] Fatal error:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* Background Job: Email Processor
|
||||
* Processes individual emails from the queue (for all sources: transactional, campaign, workflow)
|
||||
*/
|
||||
|
||||
import type {Prisma} from '@plunk/db';
|
||||
import {EmailSourceType, EmailStatus} from '@plunk/db';
|
||||
import {type Job, Worker} from 'bullmq';
|
||||
|
||||
import {prisma} from '../database/prisma.js';
|
||||
import {EmailService} from '../services/EmailService.js';
|
||||
import {MeterService} from '../services/MeterService.js';
|
||||
import {emailQueue, type SendEmailJobData} from '../services/QueueService.js';
|
||||
import {sendRawEmail} from '../services/SESService.js';
|
||||
|
||||
export function createEmailWorker() {
|
||||
const worker = new Worker<SendEmailJobData>(
|
||||
emailQueue.name,
|
||||
async (job: Job<SendEmailJobData>) => {
|
||||
const {emailId} = job.data;
|
||||
|
||||
const email = await prisma.email.findUnique({
|
||||
where: {id: emailId},
|
||||
include: {
|
||||
contact: true,
|
||||
project: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!email) {
|
||||
throw new Error(`Email ${emailId} not found`);
|
||||
}
|
||||
|
||||
if (email.status !== EmailStatus.PENDING) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if project is disabled
|
||||
if (email.project.disabled) {
|
||||
console.warn(`[EMAIL-PROCESSOR] Project ${email.projectId} is disabled, cancelling email ${emailId}`);
|
||||
await prisma.email.update({
|
||||
where: {id: emailId},
|
||||
data: {
|
||||
status: EmailStatus.FAILED,
|
||||
error: 'Project is disabled',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Update status to sending
|
||||
await prisma.email.update({
|
||||
where: {id: emailId},
|
||||
data: {status: EmailStatus.SENDING},
|
||||
});
|
||||
|
||||
// Format template variables in subject and body
|
||||
const contactData = (email.contact.data as Record<string, unknown>) || {};
|
||||
const formattedEmail = EmailService.format({
|
||||
subject: email.subject,
|
||||
body: email.body,
|
||||
data: {
|
||||
email: email.contact.email,
|
||||
...contactData,
|
||||
},
|
||||
});
|
||||
|
||||
// Compile HTML with unsubscribe footer and badge
|
||||
const compiledHtml = EmailService.compile({
|
||||
content: formattedEmail.body,
|
||||
contact: email.contact,
|
||||
project: email.project,
|
||||
includeUnsubscribe: email.sourceType !== EmailSourceType.TRANSACTIONAL, // Don't add unsubscribe to transactional emails
|
||||
});
|
||||
|
||||
// Parse from email (format: "Name <[email protected]>" or just "[email protected]")
|
||||
const fromMatch = /(.*?)<(.+?)>/.exec(email.from) || [null, email.from, email.from];
|
||||
const fromName = fromMatch[1]?.trim() || email.project.name;
|
||||
const fromEmail = fromMatch[2]?.trim() || email.from;
|
||||
|
||||
// Send via AWS SES
|
||||
const result = await sendRawEmail({
|
||||
from: {
|
||||
name: fromName,
|
||||
email: fromEmail,
|
||||
},
|
||||
to: [email.contact.email],
|
||||
content: {
|
||||
subject: formattedEmail.subject,
|
||||
html: compiledHtml,
|
||||
},
|
||||
reply: email.replyTo || undefined,
|
||||
tracking: email.project.trackingEnabled, // Use project's tracking preference
|
||||
});
|
||||
|
||||
// Mark as sent with SES message ID
|
||||
await prisma.email.update({
|
||||
where: {id: emailId},
|
||||
data: {
|
||||
status: EmailStatus.SENT,
|
||||
sentAt: new Date(),
|
||||
messageId: result.messageId,
|
||||
},
|
||||
});
|
||||
|
||||
// Record usage for billing (pay-per-email)
|
||||
// Uses email ID as idempotency key to prevent double-charging on retries
|
||||
// Charge 2 emails if attachments are present
|
||||
if (email.project.customer) {
|
||||
const hasAttachments = email.attachments && Array.isArray(email.attachments) && email.attachments.length > 0;
|
||||
const emailCount = hasAttachments ? 2 : 1;
|
||||
await MeterService.recordEmailSent(email.project.customer, emailCount, `email_${emailId}`);
|
||||
}
|
||||
|
||||
// Track event
|
||||
await prisma.event.create({
|
||||
data: {
|
||||
projectId: email.projectId,
|
||||
contactId: email.contactId,
|
||||
emailId: email.id,
|
||||
name: 'email.sent',
|
||||
data: {
|
||||
subject: formattedEmail.subject,
|
||||
from: email.from,
|
||||
messageId: result.messageId,
|
||||
} as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`[EMAIL-PROCESSOR] Failed to send email ${emailId}:`, error);
|
||||
|
||||
// Mark as failed
|
||||
await prisma.email.update({
|
||||
where: {id: emailId},
|
||||
data: {
|
||||
status: EmailStatus.FAILED,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
},
|
||||
});
|
||||
|
||||
throw error; // Re-throw to trigger retry
|
||||
}
|
||||
},
|
||||
{
|
||||
connection: emailQueue.opts.connection,
|
||||
concurrency: 10, // Process up to 10 emails concurrently
|
||||
limiter: {
|
||||
max: 14, // Max 14 emails per second (AWS SES limit is typically 14/sec)
|
||||
duration: 1000,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
worker.on('completed', job => {
|
||||
console.log(`[EMAIL-PROCESSOR] Job ${job.id} completed`);
|
||||
});
|
||||
|
||||
worker.on('failed', (job, err) => {
|
||||
console.error(`[EMAIL-PROCESSOR] Job ${job?.id} failed:`, err.message);
|
||||
});
|
||||
|
||||
worker.on('error', err => {
|
||||
console.error('[EMAIL-PROCESSOR] Worker error:', err);
|
||||
});
|
||||
|
||||
return worker;
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* Background Job: Contact Import Processor
|
||||
* Processes CSV contact imports with validation and batch processing
|
||||
*/
|
||||
|
||||
import {type Job, Worker} from 'bullmq';
|
||||
import {parse} from 'csv-parse/sync';
|
||||
|
||||
import {ContactService} from '../services/ContactService.js';
|
||||
import {type ContactImportJobData, importQueue} from '../services/QueueService.js';
|
||||
|
||||
const BATCH_SIZE = 100; // Process contacts in batches of 100
|
||||
|
||||
interface ImportResult {
|
||||
totalRows: number;
|
||||
successCount: number;
|
||||
createdCount: number;
|
||||
updatedCount: number;
|
||||
failureCount: number;
|
||||
errors: {row: number; email: string; error: string}[];
|
||||
}
|
||||
|
||||
export function createImportWorker() {
|
||||
const worker = new Worker<ContactImportJobData>(
|
||||
importQueue.name,
|
||||
async (job: Job<ContactImportJobData>) => {
|
||||
const {projectId, csvData, filename} = job.data;
|
||||
|
||||
console.log(`[IMPORT-PROCESSOR] Processing import for project ${projectId} (${filename})`);
|
||||
|
||||
const result: ImportResult = {
|
||||
totalRows: 0,
|
||||
successCount: 0,
|
||||
createdCount: 0,
|
||||
updatedCount: 0,
|
||||
failureCount: 0,
|
||||
errors: [],
|
||||
};
|
||||
|
||||
try {
|
||||
// Decode base64 CSV data
|
||||
const csvContent = Buffer.from(csvData, 'base64').toString('utf-8');
|
||||
|
||||
// Parse CSV
|
||||
const records = parse(csvContent, {
|
||||
columns: true, // Use first row as header
|
||||
skip_empty_lines: true,
|
||||
trim: true,
|
||||
relax_column_count: true, // Allow rows with different column counts
|
||||
}) as Record<string, string>[];
|
||||
|
||||
result.totalRows = records.length;
|
||||
|
||||
// Validate row count
|
||||
if (records.length === 0) {
|
||||
throw new Error('CSV file is empty');
|
||||
}
|
||||
|
||||
console.log(`[IMPORT-PROCESSOR] Parsed ${records.length} rows from CSV`);
|
||||
|
||||
// Validate that 'email' column exists
|
||||
const firstRecord = records[0];
|
||||
if (firstRecord && typeof firstRecord === 'object' && !('email' in firstRecord)) {
|
||||
throw new Error('CSV must have an "email" column');
|
||||
}
|
||||
|
||||
// Process contacts in batches
|
||||
for (let i = 0; i < records.length; i += BATCH_SIZE) {
|
||||
const batch = records.slice(i, Math.min(i + BATCH_SIZE, records.length));
|
||||
|
||||
// Process batch sequentially (to avoid overwhelming the database)
|
||||
for (const [batchIndex, record] of batch.entries()) {
|
||||
const rowNumber = i + batchIndex + 2; // +2 for header row and 1-based index
|
||||
|
||||
try {
|
||||
// Validate email
|
||||
const email = record.email?.trim();
|
||||
if (!email) {
|
||||
result.failureCount++;
|
||||
result.errors.push({
|
||||
row: rowNumber,
|
||||
email: '',
|
||||
error: 'Email is required',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Basic email validation
|
||||
if (!isValidEmail(email)) {
|
||||
result.failureCount++;
|
||||
result.errors.push({
|
||||
row: rowNumber,
|
||||
email,
|
||||
error: 'Invalid email format',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Extract custom data (all fields except email)
|
||||
const {email: _, ...customData} = record;
|
||||
const data = Object.keys(customData).length > 0 ? customData : undefined;
|
||||
|
||||
// Check if contact exists before upserting
|
||||
const existingContact = await ContactService.findByEmail(projectId, email);
|
||||
const isUpdate = !!existingContact;
|
||||
|
||||
// Upsert contact
|
||||
await ContactService.upsert(projectId, email, data, true);
|
||||
|
||||
result.successCount++;
|
||||
if (isUpdate) {
|
||||
result.updatedCount++;
|
||||
} else {
|
||||
result.createdCount++;
|
||||
}
|
||||
} catch (error) {
|
||||
result.failureCount++;
|
||||
result.errors.push({
|
||||
row: rowNumber,
|
||||
email: record.email || '',
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Update progress
|
||||
const progress = Math.round(((i + batch.length) / records.length) * 100);
|
||||
await job.updateProgress(progress);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[IMPORT-PROCESSOR] Import completed: ${result.createdCount} created, ${result.updatedCount} updated, ${result.failureCount} failed`,
|
||||
);
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error(`[IMPORT-PROCESSOR] Failed to process import:`, error);
|
||||
|
||||
// Return partial results with error
|
||||
result.errors.push({
|
||||
row: 0,
|
||||
email: '',
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
|
||||
throw error; // Re-throw to mark job as failed
|
||||
}
|
||||
},
|
||||
{
|
||||
connection: importQueue.opts.connection,
|
||||
concurrency: 2, // Process max 2 imports concurrently
|
||||
},
|
||||
);
|
||||
|
||||
worker.on('completed', job => {
|
||||
console.log(`[IMPORT-PROCESSOR] Job ${job.id} completed`);
|
||||
});
|
||||
|
||||
worker.on('failed', (job, err) => {
|
||||
console.error(`[IMPORT-PROCESSOR] Job ${job?.id} failed:`, err.message);
|
||||
});
|
||||
|
||||
worker.on('error', err => {
|
||||
console.error('[IMPORT-PROCESSOR] Worker error:', err);
|
||||
});
|
||||
|
||||
return worker;
|
||||
}
|
||||
|
||||
/**
|
||||
* Basic email validation
|
||||
*/
|
||||
function isValidEmail(email: string): boolean {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
return emailRegex.test(email);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Background Job: Scheduled Campaign Processor
|
||||
* Processes scheduled campaigns when their time arrives
|
||||
*/
|
||||
|
||||
import {CampaignStatus} from '@plunk/db';
|
||||
import {type Job, Worker} from 'bullmq';
|
||||
|
||||
import {prisma} from '../database/prisma.js';
|
||||
import {CampaignService} from '../services/CampaignService.js';
|
||||
import {type ScheduledCampaignJobData, scheduledQueue} from '../services/QueueService.js';
|
||||
|
||||
export function createScheduledCampaignWorker() {
|
||||
const worker = new Worker<ScheduledCampaignJobData>(
|
||||
scheduledQueue.name,
|
||||
async (job: Job<ScheduledCampaignJobData>) => {
|
||||
const {campaignId} = job.data;
|
||||
|
||||
console.log(`[SCHEDULED-PROCESSOR] Processing scheduled campaign ${campaignId}`);
|
||||
|
||||
// Get campaign with project
|
||||
const campaign = await prisma.campaign.findUnique({
|
||||
where: {id: campaignId},
|
||||
include: {
|
||||
project: {
|
||||
select: {disabled: true, id: true, name: true},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!campaign) {
|
||||
console.warn(`[SCHEDULED-PROCESSOR] Campaign ${campaignId} not found, skipping`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if project is disabled
|
||||
if (campaign.project.disabled) {
|
||||
console.warn(
|
||||
`[SCHEDULED-PROCESSOR] Project ${campaign.projectId} (${campaign.project.name}) is disabled, cancelling campaign ${campaignId}`,
|
||||
);
|
||||
await prisma.campaign.update({
|
||||
where: {id: campaignId},
|
||||
data: {status: CampaignStatus.CANCELLED},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify campaign is still in SCHEDULED status
|
||||
if (campaign.status !== CampaignStatus.SCHEDULED) {
|
||||
console.warn(
|
||||
`[SCHEDULED-PROCESSOR] Campaign ${campaignId} is not in SCHEDULED status (${campaign.status}), skipping`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Start sending the campaign
|
||||
await CampaignService.startSending(campaign.projectId, campaignId);
|
||||
|
||||
console.log(`[SCHEDULED-PROCESSOR] Started sending campaign ${campaignId}`);
|
||||
},
|
||||
{
|
||||
connection: scheduledQueue.opts.connection,
|
||||
concurrency: 2, // Process up to 2 scheduled campaigns concurrently
|
||||
},
|
||||
);
|
||||
|
||||
worker.on('completed', job => {
|
||||
console.log(`[SCHEDULED-PROCESSOR] Job ${job.id} completed`);
|
||||
});
|
||||
|
||||
worker.on('failed', (job, err) => {
|
||||
console.error(`[SCHEDULED-PROCESSOR] Job ${job?.id} failed:`, err.message);
|
||||
});
|
||||
|
||||
worker.on('error', err => {
|
||||
console.error('[SCHEDULED-PROCESSOR] Worker error:', err);
|
||||
});
|
||||
|
||||
return worker;
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* Segment Count Update Worker
|
||||
* Processes segment count update jobs from the BullMQ queue
|
||||
*/
|
||||
|
||||
import {type Job, Worker} from 'bullmq';
|
||||
import signale from 'signale';
|
||||
|
||||
import {prisma} from '../database/prisma.js';
|
||||
import {type SegmentCountJobData, segmentCountQueue} from '../services/QueueService.js';
|
||||
import {SegmentService} from '../services/SegmentService.js';
|
||||
|
||||
/**
|
||||
* Process segments for a single project
|
||||
* - For segments with trackMembership: compute full membership and trigger events
|
||||
* - For segments without trackMembership: only update counts
|
||||
*/
|
||||
async function processProjectSegments(projectId: string, projectName?: string): Promise<void> {
|
||||
const logPrefix = projectName ? `${projectName} (${projectId})` : projectId;
|
||||
|
||||
// Get all segments for this project, separating tracked vs non-tracked
|
||||
const segments = await prisma.segment.findMany({
|
||||
where: {projectId},
|
||||
select: {id: true, name: true, trackMembership: true},
|
||||
});
|
||||
|
||||
const trackedSegments = segments.filter(s => s.trackMembership);
|
||||
const nonTrackedSegments = segments.filter(s => !s.trackMembership);
|
||||
|
||||
signale.info(
|
||||
`[SEGMENT-COUNT-WORKER] Project ${logPrefix}: ${trackedSegments.length} tracked, ${nonTrackedSegments.length} non-tracked segments`,
|
||||
);
|
||||
|
||||
// Process tracked segments with full membership computation (creates events)
|
||||
if (trackedSegments.length > 0) {
|
||||
for (const segment of trackedSegments) {
|
||||
try {
|
||||
signale.info(
|
||||
`[SEGMENT-COUNT-WORKER] Computing membership for tracked segment "${segment.name}" (${segment.id})`,
|
||||
);
|
||||
const result = await SegmentService.computeMembership(projectId, segment.id);
|
||||
signale.success(
|
||||
`[SEGMENT-COUNT-WORKER] Segment "${segment.name}": +${result.added} entries, -${result.removed} exits, ${result.total} total members`,
|
||||
);
|
||||
} catch (error) {
|
||||
signale.error(`[SEGMENT-COUNT-WORKER] Failed to compute membership for segment ${segment.id}:`, error);
|
||||
// Continue with other segments
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process non-tracked segments with count-only update (lightweight)
|
||||
if (nonTrackedSegments.length > 0) {
|
||||
try {
|
||||
await SegmentService.refreshAllMemberCounts(projectId);
|
||||
signale.info(`[SEGMENT-COUNT-WORKER] Updated counts for ${nonTrackedSegments.length} non-tracked segments`);
|
||||
} catch (error) {
|
||||
signale.error(`[SEGMENT-COUNT-WORKER] Failed to update counts for non-tracked segments:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process segment count update job
|
||||
*/
|
||||
async function processSegmentCountUpdate(job: Job<SegmentCountJobData>): Promise<void> {
|
||||
const {projectId} = job.data;
|
||||
|
||||
signale.info(`[SEGMENT-COUNT-WORKER] Starting segment count update job ${job.id}`);
|
||||
|
||||
try {
|
||||
if (projectId) {
|
||||
// Process specific project
|
||||
signale.info(`[SEGMENT-COUNT-WORKER] Processing segments for project ${projectId}`);
|
||||
await processProjectSegments(projectId);
|
||||
signale.success(`[SEGMENT-COUNT-WORKER] Completed segments for project ${projectId}`);
|
||||
} else {
|
||||
// Process all active projects
|
||||
const projects = await prisma.project.findMany({
|
||||
where: {disabled: false},
|
||||
select: {id: true, name: true},
|
||||
});
|
||||
|
||||
signale.info(`[SEGMENT-COUNT-WORKER] Found ${projects.length} active projects`);
|
||||
|
||||
// Process projects in batches to avoid overwhelming the database
|
||||
const PROJECT_BATCH_SIZE = 10;
|
||||
for (let i = 0; i < projects.length; i += PROJECT_BATCH_SIZE) {
|
||||
const batch = projects.slice(i, i + PROJECT_BATCH_SIZE);
|
||||
|
||||
await Promise.all(
|
||||
batch.map(async project => {
|
||||
try {
|
||||
signale.info(`[SEGMENT-COUNT-WORKER] Processing project ${project.name} (${project.id})`);
|
||||
await processProjectSegments(project.id, project.name);
|
||||
signale.success(`[SEGMENT-COUNT-WORKER] Completed project ${project.name}`);
|
||||
} catch (error) {
|
||||
signale.error(`[SEGMENT-COUNT-WORKER] Failed to process project ${project.id}:`, error);
|
||||
// Don't throw - continue with other projects
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// Small delay between project batches to avoid overwhelming the database
|
||||
if (i + PROJECT_BATCH_SIZE < projects.length) {
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
}
|
||||
}
|
||||
|
||||
signale.success(`[SEGMENT-COUNT-WORKER] Completed all segment updates`);
|
||||
}
|
||||
} catch (error) {
|
||||
signale.error(`[SEGMENT-COUNT-WORKER] Error processing job ${job.id}:`, error);
|
||||
throw error; // Re-throw to trigger retry
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create and export the segment count worker
|
||||
*/
|
||||
export function createSegmentCountWorker(): Worker {
|
||||
const worker = new Worker<SegmentCountJobData>(
|
||||
segmentCountQueue.name,
|
||||
async (job: Job<SegmentCountJobData>) => {
|
||||
await processSegmentCountUpdate(job);
|
||||
},
|
||||
{
|
||||
connection: segmentCountQueue.opts.connection,
|
||||
concurrency: 1, // Process one segment count job at a time to avoid database overload
|
||||
limiter: {
|
||||
max: 1, // Max 1 job per duration
|
||||
duration: 60000, // Per minute (prevents rapid-fire updates)
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
worker.on('completed', job => {
|
||||
signale.success(`[SEGMENT-COUNT-WORKER] Job ${job.id} completed`);
|
||||
});
|
||||
|
||||
worker.on('failed', (job, error) => {
|
||||
signale.error(`[SEGMENT-COUNT-WORKER] Job ${job?.id} failed:`, error);
|
||||
});
|
||||
|
||||
worker.on('error', error => {
|
||||
signale.error('[SEGMENT-COUNT-WORKER] Worker error:', error);
|
||||
});
|
||||
|
||||
return worker;
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* Unified Queue Worker
|
||||
* Starts all queue processors (email, campaign, scheduled, workflow, import, segment-count, domain-verification)
|
||||
*
|
||||
* This should be run as a separate process in production:
|
||||
* node dist/jobs/worker.js
|
||||
*/
|
||||
|
||||
import {Worker} from 'bullmq';
|
||||
import signale from 'signale';
|
||||
|
||||
import {createApiRequestCleanupWorker} from './api-request-cleanup-processor.js';
|
||||
import {createCampaignWorker} from './campaign-processor.js';
|
||||
import {createDomainVerificationWorker} from './domain-verification-processor.js';
|
||||
import {createEmailWorker} from './email-processor.js';
|
||||
import {createImportWorker} from './import-processor.js';
|
||||
import {createScheduledCampaignWorker} from './scheduled-processor.js';
|
||||
import {createSegmentCountWorker} from './segment-count-processor.js';
|
||||
import {createWorkflowWorker} from './workflow-processor-queue.js';
|
||||
|
||||
const workers: {name: string; worker: Worker}[] = [];
|
||||
|
||||
async function startWorkers() {
|
||||
signale.info('[WORKER] Starting queue workers...');
|
||||
|
||||
try {
|
||||
// Start email worker
|
||||
const emailWorker = createEmailWorker();
|
||||
workers.push({name: 'email', worker: emailWorker});
|
||||
signale.success('[WORKER] Email worker started');
|
||||
|
||||
// Start campaign worker
|
||||
const campaignWorker = createCampaignWorker();
|
||||
workers.push({name: 'campaign', worker: campaignWorker});
|
||||
signale.success('[WORKER] Campaign worker started');
|
||||
|
||||
// Start scheduled campaign worker
|
||||
const scheduledWorker = createScheduledCampaignWorker();
|
||||
workers.push({name: 'scheduled', worker: scheduledWorker});
|
||||
signale.success('[WORKER] Scheduled campaign worker started');
|
||||
|
||||
// Start workflow worker
|
||||
const workflowWorker = createWorkflowWorker();
|
||||
workers.push({name: 'workflow', worker: workflowWorker});
|
||||
signale.success('[WORKER] Workflow worker started');
|
||||
|
||||
// Start import worker
|
||||
const importWorker = createImportWorker();
|
||||
workers.push({name: 'import', worker: importWorker});
|
||||
signale.success('[WORKER] Import worker started');
|
||||
|
||||
// Start segment count worker
|
||||
const segmentCountWorker = createSegmentCountWorker();
|
||||
workers.push({name: 'segment-count', worker: segmentCountWorker});
|
||||
signale.success('[WORKER] Segment count worker started');
|
||||
|
||||
// Start domain verification worker
|
||||
const domainVerificationWorker = createDomainVerificationWorker();
|
||||
workers.push({name: 'domain-verification', worker: domainVerificationWorker});
|
||||
signale.success('[WORKER] Domain verification worker started');
|
||||
|
||||
// Start API request cleanup worker
|
||||
const apiRequestCleanupWorker = createApiRequestCleanupWorker();
|
||||
workers.push({name: 'api-request-cleanup', worker: apiRequestCleanupWorker});
|
||||
signale.success('[WORKER] API request cleanup worker started');
|
||||
|
||||
signale.success('[WORKER] All workers started successfully');
|
||||
} catch (error) {
|
||||
signale.error('[WORKER] Failed to start workers:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
async function stopWorkers() {
|
||||
signale.info('[WORKER] Stopping workers...');
|
||||
|
||||
for (const {name, worker} of workers) {
|
||||
try {
|
||||
await worker.close();
|
||||
signale.info(`[WORKER] ${name} worker stopped`);
|
||||
} catch (error) {
|
||||
signale.error(`[WORKER] Error stopping ${name} worker:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
signale.success('[WORKER] All workers stopped');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Handle graceful shutdown
|
||||
process.on('SIGINT', () => {
|
||||
signale.info('[WORKER] Received SIGINT, shutting down gracefully...');
|
||||
void stopWorkers();
|
||||
});
|
||||
|
||||
process.on('SIGTERM', () => {
|
||||
signale.info('[WORKER] Received SIGTERM, shutting down gracefully...');
|
||||
void stopWorkers();
|
||||
});
|
||||
|
||||
process.on('uncaughtException', error => {
|
||||
signale.error('[WORKER] Uncaught exception:', error);
|
||||
void stopWorkers();
|
||||
});
|
||||
|
||||
process.on('unhandledRejection', (reason, promise) => {
|
||||
signale.error('[WORKER] Unhandled rejection at:', promise, 'reason:', reason);
|
||||
void stopWorkers();
|
||||
});
|
||||
|
||||
// Start workers
|
||||
void startWorkers();
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Background Job: Workflow Queue Processor
|
||||
* Processes workflow steps from the queue (for delayed steps)
|
||||
*/
|
||||
|
||||
import {type Job, Worker} from 'bullmq';
|
||||
|
||||
import {workflowQueue, type WorkflowStepJobData} from '../services/QueueService.js';
|
||||
import {WorkflowExecutionService} from '../services/WorkflowExecutionService.js';
|
||||
|
||||
export function createWorkflowWorker() {
|
||||
const worker = new Worker<WorkflowStepJobData>(
|
||||
workflowQueue.name,
|
||||
async (job: Job<WorkflowStepJobData>) => {
|
||||
const {executionId, stepId, type, stepExecutionId} = job.data;
|
||||
|
||||
if (type === 'timeout') {
|
||||
// Handle timeout for WAIT_FOR_EVENT steps
|
||||
if (!stepExecutionId) {
|
||||
throw new Error('stepExecutionId is required for timeout jobs');
|
||||
}
|
||||
|
||||
await WorkflowExecutionService.processTimeout(executionId, stepId, stepExecutionId);
|
||||
} else {
|
||||
// Handle regular step execution
|
||||
await WorkflowExecutionService.processStepExecution(executionId, stepId);
|
||||
}
|
||||
},
|
||||
{
|
||||
connection: workflowQueue.opts.connection,
|
||||
concurrency: 10, // Process up to 10 workflow steps concurrently
|
||||
},
|
||||
);
|
||||
|
||||
worker.on('completed', job => {
|
||||
console.log(`[WORKFLOW-PROCESSOR] Job ${job.id} completed`);
|
||||
});
|
||||
|
||||
worker.on('failed', (job, err) => {
|
||||
console.error(`[WORKFLOW-PROCESSOR] Job ${job?.id} failed:`, err.message);
|
||||
});
|
||||
|
||||
worker.on('error', err => {
|
||||
console.error('[WORKFLOW-PROCESSOR] Worker error:', err);
|
||||
});
|
||||
|
||||
return worker;
|
||||
}
|
||||
Reference in New Issue
Block a user