feat: implement bulk contact action selector for improved flexibility in bulk operations
This commit is contained in:
@@ -1,6 +1,8 @@
|
|||||||
import {Controller, Delete, Get, Middleware, Patch, Post} from '@overnightjs/core';
|
import {Controller, Delete, Get, Middleware, Patch, Post} from '@overnightjs/core';
|
||||||
import type {NextFunction, Request, Response} from 'express';
|
import type {NextFunction, Request, Response} from 'express';
|
||||||
import multer from 'multer';
|
import multer from 'multer';
|
||||||
|
import {ContactSchemas} from '@plunk/shared';
|
||||||
|
import type {BulkContactActionSelector} from '@plunk/types';
|
||||||
import signale from 'signale';
|
import signale from 'signale';
|
||||||
import {requireAuth, requireEmailVerified} from '../middleware/auth.js';
|
import {requireAuth, requireEmailVerified} from '../middleware/auth.js';
|
||||||
import {ContactService} from '../services/ContactService.js';
|
import {ContactService} from '../services/ContactService.js';
|
||||||
@@ -424,31 +426,7 @@ export class Contacts {
|
|||||||
@Middleware([requireAuth, requireEmailVerified])
|
@Middleware([requireAuth, requireEmailVerified])
|
||||||
@CatchAsync
|
@CatchAsync
|
||||||
public async bulkSubscribe(req: Request, res: Response, _next: NextFunction) {
|
public async bulkSubscribe(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth;
|
return queueBulkAction(req, res, 'subscribe');
|
||||||
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',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -459,30 +437,7 @@ export class Contacts {
|
|||||||
@Middleware([requireAuth, requireEmailVerified])
|
@Middleware([requireAuth, requireEmailVerified])
|
||||||
@CatchAsync
|
@CatchAsync
|
||||||
public async bulkUnsubscribe(req: Request, res: Response, _next: NextFunction) {
|
public async bulkUnsubscribe(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth;
|
return queueBulkAction(req, res, 'unsubscribe');
|
||||||
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',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -493,30 +448,7 @@ export class Contacts {
|
|||||||
@Middleware([requireAuth, requireEmailVerified])
|
@Middleware([requireAuth, requireEmailVerified])
|
||||||
@CatchAsync
|
@CatchAsync
|
||||||
public async bulkDelete(req: Request, res: Response, _next: NextFunction) {
|
public async bulkDelete(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth;
|
return queueBulkAction(req, res, 'delete');
|
||||||
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',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -550,3 +482,36 @@ export class Contacts {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function queueBulkAction(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
operation: 'subscribe' | 'unsubscribe' | 'delete',
|
||||||
|
) {
|
||||||
|
const auth = res.locals.auth;
|
||||||
|
|
||||||
|
const parsed = ContactSchemas.bulkAction.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: parsed.error.errors[0]?.message ?? 'Invalid bulk action payload',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const selector: BulkContactActionSelector =
|
||||||
|
parsed.data.mode === 'ids'
|
||||||
|
? {mode: 'ids', contactIds: parsed.data.contactIds}
|
||||||
|
: {mode: 'query', filter: parsed.data.filter, excludeIds: parsed.data.excludeIds};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const job = await QueueService.queueBulkContactAction(auth.projectId!, selector, operation);
|
||||||
|
return res.status(202).json({
|
||||||
|
message: `Bulk ${operation} queued successfully`,
|
||||||
|
jobId: job.id,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
signale.error(`[CONTACTS] Failed to queue bulk ${operation}:`, error);
|
||||||
|
return res.status(500).json({
|
||||||
|
error: error instanceof Error ? error.message : `Failed to queue bulk ${operation}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,73 +3,149 @@
|
|||||||
* Processes bulk subscribe, unsubscribe, and delete operations
|
* Processes bulk subscribe, unsubscribe, and delete operations
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type {BulkContactActionJobData} from '@plunk/types';
|
import {Prisma} from '@plunk/db';
|
||||||
|
import type {BulkContactActionJobData, BulkContactActionSelector} from '@plunk/types';
|
||||||
import {type Job, Worker} from 'bullmq';
|
import {type Job, Worker} from 'bullmq';
|
||||||
import signale from 'signale';
|
import signale from 'signale';
|
||||||
|
|
||||||
|
import {prisma} from '../database/prisma.js';
|
||||||
import {ContactService} from '../services/ContactService.js';
|
import {ContactService} from '../services/ContactService.js';
|
||||||
import {bulkContactQueue} from '../services/QueueService.js';
|
import {bulkContactQueue} from '../services/QueueService.js';
|
||||||
|
|
||||||
const BATCH_SIZE = 100; // Process contacts in batches of 100
|
const BATCH_SIZE = 100;
|
||||||
|
|
||||||
interface BulkActionResult {
|
interface BulkActionResult {
|
||||||
operation: 'subscribe' | 'unsubscribe' | 'delete';
|
operation: 'subscribe' | 'unsubscribe' | 'delete';
|
||||||
totalRequested: number;
|
totalRequested: number;
|
||||||
|
/** Contacts whose state was actually changed by this run. */
|
||||||
successCount: number;
|
successCount: number;
|
||||||
|
/** Subscribe/unsubscribe only: contacts already in the target state. */
|
||||||
|
unchangedCount: number;
|
||||||
|
/** Contacts that errored or weren't found (e.g. wrong project). */
|
||||||
failureCount: number;
|
failureCount: number;
|
||||||
errors: {contactId: string; email: string; error: string}[];
|
errors: {contactId: string; email: string; error: string}[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildQueryWhere(projectId: string, selector: Extract<BulkContactActionSelector, {mode: 'query'}>): Prisma.ContactWhereInput {
|
||||||
|
const search = selector.filter?.search;
|
||||||
|
const excludeIds = selector.excludeIds ?? [];
|
||||||
|
return {
|
||||||
|
projectId,
|
||||||
|
...(search ? {email: {contains: search, mode: 'insensitive' as const}} : {}),
|
||||||
|
...(excludeIds.length > 0 ? {id: {notIn: excludeIds}} : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyBatch(
|
||||||
|
projectId: string,
|
||||||
|
operation: BulkActionResult['operation'],
|
||||||
|
ids: string[],
|
||||||
|
): Promise<{changed: number; unchanged: number}> {
|
||||||
|
switch (operation) {
|
||||||
|
case 'subscribe': {
|
||||||
|
const r = await ContactService.bulkSubscribe(projectId, ids);
|
||||||
|
return {changed: r.updated, unchanged: r.unchanged};
|
||||||
|
}
|
||||||
|
case 'unsubscribe': {
|
||||||
|
const r = await ContactService.bulkUnsubscribe(projectId, ids);
|
||||||
|
return {changed: r.updated, unchanged: r.unchanged};
|
||||||
|
}
|
||||||
|
case 'delete': {
|
||||||
|
const r = await ContactService.bulkDelete(projectId, ids);
|
||||||
|
return {changed: r.deleted, unchanged: 0};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function createBulkContactWorker() {
|
export function createBulkContactWorker() {
|
||||||
const worker = new Worker<BulkContactActionJobData>(
|
const worker = new Worker<BulkContactActionJobData>(
|
||||||
bulkContactQueue.name,
|
bulkContactQueue.name,
|
||||||
async (job: Job<BulkContactActionJobData>) => {
|
async (job: Job<BulkContactActionJobData>) => {
|
||||||
const {projectId, contactIds, operation} = job.data;
|
const {projectId, operation, selector} = job.data;
|
||||||
|
|
||||||
signale.info(
|
|
||||||
`[BULK-CONTACT-PROCESSOR] Processing ${operation} for ${contactIds.length} contacts in project ${projectId}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
const result: BulkActionResult = {
|
const result: BulkActionResult = {
|
||||||
operation,
|
operation,
|
||||||
totalRequested: contactIds.length,
|
totalRequested: 0,
|
||||||
successCount: 0,
|
successCount: 0,
|
||||||
|
unchangedCount: 0,
|
||||||
failureCount: 0,
|
failureCount: 0,
|
||||||
errors: [],
|
errors: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
if (selector.mode === 'ids') {
|
||||||
// Process contacts in batches
|
const {contactIds} = selector;
|
||||||
|
result.totalRequested = contactIds.length;
|
||||||
|
|
||||||
|
signale.info(
|
||||||
|
`[BULK-CONTACT-PROCESSOR] Processing ${operation} for ${contactIds.length} contacts (ids mode) in project ${projectId}`,
|
||||||
|
);
|
||||||
|
|
||||||
for (let i = 0; i < contactIds.length; i += BATCH_SIZE) {
|
for (let i = 0; i < contactIds.length; i += BATCH_SIZE) {
|
||||||
const batchIds = contactIds.slice(i, Math.min(i + BATCH_SIZE, contactIds.length));
|
const batchIds = contactIds.slice(i, i + BATCH_SIZE);
|
||||||
|
try {
|
||||||
|
const {changed, unchanged} = await applyBatch(projectId, operation, batchIds);
|
||||||
|
result.successCount += changed;
|
||||||
|
result.unchangedCount += unchanged;
|
||||||
|
const failed = batchIds.length - changed - unchanged;
|
||||||
|
if (failed > 0) result.failureCount += failed;
|
||||||
|
} 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',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await job.updateProgress(Math.round(((i + batchIds.length) / contactIds.length) * 100));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const where = buildQueryWhere(projectId, selector);
|
||||||
|
const total = await prisma.contact.count({where});
|
||||||
|
result.totalRequested = total;
|
||||||
|
|
||||||
|
signale.info(
|
||||||
|
`[BULK-CONTACT-PROCESSOR] Processing ${operation} for ${total} contacts (query mode) in project ${projectId}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (total === 0) {
|
||||||
|
await job.updateProgress(100);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cursor-based iteration over matching contacts. We re-evaluate the where clause
|
||||||
|
// each batch (with id < cursor) instead of Prisma's `cursor:` because for `delete`
|
||||||
|
// the rows we just processed disappear — a stable cursor would either skip survivors
|
||||||
|
// or revisit deletions. Sorting by id desc + `id < lastId` is idempotent under either.
|
||||||
|
let lastId: string | undefined;
|
||||||
|
let processedRows = 0;
|
||||||
|
|
||||||
|
// Cap the loop so a runaway query (e.g. growing table) can't spin forever.
|
||||||
|
const maxIterations = Math.ceil(total / BATCH_SIZE) + 50;
|
||||||
|
for (let iter = 0; iter < maxIterations; iter += 1) {
|
||||||
|
const batch = await prisma.contact.findMany({
|
||||||
|
where: {
|
||||||
|
...where,
|
||||||
|
...(lastId ? {id: {...(where.id as object | undefined), lt: lastId}} : {}),
|
||||||
|
},
|
||||||
|
select: {id: true},
|
||||||
|
orderBy: {id: 'desc'},
|
||||||
|
take: BATCH_SIZE,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (batch.length === 0) break;
|
||||||
|
|
||||||
|
const batchIds = batch.map(c => c.id);
|
||||||
|
lastId = batchIds[batchIds.length - 1];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let batchResult: {updated?: number; deleted?: number};
|
const {changed, unchanged} = await applyBatch(projectId, operation, batchIds);
|
||||||
|
result.successCount += changed;
|
||||||
switch (operation) {
|
result.unchangedCount += unchanged;
|
||||||
case 'subscribe':
|
const failed = batchIds.length - changed - unchanged;
|
||||||
batchResult = await ContactService.bulkSubscribe(projectId, batchIds);
|
if (failed > 0) result.failureCount += failed;
|
||||||
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) {
|
} catch (error) {
|
||||||
signale.error(`[BULK-CONTACT-PROCESSOR] Batch failed:`, error);
|
signale.error('[BULK-CONTACT-PROCESSOR] Batch failed:', error);
|
||||||
result.failureCount += batchIds.length;
|
result.failureCount += batchIds.length;
|
||||||
result.errors.push({
|
result.errors.push({
|
||||||
contactId: 'batch',
|
contactId: 'batch',
|
||||||
@@ -78,24 +154,21 @@ export function createBulkContactWorker() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update progress
|
processedRows += batchIds.length;
|
||||||
const progress = Math.round(((i + batchIds.length) / contactIds.length) * 100);
|
await job.updateProgress(Math.min(100, Math.round((processedRows / total) * 100)));
|
||||||
await job.updateProgress(progress);
|
|
||||||
|
if (batch.length < BATCH_SIZE) break;
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
signale.info(
|
||||||
|
`[BULK-CONTACT-PROCESSOR] ${operation} completed: ${result.successCount} succeeded, ${result.failureCount} failed`,
|
||||||
|
);
|
||||||
|
return result;
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
connection: bulkContactQueue.opts.connection,
|
connection: bulkContactQueue.opts.connection,
|
||||||
concurrency: 3, // Process max 3 bulk operations concurrently
|
concurrency: 3,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -721,99 +721,81 @@ export class ContactService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Bulk subscribe contacts
|
* Bulk subscribe contacts
|
||||||
* Updates multiple contacts to subscribed=true in batches
|
* Updates multiple contacts to subscribed=true in batches.
|
||||||
|
* `updated` = contacts flipped from unsubscribed to subscribed.
|
||||||
|
* `unchanged` = contacts that were already subscribed (no-op, not a failure).
|
||||||
*/
|
*/
|
||||||
public static async bulkSubscribe(projectId: string, contactIds: string[]): Promise<{updated: number}> {
|
public static async bulkSubscribe(
|
||||||
// Verify all contacts belong to this project
|
projectId: string,
|
||||||
|
contactIds: string[],
|
||||||
|
): Promise<{updated: number; unchanged: number}> {
|
||||||
const contacts = await prisma.contact.findMany({
|
const contacts = await prisma.contact.findMany({
|
||||||
where: {
|
where: {id: {in: contactIds}, projectId},
|
||||||
id: {in: contactIds},
|
|
||||||
projectId,
|
|
||||||
},
|
|
||||||
select: {id: true, subscribed: true},
|
select: {id: true, subscribed: true},
|
||||||
});
|
});
|
||||||
|
|
||||||
const validIds = contacts.map(c => c.id);
|
if (contacts.length === 0) {
|
||||||
|
return {updated: 0, unchanged: 0};
|
||||||
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);
|
const unsubscribedIds = contacts.filter(c => !c.subscribed).map(c => c.id);
|
||||||
|
const unchanged = contacts.length - unsubscribedIds.length;
|
||||||
|
|
||||||
if (unsubscribedIds.length === 0) {
|
if (unsubscribedIds.length === 0) {
|
||||||
return {updated: 0};
|
return {updated: 0, unchanged};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update in a single query for performance
|
|
||||||
const result = await prisma.contact.updateMany({
|
const result = await prisma.contact.updateMany({
|
||||||
where: {
|
where: {id: {in: unsubscribedIds}, projectId},
|
||||||
id: {in: unsubscribedIds},
|
data: {subscribed: true},
|
||||||
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 => {
|
this.trackEventsSequentially(projectId, 'contact.subscribed', unsubscribedIds).catch(error => {
|
||||||
// Silently ignore errors in tests due to cleanup race conditions
|
|
||||||
if (process.env.NODE_ENV !== 'test') {
|
if (process.env.NODE_ENV !== 'test') {
|
||||||
console.error('[ContactService] Failed to track bulk subscribe events:', error);
|
console.error('[ContactService] Failed to track bulk subscribe events:', error);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return {updated: result.count};
|
return {updated: result.count, unchanged};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Bulk unsubscribe contacts
|
* Bulk unsubscribe contacts.
|
||||||
|
* `updated` = contacts flipped from subscribed to unsubscribed.
|
||||||
|
* `unchanged` = contacts that were already unsubscribed (no-op, not a failure).
|
||||||
*/
|
*/
|
||||||
public static async bulkUnsubscribe(projectId: string, contactIds: string[]): Promise<{updated: number}> {
|
public static async bulkUnsubscribe(
|
||||||
|
projectId: string,
|
||||||
|
contactIds: string[],
|
||||||
|
): Promise<{updated: number; unchanged: number}> {
|
||||||
const contacts = await prisma.contact.findMany({
|
const contacts = await prisma.contact.findMany({
|
||||||
where: {
|
where: {id: {in: contactIds}, projectId},
|
||||||
id: {in: contactIds},
|
|
||||||
projectId,
|
|
||||||
},
|
|
||||||
select: {id: true, subscribed: true},
|
select: {id: true, subscribed: true},
|
||||||
});
|
});
|
||||||
|
|
||||||
const validIds = contacts.map(c => c.id);
|
if (contacts.length === 0) {
|
||||||
|
return {updated: 0, unchanged: 0};
|
||||||
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);
|
const subscribedIds = contacts.filter(c => c.subscribed).map(c => c.id);
|
||||||
|
const unchanged = contacts.length - subscribedIds.length;
|
||||||
|
|
||||||
if (subscribedIds.length === 0) {
|
if (subscribedIds.length === 0) {
|
||||||
return {updated: 0};
|
return {updated: 0, unchanged};
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await prisma.contact.updateMany({
|
const result = await prisma.contact.updateMany({
|
||||||
where: {
|
where: {id: {in: subscribedIds}, projectId},
|
||||||
id: {in: subscribedIds},
|
data: {subscribed: false},
|
||||||
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 => {
|
this.trackEventsSequentially(projectId, 'contact.unsubscribed', subscribedIds).catch(error => {
|
||||||
// Silently ignore errors in tests due to cleanup race conditions
|
|
||||||
if (process.env.NODE_ENV !== 'test') {
|
if (process.env.NODE_ENV !== 'test') {
|
||||||
console.error('[ContactService] Failed to track bulk unsubscribe events:', error);
|
console.error('[ContactService] Failed to track bulk unsubscribe events:', error);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return {updated: result.count};
|
return {updated: result.count, unchanged};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import signale from 'signale';
|
|||||||
import type {
|
import type {
|
||||||
ApiRequestCleanupJobData,
|
ApiRequestCleanupJobData,
|
||||||
BulkContactActionJobData,
|
BulkContactActionJobData,
|
||||||
|
BulkContactActionSelector,
|
||||||
CampaignBatchJobData,
|
CampaignBatchJobData,
|
||||||
ContactImportJobData,
|
ContactImportJobData,
|
||||||
DomainVerificationJobData,
|
DomainVerificationJobData,
|
||||||
@@ -350,12 +351,12 @@ export class QueueService {
|
|||||||
*/
|
*/
|
||||||
public static async queueBulkContactAction(
|
public static async queueBulkContactAction(
|
||||||
projectId: string,
|
projectId: string,
|
||||||
contactIds: string[],
|
selector: BulkContactActionSelector,
|
||||||
operation: 'subscribe' | 'unsubscribe' | 'delete',
|
operation: 'subscribe' | 'unsubscribe' | 'delete',
|
||||||
): Promise<Job<BulkContactActionJobData>> {
|
): Promise<Job<BulkContactActionJobData>> {
|
||||||
return bulkContactQueue.add(
|
return bulkContactQueue.add(
|
||||||
'bulk-contact-action',
|
'bulk-contact-action',
|
||||||
{projectId, contactIds, operation},
|
{projectId, operation, selector},
|
||||||
{
|
{
|
||||||
jobId: `bulk-${operation}-${projectId}-${Date.now()}`,
|
jobId: `bulk-${operation}-${projectId}-${Date.now()}`,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,9 +2,6 @@ import {
|
|||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
Checkbox,
|
Checkbox,
|
||||||
ConfirmDialog,
|
ConfirmDialog,
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -25,14 +22,18 @@ import {KeyValueEditor} from '../../components/KeyValueEditor';
|
|||||||
import {network} from '../../lib/network';
|
import {network} from '../../lib/network';
|
||||||
import {formatRelativeTime} from '../../lib/dateUtils';
|
import {formatRelativeTime} from '../../lib/dateUtils';
|
||||||
import {
|
import {
|
||||||
|
AlertTriangle,
|
||||||
|
Check,
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Edit,
|
Edit,
|
||||||
FileUp,
|
FileUp,
|
||||||
|
Loader2,
|
||||||
Mail,
|
Mail,
|
||||||
MailCheck,
|
MailCheck,
|
||||||
MailX,
|
MailX,
|
||||||
|
Minus,
|
||||||
Plus,
|
Plus,
|
||||||
Search,
|
Search,
|
||||||
Trash2,
|
Trash2,
|
||||||
@@ -61,6 +62,8 @@ export default function ContactsPage() {
|
|||||||
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 [selectedContacts, setSelectedContacts] = useState<Set<string>>(new Set());
|
||||||
|
const [selectAllMatching, setSelectAllMatching] = useState(false);
|
||||||
|
const [excludedContacts, setExcludedContacts] = useState<Set<string>>(new Set());
|
||||||
const [showBulkActionsDialog, setShowBulkActionsDialog] = useState(false);
|
const [showBulkActionsDialog, setShowBulkActionsDialog] = useState(false);
|
||||||
const [bulkOperation, setBulkOperation] = useState<'subscribe' | 'unsubscribe' | 'delete' | null>(null);
|
const [bulkOperation, setBulkOperation] = useState<'subscribe' | 'unsubscribe' | 'delete' | null>(null);
|
||||||
const pageSize = 50;
|
const pageSize = 50;
|
||||||
@@ -87,6 +90,9 @@ export default function ContactsPage() {
|
|||||||
setCursorHistory([undefined]);
|
setCursorHistory([undefined]);
|
||||||
setCurrentPage(0);
|
setCurrentPage(0);
|
||||||
setContacts([]);
|
setContacts([]);
|
||||||
|
setSelectedContacts(new Set());
|
||||||
|
setSelectAllMatching(false);
|
||||||
|
setExcludedContacts(new Set());
|
||||||
}, 350);
|
}, 350);
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [searchInput, search]);
|
}, [searchInput, search]);
|
||||||
@@ -96,9 +102,12 @@ 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
|
// Preserve selection across pages only when "select all matching" is on; otherwise
|
||||||
|
// clear, since per-page id sets stop being meaningful once you've left the page.
|
||||||
|
if (!selectAllMatching) {
|
||||||
|
setSelectedContacts(new Set());
|
||||||
|
}
|
||||||
|
|
||||||
// Store cursor in history if not already there
|
|
||||||
if (cursorHistory.length <= newPage) {
|
if (cursorHistory.length <= newPage) {
|
||||||
setCursorHistory(prev => [...prev, data.cursor]);
|
setCursorHistory(prev => [...prev, data.cursor]);
|
||||||
}
|
}
|
||||||
@@ -111,12 +120,38 @@ 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
|
if (!selectAllMatching) {
|
||||||
|
setSelectedContacts(new Set());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// True when the current page's checkbox should appear "all selected"
|
||||||
|
const allOnPageSelected = contacts.length > 0 && (
|
||||||
|
selectAllMatching
|
||||||
|
? contacts.every(c => !excludedContacts.has(c.id))
|
||||||
|
: selectedContacts.size === contacts.length && contacts.every(c => selectedContacts.has(c.id))
|
||||||
|
);
|
||||||
|
|
||||||
const handleSelectAll = () => {
|
const handleSelectAll = () => {
|
||||||
if (selectedContacts.size === contacts.length && contacts.length > 0) {
|
if (selectAllMatching) {
|
||||||
|
// Toggle: exclude or re-include all on this page
|
||||||
|
if (allOnPageSelected) {
|
||||||
|
setExcludedContacts(prev => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
contacts.forEach(c => next.add(c.id));
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setExcludedContacts(prev => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
contacts.forEach(c => next.delete(c.id));
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (allOnPageSelected) {
|
||||||
setSelectedContacts(new Set());
|
setSelectedContacts(new Set());
|
||||||
} else {
|
} else {
|
||||||
setSelectedContacts(new Set(contacts.map(c => c.id)));
|
setSelectedContacts(new Set(contacts.map(c => c.id)));
|
||||||
@@ -124,15 +159,30 @@ export default function ContactsPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSelectContact = (contactId: string) => {
|
const handleSelectContact = (contactId: string) => {
|
||||||
const newSelected = new Set(selectedContacts);
|
if (selectAllMatching) {
|
||||||
if (newSelected.has(contactId)) {
|
setExcludedContacts(prev => {
|
||||||
newSelected.delete(contactId);
|
const next = new Set(prev);
|
||||||
} else {
|
if (next.has(contactId)) next.delete(contactId);
|
||||||
newSelected.add(contactId);
|
else next.add(contactId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
setSelectedContacts(newSelected);
|
setSelectedContacts(prev => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(contactId)) next.delete(contactId);
|
||||||
|
else next.add(contactId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isContactSelected = (contactId: string) =>
|
||||||
|
selectAllMatching ? !excludedContacts.has(contactId) : selectedContacts.has(contactId);
|
||||||
|
|
||||||
|
const effectiveSelectionCount = selectAllMatching
|
||||||
|
? Math.max(0, totalCount - excludedContacts.size)
|
||||||
|
: selectedContacts.size;
|
||||||
|
|
||||||
const handleBulkAction = (operation: 'subscribe' | 'unsubscribe' | 'delete') => {
|
const handleBulkAction = (operation: 'subscribe' | 'unsubscribe' | 'delete') => {
|
||||||
setBulkOperation(operation);
|
setBulkOperation(operation);
|
||||||
setShowBulkActionsDialog(true);
|
setShowBulkActionsDialog(true);
|
||||||
@@ -140,6 +190,14 @@ export default function ContactsPage() {
|
|||||||
|
|
||||||
const clearSelection = () => {
|
const clearSelection = () => {
|
||||||
setSelectedContacts(new Set());
|
setSelectedContacts(new Set());
|
||||||
|
setSelectAllMatching(false);
|
||||||
|
setExcludedContacts(new Set());
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectAllMatching = () => {
|
||||||
|
setSelectAllMatching(true);
|
||||||
|
setSelectedContacts(new Set());
|
||||||
|
setExcludedContacts(new Set());
|
||||||
};
|
};
|
||||||
|
|
||||||
const promptDelete = (contactId: string) => {
|
const promptDelete = (contactId: string) => {
|
||||||
@@ -172,8 +230,7 @@ export default function ContactsPage() {
|
|||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl sm:text-3xl font-bold text-neutral-900">Contacts</h1>
|
<h1 className="text-2xl sm:text-3xl font-bold text-neutral-900">Contacts</h1>
|
||||||
<p className="text-neutral-500 mt-2 text-sm sm:text-base">
|
<p className="text-neutral-500 mt-2 text-sm sm:text-base">
|
||||||
Manage your email subscribers and their data.{' '}
|
Manage your email subscribers and their data.
|
||||||
{totalCount > 0 ? `${totalCount.toLocaleString()} total contacts` : ''}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
@@ -191,45 +248,68 @@ export default function ContactsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Search */}
|
{/* Contacts Table */}
|
||||||
<div className="relative">
|
<Card>
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-neutral-400" />
|
{/* Contextual header strip: idle = search + count, selecting = bulk actions.
|
||||||
<Input
|
Single fixed-min-height row prevents layout shift as state toggles.
|
||||||
type="text"
|
The select-all-matching link is folded inline into the toolbar. */}
|
||||||
placeholder="Search by email..."
|
<div
|
||||||
value={searchInput}
|
key={effectiveSelectionCount === 0 ? 'idle' : 'selecting'}
|
||||||
onChange={e => setSearchInput(e.target.value)}
|
className="border-b border-neutral-200 px-6 min-h-[68px] flex items-center py-3 motion-safe:animate-in motion-safe:fade-in-0 motion-safe:duration-150"
|
||||||
className="pl-10 pr-10"
|
>
|
||||||
/>
|
{effectiveSelectionCount === 0 ? (
|
||||||
{searchInput && (
|
<div className="flex items-center gap-4 w-full">
|
||||||
<button
|
<div className="relative flex-1">
|
||||||
type="button"
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-neutral-400 pointer-events-none" />
|
||||||
aria-label="Clear search"
|
<Input
|
||||||
onClick={() => {
|
type="text"
|
||||||
setSearchInput('');
|
placeholder="Search by email..."
|
||||||
setSearch('');
|
value={searchInput}
|
||||||
setCursor(undefined);
|
onChange={e => setSearchInput(e.target.value)}
|
||||||
setCursorHistory([undefined]);
|
className="pl-10 pr-9 h-10"
|
||||||
setCurrentPage(0);
|
/>
|
||||||
setContacts([]);
|
{searchInput && (
|
||||||
}}
|
<button
|
||||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-neutral-400 hover:text-neutral-600 transition-colors"
|
type="button"
|
||||||
>
|
aria-label="Clear search"
|
||||||
<X className="h-4 w-4" />
|
onClick={() => {
|
||||||
</button>
|
setSearchInput('');
|
||||||
)}
|
setSearch('');
|
||||||
</div>
|
setCursor(undefined);
|
||||||
|
setCursorHistory([undefined]);
|
||||||
{/* Bulk Actions Toolbar */}
|
setCurrentPage(0);
|
||||||
{selectedContacts.size > 0 && (
|
setContacts([]);
|
||||||
<Card>
|
}}
|
||||||
<CardContent className="pt-6">
|
className="absolute right-2.5 top-1/2 -translate-y-1/2 rounded-sm p-0.5 text-neutral-400 transition-colors hover:text-neutral-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-400"
|
||||||
<div className="flex items-center justify-between">
|
>
|
||||||
<div className="flex items-center gap-4">
|
<X className="h-4 w-4" />
|
||||||
<span className="text-sm font-medium text-neutral-900">
|
</button>
|
||||||
{selectedContacts.size} contact{selectedContacts.size !== 1 ? 's' : ''} selected
|
)}
|
||||||
|
</div>
|
||||||
|
{totalCount > 0 && (
|
||||||
|
<span className="hidden sm:inline text-sm text-neutral-500 tabular-nums whitespace-nowrap">
|
||||||
|
{totalCount.toLocaleString()} {search ? 'matching' : 'total'}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex gap-2">
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-between gap-3 w-full">
|
||||||
|
<div className="flex items-center gap-x-4 gap-y-2 min-w-0 flex-wrap">
|
||||||
|
<span className="text-sm font-medium text-neutral-900 tabular-nums whitespace-nowrap">
|
||||||
|
{effectiveSelectionCount.toLocaleString()} selected
|
||||||
|
</span>
|
||||||
|
{!selectAllMatching && allOnPageSelected && totalCount > contacts.length && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSelectAllMatching}
|
||||||
|
className="text-sm font-medium text-neutral-600 underline-offset-4 transition-colors hover:text-neutral-900 hover:underline focus-visible:outline-none focus-visible:underline focus-visible:text-neutral-900 whitespace-nowrap rounded-sm tabular-nums"
|
||||||
|
>
|
||||||
|
Select all {totalCount.toLocaleString()}
|
||||||
|
{search ? ' matching' : ''}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<div className="hidden sm:block h-5 w-px bg-neutral-200" aria-hidden="true" />
|
||||||
|
<div className="flex gap-1.5">
|
||||||
<Button variant="outline" size="sm" onClick={() => handleBulkAction('subscribe')}>
|
<Button variant="outline" size="sm" onClick={() => handleBulkAction('subscribe')}>
|
||||||
<MailCheck className="h-4 w-4 mr-1.5" />
|
<MailCheck className="h-4 w-4 mr-1.5" />
|
||||||
Subscribe
|
Subscribe
|
||||||
@@ -238,48 +318,50 @@ export default function ContactsPage() {
|
|||||||
<MailX className="h-4 w-4 mr-1.5" />
|
<MailX className="h-4 w-4 mr-1.5" />
|
||||||
Unsubscribe
|
Unsubscribe
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" size="sm" onClick={() => handleBulkAction('delete')}>
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleBulkAction('delete')}
|
||||||
|
className="text-neutral-700 transition-colors hover:bg-red-50 hover:text-red-700 hover:border-red-200"
|
||||||
|
>
|
||||||
<Trash2 className="h-4 w-4 mr-1.5" />
|
<Trash2 className="h-4 w-4 mr-1.5" />
|
||||||
Delete
|
Delete
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="ghost" size="sm" onClick={clearSelection}>
|
<Button
|
||||||
Clear Selection
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={clearSelection}
|
||||||
|
aria-label="Clear selection"
|
||||||
|
className="text-neutral-500 hover:text-neutral-900"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
)}
|
||||||
</Card>
|
</div>
|
||||||
)}
|
<CardContent className="p-0">
|
||||||
|
|
||||||
{/* Contacts Table */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>All Contacts</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
View and manage your contact list.
|
|
||||||
{totalCount > 0 && ` ${totalCount.toLocaleString()} total contacts`}
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{isLoading && contacts.length === 0 ? (
|
{isLoading && contacts.length === 0 ? (
|
||||||
<div className="flex items-center justify-center py-12">
|
<div className="flex items-center justify-center py-16">
|
||||||
<IconSpinner />
|
<IconSpinner />
|
||||||
</div>
|
</div>
|
||||||
) : contacts.length === 0 ? (
|
) : contacts.length === 0 ? (
|
||||||
<EmptyState
|
<div className="px-6 py-12">
|
||||||
icon={Mail}
|
<EmptyState
|
||||||
title={search ? 'No contacts match' : 'No contacts yet'}
|
icon={Mail}
|
||||||
description={search ? 'Try a different search term.' : 'Add contacts to start tracking engagement.'}
|
title={search ? 'No contacts match' : 'No contacts yet'}
|
||||||
action={
|
description={search ? 'Try a different search term.' : 'Add contacts to start tracking engagement.'}
|
||||||
!search ? (
|
action={
|
||||||
<Button onClick={() => setShowCreateDialog(true)}>
|
!search ? (
|
||||||
<Plus className="h-4 w-4" />
|
<Button onClick={() => setShowCreateDialog(true)}>
|
||||||
Add Contact
|
<Plus className="h-4 w-4" />
|
||||||
</Button>
|
Add Contact
|
||||||
) : undefined
|
</Button>
|
||||||
}
|
) : undefined
|
||||||
/>
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{/* Desktop Table View - Hidden on mobile */}
|
{/* Desktop Table View - Hidden on mobile */}
|
||||||
@@ -289,7 +371,7 @@ export default function ContactsPage() {
|
|||||||
<tr>
|
<tr>
|
||||||
<th className="px-6 py-3 text-left w-12">
|
<th className="px-6 py-3 text-left w-12">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={selectedContacts.size === contacts.length && contacts.length > 0}
|
checked={allOnPageSelected}
|
||||||
onCheckedChange={handleSelectAll}
|
onCheckedChange={handleSelectAll}
|
||||||
/>
|
/>
|
||||||
</th>
|
</th>
|
||||||
@@ -312,7 +394,7 @@ export default function ContactsPage() {
|
|||||||
<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">
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={selectedContacts.has(contact.id)}
|
checked={isContactSelected(contact.id)}
|
||||||
onCheckedChange={() => handleSelectContact(contact.id)}
|
onCheckedChange={() => handleSelectContact(contact.id)}
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
@@ -360,7 +442,7 @@ export default function ContactsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mobile Card View - Only visible on mobile */}
|
{/* Mobile Card View - Only visible on mobile */}
|
||||||
<div className="md:hidden space-y-3">
|
<div className="md:hidden space-y-3 p-4">
|
||||||
{contacts.map(contact => (
|
{contacts.map(contact => (
|
||||||
<div
|
<div
|
||||||
key={contact.id}
|
key={contact.id}
|
||||||
@@ -405,7 +487,7 @@ export default function ContactsPage() {
|
|||||||
|
|
||||||
{/* Pagination Controls */}
|
{/* Pagination Controls */}
|
||||||
{(currentPage > 0 || data?.hasMore) && (
|
{(currentPage > 0 || data?.hasMore) && (
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mt-6 pt-6 border-t border-neutral-200">
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 px-6 py-4 border-t border-neutral-200">
|
||||||
<div className="text-xs sm:text-sm text-neutral-600 text-center sm:text-left">
|
<div className="text-xs sm:text-sm text-neutral-600 text-center sm:text-left">
|
||||||
Showing <span className="font-medium text-neutral-900">{currentPage * pageSize + 1}</span> to{' '}
|
Showing <span className="font-medium text-neutral-900">{currentPage * pageSize + 1}</span> to{' '}
|
||||||
<span className="font-medium text-neutral-900">{currentPage * pageSize + contacts.length}</span>
|
<span className="font-medium text-neutral-900">{currentPage * pageSize + contacts.length}</span>
|
||||||
@@ -455,7 +537,12 @@ export default function ContactsPage() {
|
|||||||
open={showBulkActionsDialog}
|
open={showBulkActionsDialog}
|
||||||
onOpenChange={setShowBulkActionsDialog}
|
onOpenChange={setShowBulkActionsDialog}
|
||||||
operation={bulkOperation}
|
operation={bulkOperation}
|
||||||
contactIds={Array.from(selectedContacts)}
|
selector={
|
||||||
|
selectAllMatching
|
||||||
|
? {mode: 'query', filter: search ? {search} : {}, excludeIds: Array.from(excludedContacts)}
|
||||||
|
: {mode: 'ids', contactIds: Array.from(selectedContacts)}
|
||||||
|
}
|
||||||
|
targetCount={effectiveSelectionCount}
|
||||||
onSuccess={() => {
|
onSuccess={() => {
|
||||||
mutate();
|
mutate();
|
||||||
clearSelection();
|
clearSelection();
|
||||||
@@ -885,23 +972,32 @@ function ImportContactsDialog({open, onOpenChange, onSuccess}: ImportContactsDia
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type BulkSelector =
|
||||||
|
| {mode: 'ids'; contactIds: string[]}
|
||||||
|
| {mode: 'query'; filter: {search?: string}; excludeIds: string[]};
|
||||||
|
|
||||||
interface BulkActionsDialogProps {
|
interface BulkActionsDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
operation: 'subscribe' | 'unsubscribe' | 'delete' | null;
|
operation: 'subscribe' | 'unsubscribe' | 'delete' | null;
|
||||||
contactIds: string[];
|
selector: BulkSelector;
|
||||||
|
targetCount: number;
|
||||||
onSuccess: () => void;
|
onSuccess: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface BulkActionResult {
|
interface BulkActionResult {
|
||||||
operation: string;
|
operation: 'subscribe' | 'unsubscribe' | 'delete';
|
||||||
totalRequested: number;
|
totalRequested: number;
|
||||||
|
/** Contacts whose state was actually changed by this run. */
|
||||||
successCount: number;
|
successCount: number;
|
||||||
|
/** Subscribe/unsubscribe only: contacts that were already in the target state. */
|
||||||
|
unchangedCount: number;
|
||||||
|
/** Contacts that errored or weren't found. */
|
||||||
failureCount: number;
|
failureCount: number;
|
||||||
errors: Array<{contactId: string; email: string; error: string}>;
|
errors: Array<{contactId: string; email: string; error: string}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function BulkActionsDialog({open, onOpenChange, operation, contactIds, onSuccess}: BulkActionsDialogProps) {
|
function BulkActionsDialog({open, onOpenChange, operation, selector, targetCount, onSuccess}: BulkActionsDialogProps) {
|
||||||
const [, setJobId] = useState<string | null>(null);
|
const [, setJobId] = useState<string | null>(null);
|
||||||
const [isProcessing, setIsProcessing] = useState(false);
|
const [isProcessing, setIsProcessing] = useState(false);
|
||||||
const [progress, setProgress] = useState(0);
|
const [progress, setProgress] = useState(0);
|
||||||
@@ -949,8 +1045,7 @@ function BulkActionsDialog({open, onOpenChange, operation, contactIds, onSuccess
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (response.result) {
|
if (response.result) {
|
||||||
const {successCount, failureCount} = response.result;
|
toast.success(buildToastSummary(response.result));
|
||||||
toast.success(`Completed: ${successCount} succeeded${failureCount > 0 ? `, ${failureCount} failed` : ''}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onSuccess();
|
onSuccess();
|
||||||
@@ -988,7 +1083,7 @@ function BulkActionsDialog({open, onOpenChange, operation, contactIds, onSuccess
|
|||||||
const data = await network.fetch<{jobId: string; message: string}, typeof ContactSchemas.bulkAction>(
|
const data = await network.fetch<{jobId: string; message: string}, typeof ContactSchemas.bulkAction>(
|
||||||
'POST',
|
'POST',
|
||||||
endpoint,
|
endpoint,
|
||||||
{contactIds},
|
selector,
|
||||||
);
|
);
|
||||||
|
|
||||||
setJobId(data.jobId);
|
setJobId(data.jobId);
|
||||||
@@ -1019,103 +1114,97 @@ function BulkActionsDialog({open, onOpenChange, operation, contactIds, onSuccess
|
|||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getOperationLabel = () => {
|
const copy = getOperationCopy(operation);
|
||||||
switch (operation) {
|
|
||||||
case 'subscribe':
|
|
||||||
return 'Subscribe';
|
|
||||||
case 'unsubscribe':
|
|
||||||
return 'Unsubscribe';
|
|
||||||
case 'delete':
|
|
||||||
return 'Delete';
|
|
||||||
default:
|
|
||||||
return 'Process';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getOperationColor = () => {
|
const isQueueing = status === 'processing' && progress === 0;
|
||||||
switch (operation) {
|
const dialogTitle =
|
||||||
case 'subscribe':
|
status === 'completed'
|
||||||
return 'green';
|
? copy.completedTitle
|
||||||
case 'unsubscribe':
|
: status === 'processing'
|
||||||
return 'yellow';
|
? copy.progressTitle
|
||||||
case 'delete':
|
: status === 'failed'
|
||||||
return 'red';
|
? copy.failedTitle
|
||||||
default:
|
: copy.title;
|
||||||
return 'blue';
|
|
||||||
}
|
const handleRetry = () => {
|
||||||
|
setErrorMessage(null);
|
||||||
|
setStatus('idle');
|
||||||
|
void handleConfirm();
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Dialog open={open} onOpenChange={handleClose}>
|
<Dialog open={open} onOpenChange={handleClose}>
|
||||||
<DialogContent className="sm:max-w-lg">
|
<DialogContent className="sm:max-w-md">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{getOperationLabel()} Contacts</DialogTitle>
|
<DialogTitle className="transition-colors">{dialogTitle}</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{status === 'idle' && (
|
{status === 'idle' && (
|
||||||
<div className="space-y-1">
|
<div className="space-y-3 motion-safe:animate-in motion-safe:fade-in-50 motion-safe:duration-200">
|
||||||
<p className="text-sm text-neutral-700">
|
<p className="text-sm text-neutral-700 leading-relaxed">
|
||||||
{operation === 'delete' ? 'Permanently delete' : operation === 'subscribe' ? 'Subscribe' : 'Unsubscribe'}{' '}
|
{copy.confirmVerb}{' '}
|
||||||
<span className="font-medium text-neutral-900">{contactIds.length} contact{contactIds.length !== 1 ? 's' : ''}</span>?
|
<span className="font-medium text-neutral-900 tabular-nums">
|
||||||
|
{targetCount.toLocaleString()} contact{targetCount !== 1 ? 's' : ''}
|
||||||
|
</span>
|
||||||
|
?
|
||||||
|
{copy.skipNote && <span className="text-neutral-500"> {copy.skipNote}</span>}
|
||||||
</p>
|
</p>
|
||||||
{operation === 'delete' && (
|
{operation === 'delete' && (
|
||||||
<p className="text-xs text-red-500">This action cannot be undone.</p>
|
<div className="flex items-start gap-2.5 rounded-md border border-red-200 bg-red-50 px-3 py-2.5 text-xs text-red-700">
|
||||||
|
<AlertTriangle className="mt-px h-3.5 w-3.5 shrink-0" strokeWidth={2.25} />
|
||||||
|
<p className="leading-relaxed">
|
||||||
|
<span className="font-medium">This action cannot be undone.</span> Contacts and their event history will be permanently removed.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{selector.mode === 'query' && (
|
||||||
|
<p className="text-xs text-neutral-500 leading-relaxed">
|
||||||
|
Contacts are evaluated when the job runs — any added in the meantime may also be included.
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{status === 'processing' && (
|
{status === 'processing' && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-3 py-1 motion-safe:animate-in motion-safe:fade-in-50 motion-safe:duration-200">
|
||||||
<div className="flex items-center justify-between text-sm">
|
<div className="flex items-baseline justify-between text-sm">
|
||||||
<span className="text-neutral-600">Processing contacts...</span>
|
<span className="flex items-center gap-2 text-neutral-600">
|
||||||
<span className="text-neutral-900 font-medium">{progress}%</span>
|
{isQueueing && <Loader2 className="h-3.5 w-3.5 animate-spin text-neutral-400" />}
|
||||||
</div>
|
<span>
|
||||||
<div className="w-full bg-neutral-200 rounded-full h-1.5">
|
{isQueueing
|
||||||
<div
|
? 'Queued — starting up…'
|
||||||
className="bg-neutral-900 h-1.5 rounded-full transition-all duration-300"
|
: `${copy.processingLabel} ${targetCount.toLocaleString()} contact${targetCount !== 1 ? 's' : ''}`}
|
||||||
style={{width: `${progress}%`}}
|
</span>
|
||||||
/>
|
</span>
|
||||||
</div>
|
<span
|
||||||
</div>
|
className={`tabular-nums font-medium transition-opacity ${
|
||||||
)}
|
isQueueing ? 'text-neutral-400' : 'text-neutral-900'
|
||||||
|
}`}
|
||||||
{status === 'completed' && result && (
|
>
|
||||||
<div className="space-y-3">
|
{progress}%
|
||||||
<div className="flex items-center gap-1.5 text-sm text-neutral-600">
|
|
||||||
<CheckCircle className="h-4 w-4 text-green-600 flex-shrink-0" />
|
|
||||||
<span>
|
|
||||||
<span className="font-medium text-neutral-900">{result.successCount}</span> succeeded
|
|
||||||
{result.failureCount > 0 && (
|
|
||||||
<>, <span className="text-red-600">{result.failureCount}</span> failed</>
|
|
||||||
)}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="relative w-full bg-neutral-100 rounded-full h-1.5 overflow-hidden">
|
||||||
{result.errors && result.errors.length > 0 && (
|
{isQueueing ? (
|
||||||
<div className="max-h-40 overflow-y-auto border border-neutral-200 rounded-md">
|
<div className="absolute inset-y-0 left-0 w-1/3 rounded-full bg-neutral-300 motion-safe:animate-[indeterminate_1.4s_ease-in-out_infinite]" />
|
||||||
<div className="text-xs text-neutral-600">
|
) : (
|
||||||
{result.errors.slice(0, 10).map((error, idx) => (
|
<div
|
||||||
<div key={idx} className="px-3 py-2 border-b border-neutral-100 last:border-0 text-red-600">
|
className="bg-neutral-900 h-full rounded-full transition-[width] duration-500 ease-out"
|
||||||
{error.error}
|
style={{width: `${progress}%`}}
|
||||||
</div>
|
/>
|
||||||
))}
|
)}
|
||||||
{result.errors.length > 10 && (
|
</div>
|
||||||
<div className="px-3 py-2 text-neutral-500">
|
|
||||||
+{result.errors.length - 10} more errors
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{status === 'completed' && result && <BulkResultSummary result={result} />}
|
||||||
|
|
||||||
{status === 'failed' && (
|
{status === 'failed' && (
|
||||||
<div className="flex items-start gap-2 text-sm">
|
<div className="flex items-start gap-2.5 rounded-md border border-red-200 bg-red-50 px-3 py-2.5 text-sm text-red-700 motion-safe:animate-in motion-safe:fade-in-50 motion-safe:duration-200">
|
||||||
<XCircle className="h-4 w-4 text-red-500 mt-0.5 flex-shrink-0" />
|
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" strokeWidth={2.25} />
|
||||||
<p className="text-red-600">{errorMessage || 'Please try again.'}</p>
|
<p className="leading-relaxed">{errorMessage || 'Something went wrong. Please try again.'}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -1132,16 +1221,25 @@ function BulkActionsDialog({open, onOpenChange, operation, contactIds, onSuccess
|
|||||||
disabled={isProcessing}
|
disabled={isProcessing}
|
||||||
variant={operation === 'delete' ? 'destructive' : 'default'}
|
variant={operation === 'delete' ? 'destructive' : 'default'}
|
||||||
>
|
>
|
||||||
{isProcessing ? 'Starting...' : getOperationLabel()}
|
{isProcessing ? 'Starting…' : copy.confirmButton}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : status === 'failed' ? (
|
||||||
|
<>
|
||||||
|
<Button type="button" variant="outline" onClick={handleClose}>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
<Button type="button" onClick={handleRetry} variant={operation === 'delete' ? 'destructive' : 'default'}>
|
||||||
|
Try again
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
) : status === 'completed' ? (
|
|
||||||
<Button type="button" onClick={handleClose}>
|
|
||||||
Close
|
|
||||||
</Button>
|
|
||||||
) : (
|
) : (
|
||||||
<Button type="button" variant="outline" onClick={handleClose}>
|
<Button
|
||||||
Close
|
type="button"
|
||||||
|
onClick={handleClose}
|
||||||
|
variant={status === 'completed' ? 'default' : 'outline'}
|
||||||
|
>
|
||||||
|
{status === 'completed' ? 'Done' : 'Hide'}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
@@ -1152,11 +1250,218 @@ function BulkActionsDialog({open, onOpenChange, operation, contactIds, onSuccess
|
|||||||
open={showCloseConfirmDialog}
|
open={showCloseConfirmDialog}
|
||||||
onOpenChange={setShowCloseConfirmDialog}
|
onOpenChange={setShowCloseConfirmDialog}
|
||||||
onConfirm={confirmClose}
|
onConfirm={confirmClose}
|
||||||
title="Close Operation"
|
title="Hide this dialog?"
|
||||||
description="Operation is still in progress. Are you sure you want to close?"
|
description="The job will keep running in the background. You won't see the result here, but the contacts will still be updated."
|
||||||
confirmText="Close Anyway"
|
confirmText="Hide"
|
||||||
variant="destructive"
|
variant="default"
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface OperationCopy {
|
||||||
|
title: string;
|
||||||
|
progressTitle: string;
|
||||||
|
completedTitle: string;
|
||||||
|
failedTitle: string;
|
||||||
|
confirmVerb: string;
|
||||||
|
confirmButton: string;
|
||||||
|
processingLabel: string;
|
||||||
|
/** Past-tense verb used in result rows: "12 subscribed". */
|
||||||
|
changedVerb: string;
|
||||||
|
/** Result-state noun phrase: "contacts subscribed" — pluralisation handled separately. */
|
||||||
|
summaryNoun: string;
|
||||||
|
/** Past participle for "already X": "already subscribed". null = no skip case. */
|
||||||
|
alreadyState: string | null;
|
||||||
|
/** Note shown next to the confirm prompt for ops with skip semantics. */
|
||||||
|
skipNote: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOperationCopy(operation: 'subscribe' | 'unsubscribe' | 'delete' | null): OperationCopy {
|
||||||
|
switch (operation) {
|
||||||
|
case 'subscribe':
|
||||||
|
return {
|
||||||
|
title: 'Subscribe contacts',
|
||||||
|
progressTitle: 'Subscribing…',
|
||||||
|
completedTitle: 'Subscribed',
|
||||||
|
failedTitle: "Couldn't subscribe contacts",
|
||||||
|
confirmVerb: 'Subscribe',
|
||||||
|
confirmButton: 'Subscribe',
|
||||||
|
processingLabel: 'Subscribing',
|
||||||
|
changedVerb: 'subscribed',
|
||||||
|
summaryNoun: 'subscribed',
|
||||||
|
alreadyState: 'already subscribed',
|
||||||
|
skipNote: 'Already-subscribed contacts will be skipped.',
|
||||||
|
};
|
||||||
|
case 'unsubscribe':
|
||||||
|
return {
|
||||||
|
title: 'Unsubscribe contacts',
|
||||||
|
progressTitle: 'Unsubscribing…',
|
||||||
|
completedTitle: 'Unsubscribed',
|
||||||
|
failedTitle: "Couldn't unsubscribe contacts",
|
||||||
|
confirmVerb: 'Unsubscribe',
|
||||||
|
confirmButton: 'Unsubscribe',
|
||||||
|
processingLabel: 'Unsubscribing',
|
||||||
|
changedVerb: 'unsubscribed',
|
||||||
|
summaryNoun: 'unsubscribed',
|
||||||
|
alreadyState: 'already unsubscribed',
|
||||||
|
skipNote: 'Already-unsubscribed contacts will be skipped.',
|
||||||
|
};
|
||||||
|
case 'delete':
|
||||||
|
return {
|
||||||
|
title: 'Delete contacts',
|
||||||
|
progressTitle: 'Deleting…',
|
||||||
|
completedTitle: 'Deleted',
|
||||||
|
failedTitle: "Couldn't delete contacts",
|
||||||
|
confirmVerb: 'Permanently delete',
|
||||||
|
confirmButton: 'Delete',
|
||||||
|
processingLabel: 'Deleting',
|
||||||
|
changedVerb: 'deleted',
|
||||||
|
summaryNoun: 'removed',
|
||||||
|
alreadyState: null,
|
||||||
|
skipNote: null,
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
title: 'Process contacts',
|
||||||
|
progressTitle: 'Processing…',
|
||||||
|
completedTitle: 'Done',
|
||||||
|
failedTitle: 'Operation failed',
|
||||||
|
confirmVerb: 'Process',
|
||||||
|
confirmButton: 'Process',
|
||||||
|
processingLabel: 'Processing',
|
||||||
|
changedVerb: 'processed',
|
||||||
|
summaryNoun: 'processed',
|
||||||
|
alreadyState: null,
|
||||||
|
skipNote: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildToastSummary(result: BulkActionResult): string {
|
||||||
|
const copy = getOperationCopy(result.operation);
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (result.successCount > 0) parts.push(`${result.successCount.toLocaleString()} ${copy.changedVerb}`);
|
||||||
|
if (result.unchangedCount > 0 && copy.alreadyState) {
|
||||||
|
parts.push(`${result.unchangedCount.toLocaleString()} ${copy.alreadyState}`);
|
||||||
|
}
|
||||||
|
if (result.failureCount > 0) parts.push(`${result.failureCount.toLocaleString()} failed`);
|
||||||
|
if (parts.length === 0) return 'No contacts to update';
|
||||||
|
return parts.join(' · ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function BulkResultSummary({result}: {result: BulkActionResult}) {
|
||||||
|
const copy = getOperationCopy(result.operation);
|
||||||
|
const {successCount, unchangedCount, failureCount} = result;
|
||||||
|
const noChanges = successCount === 0 && failureCount === 0 && unchangedCount > 0;
|
||||||
|
const total = successCount + unchangedCount + failureCount;
|
||||||
|
|
||||||
|
// Build the row list. The "primary" row is the row that represents what the
|
||||||
|
// user actually got — usually the changed count, but when nothing changed we
|
||||||
|
// promote the "already in state" row so the summary still has a clear lead.
|
||||||
|
type Row = {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
count: number;
|
||||||
|
primary?: boolean;
|
||||||
|
tone?: 'default' | 'danger';
|
||||||
|
};
|
||||||
|
const rows: Row[] = [];
|
||||||
|
|
||||||
|
if (noChanges && copy.alreadyState) {
|
||||||
|
rows.push({key: 'already', label: copy.alreadyState, count: unchangedCount, primary: true});
|
||||||
|
} else {
|
||||||
|
rows.push({key: 'changed', label: copy.completedTitle, count: successCount, primary: true});
|
||||||
|
if (unchangedCount > 0 && copy.alreadyState) {
|
||||||
|
rows.push({key: 'already', label: copy.alreadyState, count: unchangedCount});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (failureCount > 0) {
|
||||||
|
rows.push({key: 'failed', label: 'Failed', count: failureCount, tone: 'danger'});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3 motion-safe:animate-in motion-safe:fade-in-50 motion-safe:slide-in-from-bottom-1 motion-safe:duration-300">
|
||||||
|
<div className="rounded-lg border border-neutral-200 overflow-hidden divide-y divide-neutral-100">
|
||||||
|
{rows.map(row => {
|
||||||
|
const isPrimary = !!row.primary;
|
||||||
|
const isDanger = row.tone === 'danger';
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={row.key}
|
||||||
|
className={`flex items-center gap-3 px-4 ${isPrimary ? 'py-4' : 'py-2.5'}`}
|
||||||
|
>
|
||||||
|
{/* Status mark — only on the primary row. Subsequent rows leave the
|
||||||
|
same column blank to keep the labels in a single visual track. */}
|
||||||
|
<div className="w-7 shrink-0 flex items-center">
|
||||||
|
{isPrimary && (
|
||||||
|
<div
|
||||||
|
className={`flex h-7 w-7 items-center justify-center rounded-full ${
|
||||||
|
noChanges ? 'bg-neutral-100 text-neutral-500' : 'bg-neutral-900 text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{noChanges ? (
|
||||||
|
<Minus className="h-3.5 w-3.5" />
|
||||||
|
) : (
|
||||||
|
<Check className="h-3.5 w-3.5" strokeWidth={3} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`flex-1 first-letter:capitalize ${
|
||||||
|
isPrimary
|
||||||
|
? 'text-sm font-medium text-neutral-900'
|
||||||
|
: isDanger
|
||||||
|
? 'text-sm text-red-600'
|
||||||
|
: 'text-sm text-neutral-500'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{row.label}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`tabular-nums tracking-tight ${
|
||||||
|
isPrimary
|
||||||
|
? 'text-2xl font-semibold text-neutral-900 leading-none'
|
||||||
|
: isDanger
|
||||||
|
? 'text-sm font-medium text-red-700'
|
||||||
|
: 'text-sm font-medium text-neutral-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{row.count.toLocaleString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{total > 1 && rows.length > 1 && (
|
||||||
|
<div className="px-4 flex items-baseline justify-between text-xs text-neutral-500">
|
||||||
|
<span>Total processed</span>
|
||||||
|
<span className="tabular-nums font-medium text-neutral-700">{total.toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{result.errors && result.errors.length > 0 && (
|
||||||
|
<details className="group">
|
||||||
|
<summary className="cursor-pointer text-xs text-neutral-500 hover:text-neutral-700 select-none px-4">
|
||||||
|
Show error details ({result.errors.length.toLocaleString()})
|
||||||
|
</summary>
|
||||||
|
<div className="mt-2 max-h-40 overflow-y-auto rounded-md border border-neutral-200 divide-y divide-neutral-100 text-xs">
|
||||||
|
{result.errors.slice(0, 10).map((error, idx) => (
|
||||||
|
<div key={idx} className="px-3 py-2 text-red-700">
|
||||||
|
{error.error}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{result.errors.length > 10 && (
|
||||||
|
<div className="px-3 py-2 text-neutral-500">
|
||||||
|
+{(result.errors.length - 10).toLocaleString()} more
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -164,4 +164,13 @@
|
|||||||
@apply bg-background text-neutral-800 overflow-hidden;
|
@apply bg-background text-neutral-800 overflow-hidden;
|
||||||
font-feature-settings: "rlig" 1, "calt" 1;
|
font-feature-settings: "rlig" 1, "calt" 1;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes indeterminate {
|
||||||
|
0% {
|
||||||
|
transform: translateX(-100%);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: translateX(400%);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -96,9 +96,21 @@ export const ContactSchemas = {
|
|||||||
subscribed: z.boolean().default(true),
|
subscribed: z.boolean().default(true),
|
||||||
data: jsonSchema.optional(),
|
data: jsonSchema.optional(),
|
||||||
}),
|
}),
|
||||||
bulkAction: z.object({
|
bulkAction: z.discriminatedUnion('mode', [
|
||||||
contactIds: z.array(uuid).min(1).max(1000),
|
z.object({
|
||||||
}),
|
mode: z.literal('ids'),
|
||||||
|
contactIds: z.array(uuid).min(1).max(1000),
|
||||||
|
}),
|
||||||
|
z.object({
|
||||||
|
mode: z.literal('query'),
|
||||||
|
filter: z
|
||||||
|
.object({
|
||||||
|
search: z.string().max(255).optional(),
|
||||||
|
})
|
||||||
|
.default({}),
|
||||||
|
excludeIds: z.array(uuid).max(10000).optional(),
|
||||||
|
}),
|
||||||
|
]),
|
||||||
lookup: z.object({
|
lookup: z.object({
|
||||||
emails: z.array(z.string().email()).min(1).max(500),
|
emails: z.array(z.string().email()).min(1).max(500),
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -12,12 +12,23 @@ export interface ContactImportJobData {
|
|||||||
filename: string;
|
filename: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Selector describing which contacts a bulk action should target.
|
||||||
|
* - `ids`: explicit list, hard-capped at 1000.
|
||||||
|
* - `query`: every contact matching the filter, optionally excluding specific ids.
|
||||||
|
* Snapshot semantics: the worker iterates current matches at execution time, so
|
||||||
|
* contacts created after the job is queued may or may not be included.
|
||||||
|
*/
|
||||||
|
export type BulkContactActionSelector =
|
||||||
|
| {mode: 'ids'; contactIds: string[]}
|
||||||
|
| {mode: 'query'; filter: {search?: string}; excludeIds?: string[]};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Job data for bulk contact actions (subscribe, unsubscribe, delete)
|
* Job data for bulk contact actions (subscribe, unsubscribe, delete)
|
||||||
* Used by: bulkContactQueue worker
|
* Used by: bulkContactQueue worker
|
||||||
*/
|
*/
|
||||||
export interface BulkContactActionJobData {
|
export interface BulkContactActionJobData {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
contactIds: string[];
|
|
||||||
operation: 'subscribe' | 'unsubscribe' | 'delete';
|
operation: 'subscribe' | 'unsubscribe' | 'delete';
|
||||||
|
selector: BulkContactActionSelector;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user