feat: Add bulk actions to contact overview
This commit is contained in:
@@ -379,4 +379,137 @@ export class Contacts {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /contacts/bulk-subscribe
|
||||||
|
* Queue bulk subscribe operation
|
||||||
|
*/
|
||||||
|
@Post('bulk-subscribe')
|
||||||
|
@Middleware([requireAuth])
|
||||||
|
@CatchAsync
|
||||||
|
public async bulkSubscribe(req: Request, res: Response, _next: NextFunction) {
|
||||||
|
const auth = res.locals.auth as AuthResponse;
|
||||||
|
const {contactIds} = req.body;
|
||||||
|
|
||||||
|
if (!Array.isArray(contactIds) || contactIds.length === 0) {
|
||||||
|
return res.status(400).json({error: 'contactIds array is required'});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate limit
|
||||||
|
if (contactIds.length > 1000) {
|
||||||
|
return res.status(400).json({error: 'Maximum 1000 contacts can be processed at once'});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const job = await QueueService.queueBulkContactAction(auth.projectId!, contactIds, 'subscribe');
|
||||||
|
|
||||||
|
return res.status(202).json({
|
||||||
|
message: 'Bulk subscribe queued successfully',
|
||||||
|
jobId: job.id,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
signale.error('[CONTACTS] Failed to queue bulk subscribe:', error);
|
||||||
|
return res.status(500).json({
|
||||||
|
error: error instanceof Error ? error.message : 'Failed to queue bulk subscribe',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /contacts/bulk-unsubscribe
|
||||||
|
* Queue bulk unsubscribe operation
|
||||||
|
*/
|
||||||
|
@Post('bulk-unsubscribe')
|
||||||
|
@Middleware([requireAuth])
|
||||||
|
@CatchAsync
|
||||||
|
public async bulkUnsubscribe(req: Request, res: Response, _next: NextFunction) {
|
||||||
|
const auth = res.locals.auth as AuthResponse;
|
||||||
|
const {contactIds} = req.body;
|
||||||
|
|
||||||
|
if (!Array.isArray(contactIds) || contactIds.length === 0) {
|
||||||
|
return res.status(400).json({error: 'contactIds array is required'});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (contactIds.length > 1000) {
|
||||||
|
return res.status(400).json({error: 'Maximum 1000 contacts can be processed at once'});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const job = await QueueService.queueBulkContactAction(auth.projectId!, contactIds, 'unsubscribe');
|
||||||
|
|
||||||
|
return res.status(202).json({
|
||||||
|
message: 'Bulk unsubscribe queued successfully',
|
||||||
|
jobId: job.id,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
signale.error('[CONTACTS] Failed to queue bulk unsubscribe:', error);
|
||||||
|
return res.status(500).json({
|
||||||
|
error: error instanceof Error ? error.message : 'Failed to queue bulk unsubscribe',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /contacts/bulk-delete
|
||||||
|
* Queue bulk delete operation
|
||||||
|
*/
|
||||||
|
@Post('bulk-delete')
|
||||||
|
@Middleware([requireAuth])
|
||||||
|
@CatchAsync
|
||||||
|
public async bulkDelete(req: Request, res: Response, _next: NextFunction) {
|
||||||
|
const auth = res.locals.auth as AuthResponse;
|
||||||
|
const {contactIds} = req.body;
|
||||||
|
|
||||||
|
if (!Array.isArray(contactIds) || contactIds.length === 0) {
|
||||||
|
return res.status(400).json({error: 'contactIds array is required'});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (contactIds.length > 1000) {
|
||||||
|
return res.status(400).json({error: 'Maximum 1000 contacts can be processed at once'});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const job = await QueueService.queueBulkContactAction(auth.projectId!, contactIds, 'delete');
|
||||||
|
|
||||||
|
return res.status(202).json({
|
||||||
|
message: 'Bulk delete queued successfully',
|
||||||
|
jobId: job.id,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
signale.error('[CONTACTS] Failed to queue bulk delete:', error);
|
||||||
|
return res.status(500).json({
|
||||||
|
error: error instanceof Error ? error.message : 'Failed to queue bulk delete',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /contacts/bulk/:jobId
|
||||||
|
* Get bulk action job status
|
||||||
|
*/
|
||||||
|
@Get('bulk/:jobId')
|
||||||
|
@Middleware([requireAuth])
|
||||||
|
@CatchAsync
|
||||||
|
public async getBulkActionStatus(req: Request, res: Response, _next: NextFunction) {
|
||||||
|
const jobId = req.params.jobId;
|
||||||
|
|
||||||
|
if (!jobId) {
|
||||||
|
return res.status(400).json({error: 'Job ID is required'});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const status = await QueueService.getBulkActionJobStatus(jobId);
|
||||||
|
|
||||||
|
if (!status) {
|
||||||
|
return res.status(404).json({error: 'Bulk action job not found'});
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.status(200).json(status);
|
||||||
|
} catch (error) {
|
||||||
|
signale.error('[CONTACTS] Failed to get bulk action status:', error);
|
||||||
|
return res.status(500).json({
|
||||||
|
error: error instanceof Error ? error.message : 'Failed to get bulk action status',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
/**
|
||||||
|
* Background Job: Bulk Contact Action Processor
|
||||||
|
* Processes bulk subscribe, unsubscribe, and delete operations
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {type Job, Worker} from 'bullmq';
|
||||||
|
import signale from 'signale';
|
||||||
|
|
||||||
|
import {ContactService} from '../services/ContactService.js';
|
||||||
|
import {type BulkContactActionJobData, bulkContactQueue} from '../services/QueueService.js';
|
||||||
|
|
||||||
|
const BATCH_SIZE = 100; // Process contacts in batches of 100
|
||||||
|
|
||||||
|
interface BulkActionResult {
|
||||||
|
operation: 'subscribe' | 'unsubscribe' | 'delete';
|
||||||
|
totalRequested: number;
|
||||||
|
successCount: number;
|
||||||
|
failureCount: number;
|
||||||
|
errors: {contactId: string; email: string; error: string}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createBulkContactWorker() {
|
||||||
|
const worker = new Worker<BulkContactActionJobData>(
|
||||||
|
bulkContactQueue.name,
|
||||||
|
async (job: Job<BulkContactActionJobData>) => {
|
||||||
|
const {projectId, contactIds, operation} = job.data;
|
||||||
|
|
||||||
|
signale.info(
|
||||||
|
`[BULK-CONTACT-PROCESSOR] Processing ${operation} for ${contactIds.length} contacts in project ${projectId}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const result: BulkActionResult = {
|
||||||
|
operation,
|
||||||
|
totalRequested: contactIds.length,
|
||||||
|
successCount: 0,
|
||||||
|
failureCount: 0,
|
||||||
|
errors: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Process contacts in batches
|
||||||
|
for (let i = 0; i < contactIds.length; i += BATCH_SIZE) {
|
||||||
|
const batchIds = contactIds.slice(i, Math.min(i + BATCH_SIZE, contactIds.length));
|
||||||
|
|
||||||
|
try {
|
||||||
|
let batchResult: {updated?: number; deleted?: number};
|
||||||
|
|
||||||
|
switch (operation) {
|
||||||
|
case 'subscribe':
|
||||||
|
batchResult = await ContactService.bulkSubscribe(projectId, batchIds);
|
||||||
|
result.successCount += batchResult.updated || 0;
|
||||||
|
break;
|
||||||
|
case 'unsubscribe':
|
||||||
|
batchResult = await ContactService.bulkUnsubscribe(projectId, batchIds);
|
||||||
|
result.successCount += batchResult.updated || 0;
|
||||||
|
break;
|
||||||
|
case 'delete':
|
||||||
|
batchResult = await ContactService.bulkDelete(projectId, batchIds);
|
||||||
|
result.successCount += batchResult.deleted || 0;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If some contacts in batch weren't processed, track them as failures
|
||||||
|
const processedCount = batchResult.updated || batchResult.deleted || 0;
|
||||||
|
const failedCount = batchIds.length - processedCount;
|
||||||
|
if (failedCount > 0) {
|
||||||
|
result.failureCount += failedCount;
|
||||||
|
// Note: We don't have individual contact details for batch failures
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
signale.error(`[BULK-CONTACT-PROCESSOR] Batch failed:`, error);
|
||||||
|
result.failureCount += batchIds.length;
|
||||||
|
result.errors.push({
|
||||||
|
contactId: 'batch',
|
||||||
|
email: '',
|
||||||
|
error: error instanceof Error ? error.message : 'Batch processing failed',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update progress
|
||||||
|
const progress = Math.round(((i + batchIds.length) / contactIds.length) * 100);
|
||||||
|
await job.updateProgress(progress);
|
||||||
|
}
|
||||||
|
|
||||||
|
signale.info(
|
||||||
|
`[BULK-CONTACT-PROCESSOR] ${operation} completed: ${result.successCount} succeeded, ${result.failureCount} failed`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
signale.error(`[BULK-CONTACT-PROCESSOR] Failed to process ${operation}:`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
connection: bulkContactQueue.opts.connection,
|
||||||
|
concurrency: 3, // Process max 3 bulk operations concurrently
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
worker.on('completed', job => {
|
||||||
|
signale.info(`[BULK-CONTACT-PROCESSOR] Job ${job.id} completed`);
|
||||||
|
});
|
||||||
|
|
||||||
|
worker.on('failed', (job, err) => {
|
||||||
|
signale.error(`[BULK-CONTACT-PROCESSOR] Job ${job?.id} failed:`, err.message);
|
||||||
|
});
|
||||||
|
|
||||||
|
worker.on('error', err => {
|
||||||
|
signale.error('[BULK-CONTACT-PROCESSOR] Worker error:', err);
|
||||||
|
});
|
||||||
|
|
||||||
|
return worker;
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import {Worker} from 'bullmq';
|
|||||||
import signale from 'signale';
|
import signale from 'signale';
|
||||||
|
|
||||||
import {createApiRequestCleanupWorker} from './api-request-cleanup-processor.js';
|
import {createApiRequestCleanupWorker} from './api-request-cleanup-processor.js';
|
||||||
|
import {createBulkContactWorker} from './bulk-contact-processor.js';
|
||||||
import {createCampaignWorker} from './campaign-processor.js';
|
import {createCampaignWorker} from './campaign-processor.js';
|
||||||
import {createDomainVerificationWorker} from './domain-verification-processor.js';
|
import {createDomainVerificationWorker} from './domain-verification-processor.js';
|
||||||
import {createEmailWorker} from './email-processor.js';
|
import {createEmailWorker} from './email-processor.js';
|
||||||
@@ -49,6 +50,11 @@ async function startWorkers() {
|
|||||||
workers.push({name: 'import', worker: importWorker});
|
workers.push({name: 'import', worker: importWorker});
|
||||||
signale.success('[WORKER] Import worker started');
|
signale.success('[WORKER] Import worker started');
|
||||||
|
|
||||||
|
// Start bulk contact action worker
|
||||||
|
const bulkContactWorker = createBulkContactWorker();
|
||||||
|
workers.push({name: 'bulk-contact-actions', worker: bulkContactWorker});
|
||||||
|
signale.success('[WORKER] Bulk contact action worker started');
|
||||||
|
|
||||||
// Start segment count worker
|
// Start segment count worker
|
||||||
const segmentCountWorker = createSegmentCountWorker();
|
const segmentCountWorker = createSegmentCountWorker();
|
||||||
workers.push({name: 'segment-count', worker: segmentCountWorker});
|
workers.push({name: 'segment-count', worker: segmentCountWorker});
|
||||||
|
|||||||
@@ -679,4 +679,148 @@ export class ContactService {
|
|||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bulk subscribe contacts
|
||||||
|
* Updates multiple contacts to subscribed=true in batches
|
||||||
|
*/
|
||||||
|
public static async bulkSubscribe(
|
||||||
|
projectId: string,
|
||||||
|
contactIds: string[],
|
||||||
|
): Promise<{updated: number}> {
|
||||||
|
// Verify all contacts belong to this project
|
||||||
|
const contacts = await prisma.contact.findMany({
|
||||||
|
where: {
|
||||||
|
id: {in: contactIds},
|
||||||
|
projectId,
|
||||||
|
},
|
||||||
|
select: {id: true, subscribed: true},
|
||||||
|
});
|
||||||
|
|
||||||
|
const validIds = contacts.map(c => c.id);
|
||||||
|
|
||||||
|
if (validIds.length === 0) {
|
||||||
|
return {updated: 0};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only update contacts that are currently unsubscribed
|
||||||
|
const unsubscribedIds = contacts.filter(c => !c.subscribed).map(c => c.id);
|
||||||
|
|
||||||
|
if (unsubscribedIds.length === 0) {
|
||||||
|
return {updated: 0};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update in a single query for performance
|
||||||
|
const result = await prisma.contact.updateMany({
|
||||||
|
where: {
|
||||||
|
id: {in: unsubscribedIds},
|
||||||
|
projectId,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
subscribed: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Track events for changed contacts sequentially to avoid database deadlocks
|
||||||
|
// Process in background to avoid blocking the API response
|
||||||
|
this.trackEventsSequentially(projectId, 'contact.subscribed', unsubscribedIds).catch(error => {
|
||||||
|
// Silently ignore errors in tests due to cleanup race conditions
|
||||||
|
if (process.env.NODE_ENV !== 'test') {
|
||||||
|
console.error('[ContactService] Failed to track bulk subscribe events:', error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return {updated: result.count};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bulk unsubscribe contacts
|
||||||
|
*/
|
||||||
|
public static async bulkUnsubscribe(
|
||||||
|
projectId: string,
|
||||||
|
contactIds: string[],
|
||||||
|
): Promise<{updated: number}> {
|
||||||
|
const contacts = await prisma.contact.findMany({
|
||||||
|
where: {
|
||||||
|
id: {in: contactIds},
|
||||||
|
projectId,
|
||||||
|
},
|
||||||
|
select: {id: true, subscribed: true},
|
||||||
|
});
|
||||||
|
|
||||||
|
const validIds = contacts.map(c => c.id);
|
||||||
|
|
||||||
|
if (validIds.length === 0) {
|
||||||
|
return {updated: 0};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only update contacts that are currently subscribed
|
||||||
|
const subscribedIds = contacts.filter(c => c.subscribed).map(c => c.id);
|
||||||
|
|
||||||
|
if (subscribedIds.length === 0) {
|
||||||
|
return {updated: 0};
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await prisma.contact.updateMany({
|
||||||
|
where: {
|
||||||
|
id: {in: subscribedIds},
|
||||||
|
projectId,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
subscribed: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Track events for changed contacts sequentially to avoid database deadlocks
|
||||||
|
// Process in background to avoid blocking the API response
|
||||||
|
this.trackEventsSequentially(projectId, 'contact.unsubscribed', subscribedIds).catch(error => {
|
||||||
|
// Silently ignore errors in tests due to cleanup race conditions
|
||||||
|
if (process.env.NODE_ENV !== 'test') {
|
||||||
|
console.error('[ContactService] Failed to track bulk unsubscribe events:', error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return {updated: result.count};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bulk delete contacts
|
||||||
|
*/
|
||||||
|
public static async bulkDelete(
|
||||||
|
projectId: string,
|
||||||
|
contactIds: string[],
|
||||||
|
): Promise<{deleted: number}> {
|
||||||
|
const result = await prisma.contact.deleteMany({
|
||||||
|
where: {
|
||||||
|
id: {in: contactIds},
|
||||||
|
projectId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {deleted: result.count};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Track events sequentially to avoid database deadlocks
|
||||||
|
* Processes events one at a time with error handling
|
||||||
|
*
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
private static async trackEventsSequentially(
|
||||||
|
projectId: string,
|
||||||
|
eventName: string,
|
||||||
|
contactIds: string[],
|
||||||
|
): Promise<void> {
|
||||||
|
for (const contactId of contactIds) {
|
||||||
|
try {
|
||||||
|
await EventService.trackEvent(projectId, eventName, contactId);
|
||||||
|
} catch (error) {
|
||||||
|
// Log error but continue processing remaining events
|
||||||
|
// Suppress logging in test environments to reduce noise from cleanup race conditions
|
||||||
|
if (process.env.NODE_ENV !== 'test') {
|
||||||
|
console.error(`[ContactService] Failed to track event ${eventName} for contact ${contactId}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,6 +52,12 @@ export interface ApiRequestCleanupJobData {
|
|||||||
// Empty - cleans up old API request logs
|
// Empty - cleans up old API request logs
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface BulkContactActionJobData {
|
||||||
|
projectId: string;
|
||||||
|
contactIds: string[];
|
||||||
|
operation: 'subscribe' | 'unsubscribe' | 'delete';
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Queue Configuration
|
* Queue Configuration
|
||||||
*/
|
*/
|
||||||
@@ -181,6 +187,19 @@ export const apiRequestCleanupQueue = new Queue<ApiRequestCleanupJobData>('api-r
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const bulkContactQueue = new Queue<BulkContactActionJobData>('bulk-contact-actions', {
|
||||||
|
connection: redisConnection,
|
||||||
|
defaultJobOptions: {
|
||||||
|
attempts: 2, // Limited retries for bulk operations
|
||||||
|
backoff: {
|
||||||
|
type: 'exponential',
|
||||||
|
delay: 5000,
|
||||||
|
},
|
||||||
|
removeOnComplete: 50, // Keep last 50 completed bulk operations
|
||||||
|
removeOnFail: 100, // Keep last 100 failed bulk operations
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Queue Service - Centralized queue management
|
* Queue Service - Centralized queue management
|
||||||
*/
|
*/
|
||||||
@@ -328,6 +347,48 @@ export class QueueService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Queue bulk contact action job
|
||||||
|
*/
|
||||||
|
public static async queueBulkContactAction(
|
||||||
|
projectId: string,
|
||||||
|
contactIds: string[],
|
||||||
|
operation: 'subscribe' | 'unsubscribe' | 'delete',
|
||||||
|
): Promise<Job<BulkContactActionJobData>> {
|
||||||
|
return bulkContactQueue.add(
|
||||||
|
'bulk-contact-action',
|
||||||
|
{projectId, contactIds, operation},
|
||||||
|
{
|
||||||
|
jobId: `bulk-${operation}-${projectId}-${Date.now()}`,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get bulk action job status and progress
|
||||||
|
*/
|
||||||
|
public static async getBulkActionJobStatus(jobId: string) {
|
||||||
|
const job = await bulkContactQueue.getJob(jobId);
|
||||||
|
|
||||||
|
if (!job) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = await job.getState();
|
||||||
|
const progress = job.progress;
|
||||||
|
const returnValue = job.returnvalue;
|
||||||
|
const failedReason = job.failedReason;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: job.id,
|
||||||
|
state,
|
||||||
|
progress,
|
||||||
|
result: returnValue,
|
||||||
|
data: job.data,
|
||||||
|
failedReason,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Queue segment count update job
|
* Queue segment count update job
|
||||||
*/
|
*/
|
||||||
@@ -354,6 +415,7 @@ export class QueueService {
|
|||||||
segmentCountCounts,
|
segmentCountCounts,
|
||||||
domainVerificationCounts,
|
domainVerificationCounts,
|
||||||
apiRequestCleanupCounts,
|
apiRequestCleanupCounts,
|
||||||
|
bulkContactCounts,
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
emailQueue.getJobCounts('waiting', 'active', 'completed', 'failed', 'delayed'),
|
emailQueue.getJobCounts('waiting', 'active', 'completed', 'failed', 'delayed'),
|
||||||
campaignQueue.getJobCounts('waiting', 'active', 'completed', 'failed', 'delayed'),
|
campaignQueue.getJobCounts('waiting', 'active', 'completed', 'failed', 'delayed'),
|
||||||
@@ -363,6 +425,7 @@ export class QueueService {
|
|||||||
segmentCountQueue.getJobCounts('waiting', 'active', 'completed', 'failed', 'delayed'),
|
segmentCountQueue.getJobCounts('waiting', 'active', 'completed', 'failed', 'delayed'),
|
||||||
domainVerificationQueue.getJobCounts('waiting', 'active', 'completed', 'failed', 'delayed'),
|
domainVerificationQueue.getJobCounts('waiting', 'active', 'completed', 'failed', 'delayed'),
|
||||||
apiRequestCleanupQueue.getJobCounts('waiting', 'active', 'completed', 'failed', 'delayed'),
|
apiRequestCleanupQueue.getJobCounts('waiting', 'active', 'completed', 'failed', 'delayed'),
|
||||||
|
bulkContactQueue.getJobCounts('waiting', 'active', 'completed', 'failed', 'delayed'),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -374,6 +437,7 @@ export class QueueService {
|
|||||||
segmentCount: segmentCountCounts,
|
segmentCount: segmentCountCounts,
|
||||||
domainVerification: domainVerificationCounts,
|
domainVerification: domainVerificationCounts,
|
||||||
apiRequestCleanup: apiRequestCleanupCounts,
|
apiRequestCleanup: apiRequestCleanupCounts,
|
||||||
|
bulkContact: bulkContactCounts,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -390,6 +454,7 @@ export class QueueService {
|
|||||||
segmentCountQueue.pause(),
|
segmentCountQueue.pause(),
|
||||||
domainVerificationQueue.pause(),
|
domainVerificationQueue.pause(),
|
||||||
apiRequestCleanupQueue.pause(),
|
apiRequestCleanupQueue.pause(),
|
||||||
|
bulkContactQueue.pause(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -406,6 +471,7 @@ export class QueueService {
|
|||||||
segmentCountQueue.resume(),
|
segmentCountQueue.resume(),
|
||||||
domainVerificationQueue.resume(),
|
domainVerificationQueue.resume(),
|
||||||
apiRequestCleanupQueue.resume(),
|
apiRequestCleanupQueue.resume(),
|
||||||
|
bulkContactQueue.resume(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -430,6 +496,8 @@ export class QueueService {
|
|||||||
segmentCountQueue.clean(gracePeriod * 7, 50, 'failed'),
|
segmentCountQueue.clean(gracePeriod * 7, 50, 'failed'),
|
||||||
domainVerificationQueue.clean(gracePeriod, 10, 'completed'),
|
domainVerificationQueue.clean(gracePeriod, 10, 'completed'),
|
||||||
domainVerificationQueue.clean(gracePeriod * 7, 50, 'failed'),
|
domainVerificationQueue.clean(gracePeriod * 7, 50, 'failed'),
|
||||||
|
bulkContactQueue.clean(gracePeriod, 50, 'completed'),
|
||||||
|
bulkContactQueue.clean(gracePeriod * 7, 100, 'failed'),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -514,6 +582,7 @@ export class QueueService {
|
|||||||
segmentCountQueue.close(),
|
segmentCountQueue.close(),
|
||||||
domainVerificationQueue.close(),
|
domainVerificationQueue.close(),
|
||||||
apiRequestCleanupQueue.close(),
|
apiRequestCleanupQueue.close(),
|
||||||
|
bulkContactQueue.close(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -439,4 +439,271 @@ describe('ContactService - Duplicate Prevention & Data Merging', () => {
|
|||||||
expect(unsubscribed?.subscribed).toBe(false);
|
expect(unsubscribed?.subscribed).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Bulk Contact Operations', () => {
|
||||||
|
describe('bulkSubscribe', () => {
|
||||||
|
it('should subscribe multiple unsubscribed contacts', async () => {
|
||||||
|
const contact1 = await factories.createContact({projectId, subscribed: false});
|
||||||
|
const contact2 = await factories.createContact({projectId, subscribed: false});
|
||||||
|
const contact3 = await factories.createContact({projectId, subscribed: false});
|
||||||
|
|
||||||
|
const result = await ContactService.bulkSubscribe(projectId, [contact1.id, contact2.id, contact3.id]);
|
||||||
|
|
||||||
|
expect(result.updated).toBe(3);
|
||||||
|
|
||||||
|
const contacts = await prisma.contact.findMany({
|
||||||
|
where: {id: {in: [contact1.id, contact2.id, contact3.id]}},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(contacts.every(c => c.subscribed)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should only update unsubscribed contacts, not already subscribed ones', async () => {
|
||||||
|
const unsubscribed1 = await factories.createContact({projectId, subscribed: false});
|
||||||
|
const unsubscribed2 = await factories.createContact({projectId, subscribed: false});
|
||||||
|
const alreadySubscribed = await factories.createContact({projectId, subscribed: true});
|
||||||
|
|
||||||
|
const result = await ContactService.bulkSubscribe(projectId, [
|
||||||
|
unsubscribed1.id,
|
||||||
|
unsubscribed2.id,
|
||||||
|
alreadySubscribed.id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(result.updated).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 0 if no contacts need updating', async () => {
|
||||||
|
const contact1 = await factories.createContact({projectId, subscribed: true});
|
||||||
|
const contact2 = await factories.createContact({projectId, subscribed: true});
|
||||||
|
|
||||||
|
const result = await ContactService.bulkSubscribe(projectId, [contact1.id, contact2.id]);
|
||||||
|
|
||||||
|
expect(result.updated).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should only update contacts belonging to the specified project', async () => {
|
||||||
|
const {project: otherProject} = await factories.createUserWithProject();
|
||||||
|
const ownContact = await factories.createContact({projectId, subscribed: false});
|
||||||
|
const otherContact = await factories.createContact({projectId: otherProject.id, subscribed: false});
|
||||||
|
|
||||||
|
const result = await ContactService.bulkSubscribe(projectId, [ownContact.id, otherContact.id]);
|
||||||
|
|
||||||
|
expect(result.updated).toBe(1);
|
||||||
|
|
||||||
|
const ownContactAfter = await prisma.contact.findUnique({where: {id: ownContact.id}});
|
||||||
|
const otherContactAfter = await prisma.contact.findUnique({where: {id: otherContact.id}});
|
||||||
|
|
||||||
|
expect(ownContactAfter?.subscribed).toBe(true);
|
||||||
|
expect(otherContactAfter?.subscribed).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty contact IDs array', async () => {
|
||||||
|
const result = await ContactService.bulkSubscribe(projectId, []);
|
||||||
|
|
||||||
|
expect(result.updated).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle non-existent contact IDs gracefully', async () => {
|
||||||
|
const result = await ContactService.bulkSubscribe(projectId, ['non-existent-1', 'non-existent-2']);
|
||||||
|
|
||||||
|
expect(result.updated).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle large batches efficiently', async () => {
|
||||||
|
const contacts = await Promise.all(
|
||||||
|
Array.from({length: 150}, () => factories.createContact({projectId, subscribed: false})),
|
||||||
|
);
|
||||||
|
const contactIds = contacts.map(c => c.id);
|
||||||
|
|
||||||
|
const result = await ContactService.bulkSubscribe(projectId, contactIds);
|
||||||
|
|
||||||
|
expect(result.updated).toBe(150);
|
||||||
|
|
||||||
|
const updatedContacts = await prisma.contact.findMany({
|
||||||
|
where: {id: {in: contactIds}},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(updatedContacts.every(c => c.subscribed)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('bulkUnsubscribe', () => {
|
||||||
|
it('should unsubscribe multiple subscribed contacts', async () => {
|
||||||
|
const contact1 = await factories.createContact({projectId, subscribed: true});
|
||||||
|
const contact2 = await factories.createContact({projectId, subscribed: true});
|
||||||
|
const contact3 = await factories.createContact({projectId, subscribed: true});
|
||||||
|
|
||||||
|
const result = await ContactService.bulkUnsubscribe(projectId, [contact1.id, contact2.id, contact3.id]);
|
||||||
|
|
||||||
|
expect(result.updated).toBe(3);
|
||||||
|
|
||||||
|
const contacts = await prisma.contact.findMany({
|
||||||
|
where: {id: {in: [contact1.id, contact2.id, contact3.id]}},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(contacts.every(c => !c.subscribed)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should only update subscribed contacts, not already unsubscribed ones', async () => {
|
||||||
|
const subscribed1 = await factories.createContact({projectId, subscribed: true});
|
||||||
|
const subscribed2 = await factories.createContact({projectId, subscribed: true});
|
||||||
|
const alreadyUnsubscribed = await factories.createContact({projectId, subscribed: false});
|
||||||
|
|
||||||
|
const result = await ContactService.bulkUnsubscribe(projectId, [
|
||||||
|
subscribed1.id,
|
||||||
|
subscribed2.id,
|
||||||
|
alreadyUnsubscribed.id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(result.updated).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 0 if no contacts need updating', async () => {
|
||||||
|
const contact1 = await factories.createContact({projectId, subscribed: false});
|
||||||
|
const contact2 = await factories.createContact({projectId, subscribed: false});
|
||||||
|
|
||||||
|
const result = await ContactService.bulkUnsubscribe(projectId, [contact1.id, contact2.id]);
|
||||||
|
|
||||||
|
expect(result.updated).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should only update contacts belonging to the specified project', async () => {
|
||||||
|
const {project: otherProject} = await factories.createUserWithProject();
|
||||||
|
const ownContact = await factories.createContact({projectId, subscribed: true});
|
||||||
|
const otherContact = await factories.createContact({projectId: otherProject.id, subscribed: true});
|
||||||
|
|
||||||
|
const result = await ContactService.bulkUnsubscribe(projectId, [ownContact.id, otherContact.id]);
|
||||||
|
|
||||||
|
expect(result.updated).toBe(1);
|
||||||
|
|
||||||
|
const ownContactAfter = await prisma.contact.findUnique({where: {id: ownContact.id}});
|
||||||
|
const otherContactAfter = await prisma.contact.findUnique({where: {id: otherContact.id}});
|
||||||
|
|
||||||
|
expect(ownContactAfter?.subscribed).toBe(false);
|
||||||
|
expect(otherContactAfter?.subscribed).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty contact IDs array', async () => {
|
||||||
|
const result = await ContactService.bulkUnsubscribe(projectId, []);
|
||||||
|
|
||||||
|
expect(result.updated).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle non-existent contact IDs gracefully', async () => {
|
||||||
|
const result = await ContactService.bulkUnsubscribe(projectId, ['non-existent-1', 'non-existent-2']);
|
||||||
|
|
||||||
|
expect(result.updated).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('bulkDelete', () => {
|
||||||
|
it('should delete multiple contacts', async () => {
|
||||||
|
const contact1 = await factories.createContact({projectId});
|
||||||
|
const contact2 = await factories.createContact({projectId});
|
||||||
|
const contact3 = await factories.createContact({projectId});
|
||||||
|
|
||||||
|
const result = await ContactService.bulkDelete(projectId, [contact1.id, contact2.id, contact3.id]);
|
||||||
|
|
||||||
|
expect(result.deleted).toBe(3);
|
||||||
|
|
||||||
|
const contacts = await prisma.contact.findMany({
|
||||||
|
where: {id: {in: [contact1.id, contact2.id, contact3.id]}},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(contacts).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should only delete contacts belonging to the specified project', async () => {
|
||||||
|
const {project: otherProject} = await factories.createUserWithProject();
|
||||||
|
const ownContact = await factories.createContact({projectId});
|
||||||
|
const otherContact = await factories.createContact({projectId: otherProject.id});
|
||||||
|
|
||||||
|
const result = await ContactService.bulkDelete(projectId, [ownContact.id, otherContact.id]);
|
||||||
|
|
||||||
|
expect(result.deleted).toBe(1);
|
||||||
|
|
||||||
|
const ownContactAfter = await prisma.contact.findUnique({where: {id: ownContact.id}});
|
||||||
|
const otherContactAfter = await prisma.contact.findUnique({where: {id: otherContact.id}});
|
||||||
|
|
||||||
|
expect(ownContactAfter).toBeNull();
|
||||||
|
expect(otherContactAfter).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty contact IDs array', async () => {
|
||||||
|
const result = await ContactService.bulkDelete(projectId, []);
|
||||||
|
|
||||||
|
expect(result.deleted).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle non-existent contact IDs gracefully', async () => {
|
||||||
|
const result = await ContactService.bulkDelete(projectId, ['non-existent-1', 'non-existent-2']);
|
||||||
|
|
||||||
|
expect(result.deleted).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle large batches efficiently', async () => {
|
||||||
|
const contacts = await Promise.all(Array.from({length: 200}, () => factories.createContact({projectId})));
|
||||||
|
const contactIds = contacts.map(c => c.id);
|
||||||
|
|
||||||
|
const result = await ContactService.bulkDelete(projectId, contactIds);
|
||||||
|
|
||||||
|
expect(result.deleted).toBe(200);
|
||||||
|
|
||||||
|
const remainingContacts = await prisma.contact.findMany({
|
||||||
|
where: {id: {in: contactIds}},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(remainingContacts).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should delete both subscribed and unsubscribed contacts', async () => {
|
||||||
|
const subscribed = await factories.createContact({projectId, subscribed: true});
|
||||||
|
const unsubscribed = await factories.createContact({projectId, subscribed: false});
|
||||||
|
|
||||||
|
const result = await ContactService.bulkDelete(projectId, [subscribed.id, unsubscribed.id]);
|
||||||
|
|
||||||
|
expect(result.deleted).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle partial matches (some exist, some do not)', async () => {
|
||||||
|
const existingContact = await factories.createContact({projectId});
|
||||||
|
|
||||||
|
const result = await ContactService.bulkDelete(projectId, [existingContact.id, 'non-existent-id']);
|
||||||
|
|
||||||
|
expect(result.deleted).toBe(1);
|
||||||
|
|
||||||
|
const contact = await prisma.contact.findUnique({where: {id: existingContact.id}});
|
||||||
|
expect(contact).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Bulk Operations - Project Isolation', () => {
|
||||||
|
it('should never leak contacts between projects in bulk operations', async () => {
|
||||||
|
const {project: project1} = await factories.createUserWithProject();
|
||||||
|
const {project: project2} = await factories.createUserWithProject();
|
||||||
|
|
||||||
|
const p1Contact1 = await factories.createContact({projectId: project1.id, subscribed: false});
|
||||||
|
const p1Contact2 = await factories.createContact({projectId: project1.id, subscribed: false});
|
||||||
|
const p2Contact1 = await factories.createContact({projectId: project2.id, subscribed: false});
|
||||||
|
const p2Contact2 = await factories.createContact({projectId: project2.id, subscribed: false});
|
||||||
|
|
||||||
|
await ContactService.bulkSubscribe(project1.id, [
|
||||||
|
p1Contact1.id,
|
||||||
|
p1Contact2.id,
|
||||||
|
p2Contact1.id,
|
||||||
|
p2Contact2.id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const p1ContactsAfter = await prisma.contact.findMany({
|
||||||
|
where: {projectId: project1.id},
|
||||||
|
});
|
||||||
|
const p2ContactsAfter = await prisma.contact.findMany({
|
||||||
|
where: {projectId: project2.id},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(p1ContactsAfter.every(c => c.subscribed)).toBe(true);
|
||||||
|
expect(p2ContactsAfter.every(c => !c.subscribed)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
CardDescription,
|
CardDescription,
|
||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
|
Checkbox,
|
||||||
ConfirmDialog,
|
ConfirmDialog,
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -62,6 +63,9 @@ export default function ContactsPage() {
|
|||||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||||
const [contactToDelete, setContactToDelete] = useState<string | null>(null);
|
const [contactToDelete, setContactToDelete] = useState<string | null>(null);
|
||||||
const [totalCount, setTotalCount] = useState<number>(0);
|
const [totalCount, setTotalCount] = useState<number>(0);
|
||||||
|
const [selectedContacts, setSelectedContacts] = useState<Set<string>>(new Set());
|
||||||
|
const [showBulkActionsDialog, setShowBulkActionsDialog] = useState(false);
|
||||||
|
const [bulkOperation, setBulkOperation] = useState<'subscribe' | 'unsubscribe' | 'delete' | null>(null);
|
||||||
const pageSize = 50;
|
const pageSize = 50;
|
||||||
|
|
||||||
const {data, mutate, isLoading} = useSWR<PaginatedContacts>(
|
const {data, mutate, isLoading} = useSWR<PaginatedContacts>(
|
||||||
@@ -93,6 +97,7 @@ export default function ContactsPage() {
|
|||||||
const newPage = currentPage + 1;
|
const newPage = currentPage + 1;
|
||||||
setCursor(data.cursor);
|
setCursor(data.cursor);
|
||||||
setCurrentPage(newPage);
|
setCurrentPage(newPage);
|
||||||
|
setSelectedContacts(new Set()); // Clear selection on page change
|
||||||
|
|
||||||
// Store cursor in history if not already there
|
// Store cursor in history if not already there
|
||||||
if (cursorHistory.length <= newPage) {
|
if (cursorHistory.length <= newPage) {
|
||||||
@@ -107,9 +112,37 @@ export default function ContactsPage() {
|
|||||||
const previousCursor = cursorHistory[newPage];
|
const previousCursor = cursorHistory[newPage];
|
||||||
setCursor(previousCursor);
|
setCursor(previousCursor);
|
||||||
setCurrentPage(newPage);
|
setCurrentPage(newPage);
|
||||||
|
setSelectedContacts(new Set()); // Clear selection on page change
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSelectAll = () => {
|
||||||
|
if (selectedContacts.size === contacts.length && contacts.length > 0) {
|
||||||
|
setSelectedContacts(new Set());
|
||||||
|
} else {
|
||||||
|
setSelectedContacts(new Set(contacts.map(c => c.id)));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectContact = (contactId: string) => {
|
||||||
|
const newSelected = new Set(selectedContacts);
|
||||||
|
if (newSelected.has(contactId)) {
|
||||||
|
newSelected.delete(contactId);
|
||||||
|
} else {
|
||||||
|
newSelected.add(contactId);
|
||||||
|
}
|
||||||
|
setSelectedContacts(newSelected);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBulkAction = (operation: 'subscribe' | 'unsubscribe' | 'delete') => {
|
||||||
|
setBulkOperation(operation);
|
||||||
|
setShowBulkActionsDialog(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearSelection = () => {
|
||||||
|
setSelectedContacts(new Set());
|
||||||
|
};
|
||||||
|
|
||||||
const promptDelete = (contactId: string) => {
|
const promptDelete = (contactId: string) => {
|
||||||
setContactToDelete(contactId);
|
setContactToDelete(contactId);
|
||||||
setShowDeleteDialog(true);
|
setShowDeleteDialog(true);
|
||||||
@@ -194,6 +227,50 @@ export default function ContactsPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Bulk Actions Toolbar */}
|
||||||
|
{selectedContacts.size > 0 && (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<span className="text-sm font-medium text-neutral-900">
|
||||||
|
{selectedContacts.size} contact{selectedContacts.size !== 1 ? 's' : ''} selected
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleBulkAction('subscribe')}
|
||||||
|
>
|
||||||
|
<MailCheck className="h-4 w-4 mr-1.5" />
|
||||||
|
Subscribe
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleBulkAction('unsubscribe')}
|
||||||
|
>
|
||||||
|
<MailX className="h-4 w-4 mr-1.5" />
|
||||||
|
Unsubscribe
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleBulkAction('delete')}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4 mr-1.5" />
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button variant="ghost" size="sm" onClick={clearSelection}>
|
||||||
|
Clear Selection
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Contacts Table */}
|
{/* Contacts Table */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@@ -244,6 +321,12 @@ export default function ContactsPage() {
|
|||||||
<table className="w-full">
|
<table className="w-full">
|
||||||
<thead className="bg-neutral-50 border-b border-neutral-200">
|
<thead className="bg-neutral-50 border-b border-neutral-200">
|
||||||
<tr>
|
<tr>
|
||||||
|
<th className="px-6 py-3 text-left w-12">
|
||||||
|
<Checkbox
|
||||||
|
checked={selectedContacts.size === contacts.length && contacts.length > 0}
|
||||||
|
onCheckedChange={handleSelectAll}
|
||||||
|
/>
|
||||||
|
</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 uppercase tracking-wider">
|
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 uppercase tracking-wider">
|
||||||
Email
|
Email
|
||||||
</th>
|
</th>
|
||||||
@@ -261,6 +344,12 @@ export default function ContactsPage() {
|
|||||||
<tbody className="bg-white divide-y divide-neutral-200">
|
<tbody className="bg-white divide-y divide-neutral-200">
|
||||||
{contacts.map(contact => (
|
{contacts.map(contact => (
|
||||||
<tr key={contact.id} className="hover:bg-neutral-50 transition-colors">
|
<tr key={contact.id} className="hover:bg-neutral-50 transition-colors">
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<Checkbox
|
||||||
|
checked={selectedContacts.has(contact.id)}
|
||||||
|
onCheckedChange={() => handleSelectContact(contact.id)}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{contact.subscribed ? (
|
{contact.subscribed ? (
|
||||||
@@ -399,6 +488,18 @@ export default function ContactsPage() {
|
|||||||
{/* Import Contacts Dialog */}
|
{/* Import Contacts Dialog */}
|
||||||
<ImportContactsDialog open={showImportDialog} onOpenChange={setShowImportDialog} onSuccess={() => mutate()} />
|
<ImportContactsDialog open={showImportDialog} onOpenChange={setShowImportDialog} onSuccess={() => mutate()} />
|
||||||
|
|
||||||
|
{/* Bulk Actions Dialog */}
|
||||||
|
<BulkActionsDialog
|
||||||
|
open={showBulkActionsDialog}
|
||||||
|
onOpenChange={setShowBulkActionsDialog}
|
||||||
|
operation={bulkOperation}
|
||||||
|
contactIds={Array.from(selectedContacts)}
|
||||||
|
onSuccess={() => {
|
||||||
|
mutate();
|
||||||
|
clearSelection();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Delete Confirmation Dialog */}
|
{/* Delete Confirmation Dialog */}
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
open={showDeleteDialog}
|
open={showDeleteDialog}
|
||||||
@@ -859,3 +960,286 @@ function ImportContactsDialog({open, onOpenChange, onSuccess}: ImportContactsDia
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface BulkActionsDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
operation: 'subscribe' | 'unsubscribe' | 'delete' | null;
|
||||||
|
contactIds: string[];
|
||||||
|
onSuccess: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BulkActionResult {
|
||||||
|
operation: string;
|
||||||
|
totalRequested: number;
|
||||||
|
successCount: number;
|
||||||
|
failureCount: number;
|
||||||
|
errors: Array<{contactId: string; email: string; error: string}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function BulkActionsDialog({open, onOpenChange, operation, contactIds, onSuccess}: BulkActionsDialogProps) {
|
||||||
|
const [jobId, setJobId] = useState<string | null>(null);
|
||||||
|
const [isProcessing, setIsProcessing] = useState(false);
|
||||||
|
const [progress, setProgress] = useState(0);
|
||||||
|
const [status, setStatus] = useState<'idle' | 'processing' | 'completed' | 'failed'>('idle');
|
||||||
|
const [result, setResult] = useState<BulkActionResult | null>(null);
|
||||||
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||||
|
const pollIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
const [showCloseConfirmDialog, setShowCloseConfirmDialog] = useState(false);
|
||||||
|
|
||||||
|
// Clean up polling on unmount or dialog close
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
if (pollIntervalRef.current) {
|
||||||
|
clearInterval(pollIntervalRef.current);
|
||||||
|
pollIntervalRef.current = null;
|
||||||
|
}
|
||||||
|
setTimeout(() => {
|
||||||
|
setJobId(null);
|
||||||
|
setProgress(0);
|
||||||
|
setStatus('idle');
|
||||||
|
setResult(null);
|
||||||
|
setErrorMessage(null);
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const pollJobStatus = async (jobId: string) => {
|
||||||
|
try {
|
||||||
|
const response = await network.fetch<{
|
||||||
|
id: string;
|
||||||
|
state: string;
|
||||||
|
progress: number;
|
||||||
|
result: BulkActionResult | null;
|
||||||
|
failedReason?: string;
|
||||||
|
}>('GET', `/contacts/bulk/${jobId}`);
|
||||||
|
|
||||||
|
setProgress(response.progress || 0);
|
||||||
|
|
||||||
|
if (response.state === 'completed') {
|
||||||
|
setStatus('completed');
|
||||||
|
setResult(response.result);
|
||||||
|
if (pollIntervalRef.current) {
|
||||||
|
clearInterval(pollIntervalRef.current);
|
||||||
|
pollIntervalRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.result) {
|
||||||
|
const {successCount, failureCount} = response.result;
|
||||||
|
toast.success(`Completed: ${successCount} succeeded${failureCount > 0 ? `, ${failureCount} failed` : ''}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
onSuccess();
|
||||||
|
} else if (response.state === 'failed') {
|
||||||
|
setStatus('failed');
|
||||||
|
if (pollIntervalRef.current) {
|
||||||
|
clearInterval(pollIntervalRef.current);
|
||||||
|
pollIntervalRef.current = null;
|
||||||
|
}
|
||||||
|
const errorMsg = response.failedReason || 'Operation failed';
|
||||||
|
setErrorMessage(errorMsg);
|
||||||
|
toast.error(errorMsg);
|
||||||
|
} else if (response.state === 'active') {
|
||||||
|
setStatus('processing');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to poll job status:', error);
|
||||||
|
if (pollIntervalRef.current) {
|
||||||
|
clearInterval(pollIntervalRef.current);
|
||||||
|
pollIntervalRef.current = null;
|
||||||
|
}
|
||||||
|
setStatus('failed');
|
||||||
|
toast.error('Failed to check operation status');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirm = async () => {
|
||||||
|
if (!operation) return;
|
||||||
|
|
||||||
|
setIsProcessing(true);
|
||||||
|
setStatus('processing');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const endpoint = `/contacts/bulk-${operation}`;
|
||||||
|
const data = await network.fetch<{jobId: string; message: string}, typeof ContactSchemas.bulkAction>(
|
||||||
|
'POST',
|
||||||
|
endpoint,
|
||||||
|
{contactIds},
|
||||||
|
);
|
||||||
|
|
||||||
|
setJobId(data.jobId);
|
||||||
|
|
||||||
|
// Start polling for job status
|
||||||
|
pollIntervalRef.current = setInterval(() => {
|
||||||
|
void pollJobStatus(data.jobId);
|
||||||
|
}, 1000);
|
||||||
|
} catch (error) {
|
||||||
|
const errorMsg = error instanceof Error ? error.message : 'Failed to start operation';
|
||||||
|
setErrorMessage(errorMsg);
|
||||||
|
toast.error(errorMsg);
|
||||||
|
setStatus('failed');
|
||||||
|
} finally {
|
||||||
|
setIsProcessing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
if (status === 'processing') {
|
||||||
|
setShowCloseConfirmDialog(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onOpenChange(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmClose = () => {
|
||||||
|
onOpenChange(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getOperationLabel = () => {
|
||||||
|
switch (operation) {
|
||||||
|
case 'subscribe': return 'Subscribe';
|
||||||
|
case 'unsubscribe': return 'Unsubscribe';
|
||||||
|
case 'delete': return 'Delete';
|
||||||
|
default: return 'Process';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getOperationColor = () => {
|
||||||
|
switch (operation) {
|
||||||
|
case 'subscribe': return 'green';
|
||||||
|
case 'unsubscribe': return 'yellow';
|
||||||
|
case 'delete': return 'red';
|
||||||
|
default: return 'blue';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Dialog open={open} onOpenChange={handleClose}>
|
||||||
|
<DialogContent className="sm:max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{getOperationLabel()} Contacts</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{status === 'idle' && (
|
||||||
|
<div className="bg-neutral-50 border border-neutral-200 rounded-lg p-4">
|
||||||
|
<p className="text-sm text-neutral-900">
|
||||||
|
Are you sure you want to {operation} {contactIds.length} contact
|
||||||
|
{contactIds.length !== 1 ? 's' : ''}?
|
||||||
|
</p>
|
||||||
|
{operation === 'delete' && (
|
||||||
|
<p className="text-sm text-red-600 mt-2 font-medium">
|
||||||
|
This action cannot be undone.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(status === 'processing') && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-neutral-600">Processing contacts...</span>
|
||||||
|
<span className="text-neutral-900 font-medium">{progress}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full bg-neutral-200 rounded-full h-2">
|
||||||
|
<div
|
||||||
|
className={`bg-${getOperationColor()}-600 h-2 rounded-full transition-all duration-300`}
|
||||||
|
style={{width: `${progress}%`}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status === 'completed' && result && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="bg-green-50 rounded-lg p-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<CheckCircle className="h-5 w-5 text-green-600" />
|
||||||
|
<div className="text-2xl font-bold text-green-900">{result.successCount}</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-green-700">Succeeded</div>
|
||||||
|
</div>
|
||||||
|
{result.failureCount > 0 && (
|
||||||
|
<div className="bg-red-50 rounded-lg p-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<XCircle className="h-5 w-5 text-red-600" />
|
||||||
|
<div className="text-2xl font-bold text-red-900">{result.failureCount}</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-red-700">Failed</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{result.errors && result.errors.length > 0 && (
|
||||||
|
<div className="bg-red-50 border border-red-200 rounded-lg p-4 max-h-48 overflow-y-auto">
|
||||||
|
<h4 className="font-medium text-red-900 mb-2">Errors</h4>
|
||||||
|
<div className="space-y-1 text-sm text-red-800">
|
||||||
|
{result.errors.slice(0, 10).map((error, idx) => (
|
||||||
|
<div key={idx}>{error.error}</div>
|
||||||
|
))}
|
||||||
|
{result.errors.length > 10 && (
|
||||||
|
<div className="text-red-700 font-medium mt-2">
|
||||||
|
...and {result.errors.length - 10} more errors
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status === 'failed' && (
|
||||||
|
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
|
||||||
|
<div className="flex items-center gap-2 text-red-900">
|
||||||
|
<XCircle className="h-5 w-5" />
|
||||||
|
<span className="font-medium">Operation failed</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-red-800 mt-1">
|
||||||
|
{errorMessage || 'Please try again.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
{status === 'idle' ? (
|
||||||
|
<>
|
||||||
|
<Button type="button" variant="outline" onClick={handleClose}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={handleConfirm}
|
||||||
|
disabled={isProcessing}
|
||||||
|
variant={operation === 'delete' ? 'destructive' : 'default'}
|
||||||
|
>
|
||||||
|
{isProcessing ? 'Starting...' : getOperationLabel()}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : status === 'completed' ? (
|
||||||
|
<Button type="button" onClick={handleClose}>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button type="button" variant="outline" onClick={handleClose}>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={showCloseConfirmDialog}
|
||||||
|
onOpenChange={setShowCloseConfirmDialog}
|
||||||
|
onConfirm={confirmClose}
|
||||||
|
title="Close Operation"
|
||||||
|
description="Operation is still in progress. Are you sure you want to close?"
|
||||||
|
confirmText="Close Anyway"
|
||||||
|
variant="destructive"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -69,7 +69,10 @@ export const ContactSchemas = {
|
|||||||
subscribed: z.boolean().default(true),
|
subscribed: z.boolean().default(true),
|
||||||
data: jsonSchema.optional(),
|
data: jsonSchema.optional(),
|
||||||
}),
|
}),
|
||||||
};
|
bulkAction: z.object({
|
||||||
|
contactIds: z.array(uuid).min(1).max(1000),
|
||||||
|
}),
|
||||||
|
} as const;
|
||||||
|
|
||||||
const segmentFilterSchema = z.object({
|
const segmentFilterSchema = z.object({
|
||||||
field: z.string().min(1),
|
field: z.string().min(1),
|
||||||
|
|||||||
+59
-34
@@ -63,54 +63,79 @@ class TestDatabase {
|
|||||||
* Clean up database after each test
|
* Clean up database after each test
|
||||||
* Deletes all records in reverse order of dependencies
|
* Deletes all records in reverse order of dependencies
|
||||||
* Uses batched deletes to prevent memory issues with large datasets
|
* Uses batched deletes to prevent memory issues with large datasets
|
||||||
|
* Retries on deadlock to handle race conditions with background event tracking
|
||||||
*/
|
*/
|
||||||
async cleanup() {
|
async cleanup() {
|
||||||
if (!this.prisma) return;
|
if (!this.prisma) return;
|
||||||
|
|
||||||
try {
|
const maxRetries = 3;
|
||||||
// Use a transaction to ensure all deletes happen atomically
|
let lastError: Error | null = null;
|
||||||
// This prevents foreign key constraint violations and race conditions
|
|
||||||
await this.prisma.$transaction([
|
|
||||||
// Level 1: Delete deepest dependencies first
|
|
||||||
this.prisma.event.deleteMany(),
|
|
||||||
this.prisma.workflowStepExecution.deleteMany(),
|
|
||||||
|
|
||||||
// Level 2: Delete entities that depend on Level 1
|
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||||
this.prisma.email.deleteMany(),
|
try {
|
||||||
this.prisma.workflowExecution.deleteMany(),
|
// Use a transaction to ensure all deletes happen atomically
|
||||||
|
// This prevents foreign key constraint violations and race conditions
|
||||||
|
await this.prisma.$transaction([
|
||||||
|
// Level 1: Delete deepest dependencies first
|
||||||
|
this.prisma.event.deleteMany(),
|
||||||
|
this.prisma.workflowStepExecution.deleteMany(),
|
||||||
|
|
||||||
// Level 3: Delete workflow structure
|
// Level 2: Delete entities that depend on Level 1
|
||||||
this.prisma.workflowTransition.deleteMany(),
|
this.prisma.email.deleteMany(),
|
||||||
this.prisma.workflowStep.deleteMany(),
|
this.prisma.workflowExecution.deleteMany(),
|
||||||
this.prisma.workflow.deleteMany(),
|
|
||||||
|
|
||||||
// Level 4: Delete campaigns and templates
|
// Level 3: Delete workflow structure
|
||||||
this.prisma.campaign.deleteMany(),
|
this.prisma.workflowTransition.deleteMany(),
|
||||||
this.prisma.template.deleteMany(),
|
this.prisma.workflowStep.deleteMany(),
|
||||||
|
this.prisma.workflow.deleteMany(),
|
||||||
|
|
||||||
// Level 5: Delete segment relationships
|
// Level 4: Delete campaigns and templates
|
||||||
this.prisma.segmentMembership.deleteMany(),
|
this.prisma.campaign.deleteMany(),
|
||||||
this.prisma.segment.deleteMany(),
|
this.prisma.template.deleteMany(),
|
||||||
|
|
||||||
// Level 6: Delete contacts
|
// Level 5: Delete segment relationships
|
||||||
this.prisma.contact.deleteMany(),
|
this.prisma.segmentMembership.deleteMany(),
|
||||||
|
this.prisma.segment.deleteMany(),
|
||||||
|
|
||||||
// Level 7: Delete domains
|
// Level 6: Delete contacts
|
||||||
this.prisma.domain.deleteMany(),
|
this.prisma.contact.deleteMany(),
|
||||||
|
|
||||||
// Level 8: Delete memberships (has FK to both user and project)
|
// Level 7: Delete domains
|
||||||
this.prisma.membership.deleteMany(),
|
this.prisma.domain.deleteMany(),
|
||||||
|
|
||||||
// Level 9: Delete projects
|
// Level 8: Delete memberships (has FK to both user and project)
|
||||||
this.prisma.project.deleteMany(),
|
this.prisma.membership.deleteMany(),
|
||||||
|
|
||||||
// Level 10: Delete users last
|
// Level 9: Delete projects
|
||||||
this.prisma.user.deleteMany(),
|
this.prisma.project.deleteMany(),
|
||||||
]);
|
|
||||||
} catch (error) {
|
// Level 10: Delete users last
|
||||||
console.error('Error cleaning up database:', error);
|
this.prisma.user.deleteMany(),
|
||||||
throw error;
|
]);
|
||||||
|
|
||||||
|
// Success - exit retry loop
|
||||||
|
return;
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error as Error;
|
||||||
|
|
||||||
|
// Check if this is a deadlock error (PostgreSQL error code 40P01)
|
||||||
|
const isDeadlock = error instanceof Error && error.message?.includes('deadlock detected');
|
||||||
|
|
||||||
|
if (isDeadlock && attempt < maxRetries) {
|
||||||
|
// Wait before retrying (exponential backoff)
|
||||||
|
const delay = Math.pow(2, attempt) * 50; // 100ms, 200ms, 400ms
|
||||||
|
await new Promise(resolve => setTimeout(resolve, delay));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not a deadlock or out of retries
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If we get here, all retries failed
|
||||||
|
console.error(`Error cleaning up database after ${maxRetries} attempts:`, lastError);
|
||||||
|
throw lastError;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user