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 {createApiRequestCleanupWorker} from './api-request-cleanup-processor.js';
|
||||
import {createBulkContactWorker} from './bulk-contact-processor.js';
|
||||
import {createCampaignWorker} from './campaign-processor.js';
|
||||
import {createDomainVerificationWorker} from './domain-verification-processor.js';
|
||||
import {createEmailWorker} from './email-processor.js';
|
||||
@@ -49,6 +50,11 @@ async function startWorkers() {
|
||||
workers.push({name: 'import', worker: importWorker});
|
||||
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
|
||||
const segmentCountWorker = createSegmentCountWorker();
|
||||
workers.push({name: 'segment-count', worker: segmentCountWorker});
|
||||
|
||||
@@ -679,4 +679,148 @@ export class ContactService {
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
export interface BulkContactActionJobData {
|
||||
projectId: string;
|
||||
contactIds: string[];
|
||||
operation: 'subscribe' | 'unsubscribe' | 'delete';
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
@@ -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
|
||||
*/
|
||||
@@ -354,6 +415,7 @@ export class QueueService {
|
||||
segmentCountCounts,
|
||||
domainVerificationCounts,
|
||||
apiRequestCleanupCounts,
|
||||
bulkContactCounts,
|
||||
] = await Promise.all([
|
||||
emailQueue.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'),
|
||||
domainVerificationQueue.getJobCounts('waiting', 'active', 'completed', 'failed', 'delayed'),
|
||||
apiRequestCleanupQueue.getJobCounts('waiting', 'active', 'completed', 'failed', 'delayed'),
|
||||
bulkContactQueue.getJobCounts('waiting', 'active', 'completed', 'failed', 'delayed'),
|
||||
]);
|
||||
|
||||
return {
|
||||
@@ -374,6 +437,7 @@ export class QueueService {
|
||||
segmentCount: segmentCountCounts,
|
||||
domainVerification: domainVerificationCounts,
|
||||
apiRequestCleanup: apiRequestCleanupCounts,
|
||||
bulkContact: bulkContactCounts,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -390,6 +454,7 @@ export class QueueService {
|
||||
segmentCountQueue.pause(),
|
||||
domainVerificationQueue.pause(),
|
||||
apiRequestCleanupQueue.pause(),
|
||||
bulkContactQueue.pause(),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -406,6 +471,7 @@ export class QueueService {
|
||||
segmentCountQueue.resume(),
|
||||
domainVerificationQueue.resume(),
|
||||
apiRequestCleanupQueue.resume(),
|
||||
bulkContactQueue.resume(),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -430,6 +496,8 @@ export class QueueService {
|
||||
segmentCountQueue.clean(gracePeriod * 7, 50, 'failed'),
|
||||
domainVerificationQueue.clean(gracePeriod, 10, 'completed'),
|
||||
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(),
|
||||
domainVerificationQueue.close(),
|
||||
apiRequestCleanupQueue.close(),
|
||||
bulkContactQueue.close(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -439,4 +439,271 @@ describe('ContactService - Duplicate Prevention & Data Merging', () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user