feat: implement bulk contact action selector for improved flexibility in bulk operations

This commit is contained in:
Dries Augustyns
2026-05-10 10:40:40 +02:00
parent 7658a59b5d
commit de6335e999
8 changed files with 713 additions and 355 deletions
+38 -73
View File
@@ -1,6 +1,8 @@
import {Controller, Delete, Get, Middleware, Patch, Post} from '@overnightjs/core';
import type {NextFunction, Request, Response} from 'express';
import multer from 'multer';
import {ContactSchemas} from '@plunk/shared';
import type {BulkContactActionSelector} from '@plunk/types';
import signale from 'signale';
import {requireAuth, requireEmailVerified} from '../middleware/auth.js';
import {ContactService} from '../services/ContactService.js';
@@ -424,31 +426,7 @@ export class Contacts {
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async bulkSubscribe(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth;
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',
});
}
return queueBulkAction(req, res, 'subscribe');
}
/**
@@ -459,30 +437,7 @@ export class Contacts {
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async bulkUnsubscribe(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth;
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',
});
}
return queueBulkAction(req, res, 'unsubscribe');
}
/**
@@ -493,30 +448,7 @@ export class Contacts {
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async bulkDelete(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth;
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',
});
}
return queueBulkAction(req, res, '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}`,
});
}
}
+122 -49
View File
@@ -3,73 +3,149 @@
* 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 signale from 'signale';
import {prisma} from '../database/prisma.js';
import {ContactService} from '../services/ContactService.js';
import {bulkContactQueue} from '../services/QueueService.js';
const BATCH_SIZE = 100; // Process contacts in batches of 100
const BATCH_SIZE = 100;
interface BulkActionResult {
operation: 'subscribe' | 'unsubscribe' | 'delete';
totalRequested: number;
/** Contacts whose state was actually changed by this run. */
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;
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() {
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 {projectId, operation, selector} = job.data;
const result: BulkActionResult = {
operation,
totalRequested: contactIds.length,
totalRequested: 0,
successCount: 0,
unchangedCount: 0,
failureCount: 0,
errors: [],
};
try {
// Process contacts in batches
if (selector.mode === 'ids') {
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) {
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 {
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
}
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);
signale.error('[BULK-CONTACT-PROCESSOR] Batch failed:', error);
result.failureCount += batchIds.length;
result.errors.push({
contactId: 'batch',
@@ -78,24 +154,21 @@ export function createBulkContactWorker() {
});
}
// Update progress
const progress = Math.round(((i + batchIds.length) / contactIds.length) * 100);
await job.updateProgress(progress);
processedRows += batchIds.length;
await job.updateProgress(Math.min(100, Math.round((processedRows / total) * 100)));
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,
concurrency: 3, // Process max 3 bulk operations concurrently
concurrency: 3,
},
);
+30 -48
View File
@@ -721,99 +721,81 @@ export class ContactService {
/**
* 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}> {
// Verify all contacts belong to this project
public static async bulkSubscribe(
projectId: string,
contactIds: string[],
): Promise<{updated: number; unchanged: number}> {
const contacts = await prisma.contact.findMany({
where: {
id: {in: contactIds},
projectId,
},
where: {id: {in: contactIds}, projectId},
select: {id: true, subscribed: true},
});
const validIds = contacts.map(c => c.id);
if (validIds.length === 0) {
return {updated: 0};
if (contacts.length === 0) {
return {updated: 0, unchanged: 0};
}
// Only update contacts that are currently unsubscribed
const unsubscribedIds = contacts.filter(c => !c.subscribed).map(c => c.id);
const unchanged = contacts.length - unsubscribedIds.length;
if (unsubscribedIds.length === 0) {
return {updated: 0};
return {updated: 0, unchanged};
}
// Update in a single query for performance
const result = await prisma.contact.updateMany({
where: {
id: {in: unsubscribedIds},
projectId,
},
data: {
subscribed: true,
},
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};
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({
where: {
id: {in: contactIds},
projectId,
},
where: {id: {in: contactIds}, projectId},
select: {id: true, subscribed: true},
});
const validIds = contacts.map(c => c.id);
if (validIds.length === 0) {
return {updated: 0};
if (contacts.length === 0) {
return {updated: 0, unchanged: 0};
}
// Only update contacts that are currently subscribed
const subscribedIds = contacts.filter(c => c.subscribed).map(c => c.id);
const unchanged = contacts.length - subscribedIds.length;
if (subscribedIds.length === 0) {
return {updated: 0};
return {updated: 0, unchanged};
}
const result = await prisma.contact.updateMany({
where: {
id: {in: subscribedIds},
projectId,
},
data: {
subscribed: false,
},
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};
return {updated: result.count, unchanged};
}
/**
+3 -2
View File
@@ -5,6 +5,7 @@ import signale from 'signale';
import type {
ApiRequestCleanupJobData,
BulkContactActionJobData,
BulkContactActionSelector,
CampaignBatchJobData,
ContactImportJobData,
DomainVerificationJobData,
@@ -350,12 +351,12 @@ export class QueueService {
*/
public static async queueBulkContactAction(
projectId: string,
contactIds: string[],
selector: BulkContactActionSelector,
operation: 'subscribe' | 'unsubscribe' | 'delete',
): Promise<Job<BulkContactActionJobData>> {
return bulkContactQueue.add(
'bulk-contact-action',
{projectId, contactIds, operation},
{projectId, operation, selector},
{
jobId: `bulk-${operation}-${projectId}-${Date.now()}`,
},
+484 -179
View File
@@ -2,9 +2,6 @@ import {
Button,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
Checkbox,
ConfirmDialog,
Dialog,
@@ -25,14 +22,18 @@ import {KeyValueEditor} from '../../components/KeyValueEditor';
import {network} from '../../lib/network';
import {formatRelativeTime} from '../../lib/dateUtils';
import {
AlertTriangle,
Check,
CheckCircle,
ChevronLeft,
ChevronRight,
Edit,
FileUp,
Loader2,
Mail,
MailCheck,
MailX,
Minus,
Plus,
Search,
Trash2,
@@ -61,6 +62,8 @@ export default function ContactsPage() {
const [contactToDelete, setContactToDelete] = useState<string | null>(null);
const [totalCount, setTotalCount] = useState<number>(0);
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 [bulkOperation, setBulkOperation] = useState<'subscribe' | 'unsubscribe' | 'delete' | null>(null);
const pageSize = 50;
@@ -87,6 +90,9 @@ export default function ContactsPage() {
setCursorHistory([undefined]);
setCurrentPage(0);
setContacts([]);
setSelectedContacts(new Set());
setSelectAllMatching(false);
setExcludedContacts(new Set());
}, 350);
return () => clearTimeout(timer);
}, [searchInput, search]);
@@ -96,9 +102,12 @@ export default function ContactsPage() {
const newPage = currentPage + 1;
setCursor(data.cursor);
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) {
setCursorHistory(prev => [...prev, data.cursor]);
}
@@ -111,12 +120,38 @@ export default function ContactsPage() {
const previousCursor = cursorHistory[newPage];
setCursor(previousCursor);
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 = () => {
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());
} else {
setSelectedContacts(new Set(contacts.map(c => c.id)));
@@ -124,15 +159,30 @@ export default function ContactsPage() {
};
const handleSelectContact = (contactId: string) => {
const newSelected = new Set(selectedContacts);
if (newSelected.has(contactId)) {
newSelected.delete(contactId);
} else {
newSelected.add(contactId);
if (selectAllMatching) {
setExcludedContacts(prev => {
const next = new Set(prev);
if (next.has(contactId)) next.delete(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') => {
setBulkOperation(operation);
setShowBulkActionsDialog(true);
@@ -140,6 +190,14 @@ export default function ContactsPage() {
const clearSelection = () => {
setSelectedContacts(new Set());
setSelectAllMatching(false);
setExcludedContacts(new Set());
};
const handleSelectAllMatching = () => {
setSelectAllMatching(true);
setSelectedContacts(new Set());
setExcludedContacts(new Set());
};
const promptDelete = (contactId: string) => {
@@ -172,8 +230,7 @@ export default function ContactsPage() {
<div>
<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">
Manage your email subscribers and their data.{' '}
{totalCount > 0 ? `${totalCount.toLocaleString()} total contacts` : ''}
Manage your email subscribers and their data.
</p>
</div>
<div className="flex gap-2">
@@ -191,45 +248,68 @@ export default function ContactsPage() {
</div>
</div>
{/* Search */}
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-neutral-400" />
<Input
type="text"
placeholder="Search by email..."
value={searchInput}
onChange={e => setSearchInput(e.target.value)}
className="pl-10 pr-10"
/>
{searchInput && (
<button
type="button"
aria-label="Clear search"
onClick={() => {
setSearchInput('');
setSearch('');
setCursor(undefined);
setCursorHistory([undefined]);
setCurrentPage(0);
setContacts([]);
}}
className="absolute right-3 top-1/2 -translate-y-1/2 text-neutral-400 hover:text-neutral-600 transition-colors"
>
<X className="h-4 w-4" />
</button>
)}
</div>
{/* Bulk Actions Toolbar */}
{selectedContacts.size > 0 && (
<Card>
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<span className="text-sm font-medium text-neutral-900">
{selectedContacts.size} contact{selectedContacts.size !== 1 ? 's' : ''} selected
{/* Contacts Table */}
<Card>
{/* Contextual header strip: idle = search + count, selecting = bulk actions.
Single fixed-min-height row prevents layout shift as state toggles.
The select-all-matching link is folded inline into the toolbar. */}
<div
key={effectiveSelectionCount === 0 ? 'idle' : 'selecting'}
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"
>
{effectiveSelectionCount === 0 ? (
<div className="flex items-center gap-4 w-full">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-neutral-400 pointer-events-none" />
<Input
type="text"
placeholder="Search by email..."
value={searchInput}
onChange={e => setSearchInput(e.target.value)}
className="pl-10 pr-9 h-10"
/>
{searchInput && (
<button
type="button"
aria-label="Clear search"
onClick={() => {
setSearchInput('');
setSearch('');
setCursor(undefined);
setCursorHistory([undefined]);
setCurrentPage(0);
setContacts([]);
}}
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"
>
<X className="h-4 w-4" />
</button>
)}
</div>
{totalCount > 0 && (
<span className="hidden sm:inline text-sm text-neutral-500 tabular-nums whitespace-nowrap">
{totalCount.toLocaleString()} {search ? 'matching' : 'total'}
</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')}>
<MailCheck className="h-4 w-4 mr-1.5" />
Subscribe
@@ -238,48 +318,50 @@ export default function ContactsPage() {
<MailX className="h-4 w-4 mr-1.5" />
Unsubscribe
</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" />
Delete
</Button>
</div>
</div>
<Button variant="ghost" size="sm" onClick={clearSelection}>
Clear Selection
<Button
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>
</div>
</CardContent>
</Card>
)}
{/* Contacts Table */}
<Card>
<CardHeader>
<CardTitle>All Contacts</CardTitle>
<CardDescription>
View and manage your contact list.
{totalCount > 0 && ` ${totalCount.toLocaleString()} total contacts`}
</CardDescription>
</CardHeader>
<CardContent>
)}
</div>
<CardContent className="p-0">
{isLoading && contacts.length === 0 ? (
<div className="flex items-center justify-center py-12">
<div className="flex items-center justify-center py-16">
<IconSpinner />
</div>
) : contacts.length === 0 ? (
<EmptyState
icon={Mail}
title={search ? 'No contacts match' : 'No contacts yet'}
description={search ? 'Try a different search term.' : 'Add contacts to start tracking engagement.'}
action={
!search ? (
<Button onClick={() => setShowCreateDialog(true)}>
<Plus className="h-4 w-4" />
Add Contact
</Button>
) : undefined
}
/>
<div className="px-6 py-12">
<EmptyState
icon={Mail}
title={search ? 'No contacts match' : 'No contacts yet'}
description={search ? 'Try a different search term.' : 'Add contacts to start tracking engagement.'}
action={
!search ? (
<Button onClick={() => setShowCreateDialog(true)}>
<Plus className="h-4 w-4" />
Add Contact
</Button>
) : undefined
}
/>
</div>
) : (
<>
{/* Desktop Table View - Hidden on mobile */}
@@ -289,7 +371,7 @@ export default function ContactsPage() {
<tr>
<th className="px-6 py-3 text-left w-12">
<Checkbox
checked={selectedContacts.size === contacts.length && contacts.length > 0}
checked={allOnPageSelected}
onCheckedChange={handleSelectAll}
/>
</th>
@@ -312,7 +394,7 @@ export default function ContactsPage() {
<tr key={contact.id} className="hover:bg-neutral-50 transition-colors">
<td className="px-6 py-4 whitespace-nowrap">
<Checkbox
checked={selectedContacts.has(contact.id)}
checked={isContactSelected(contact.id)}
onCheckedChange={() => handleSelectContact(contact.id)}
/>
</td>
@@ -360,7 +442,7 @@ export default function ContactsPage() {
</div>
{/* 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 => (
<div
key={contact.id}
@@ -405,7 +487,7 @@ export default function ContactsPage() {
{/* Pagination Controls */}
{(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">
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>
@@ -455,7 +537,12 @@ export default function ContactsPage() {
open={showBulkActionsDialog}
onOpenChange={setShowBulkActionsDialog}
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={() => {
mutate();
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 {
open: boolean;
onOpenChange: (open: boolean) => void;
operation: 'subscribe' | 'unsubscribe' | 'delete' | null;
contactIds: string[];
selector: BulkSelector;
targetCount: number;
onSuccess: () => void;
}
interface BulkActionResult {
operation: string;
operation: 'subscribe' | 'unsubscribe' | 'delete';
totalRequested: number;
/** Contacts whose state was actually changed by this run. */
successCount: number;
/** Subscribe/unsubscribe only: contacts that were already in the target state. */
unchangedCount: number;
/** Contacts that errored or weren't found. */
failureCount: number;
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 [isProcessing, setIsProcessing] = useState(false);
const [progress, setProgress] = useState(0);
@@ -949,8 +1045,7 @@ function BulkActionsDialog({open, onOpenChange, operation, contactIds, onSuccess
}
if (response.result) {
const {successCount, failureCount} = response.result;
toast.success(`Completed: ${successCount} succeeded${failureCount > 0 ? `, ${failureCount} failed` : ''}`);
toast.success(buildToastSummary(response.result));
}
onSuccess();
@@ -988,7 +1083,7 @@ function BulkActionsDialog({open, onOpenChange, operation, contactIds, onSuccess
const data = await network.fetch<{jobId: string; message: string}, typeof ContactSchemas.bulkAction>(
'POST',
endpoint,
{contactIds},
selector,
);
setJobId(data.jobId);
@@ -1019,103 +1114,97 @@ function BulkActionsDialog({open, onOpenChange, operation, contactIds, onSuccess
onOpenChange(false);
};
const getOperationLabel = () => {
switch (operation) {
case 'subscribe':
return 'Subscribe';
case 'unsubscribe':
return 'Unsubscribe';
case 'delete':
return 'Delete';
default:
return 'Process';
}
};
const copy = getOperationCopy(operation);
const getOperationColor = () => {
switch (operation) {
case 'subscribe':
return 'green';
case 'unsubscribe':
return 'yellow';
case 'delete':
return 'red';
default:
return 'blue';
}
const isQueueing = status === 'processing' && progress === 0;
const dialogTitle =
status === 'completed'
? copy.completedTitle
: status === 'processing'
? copy.progressTitle
: status === 'failed'
? copy.failedTitle
: copy.title;
const handleRetry = () => {
setErrorMessage(null);
setStatus('idle');
void handleConfirm();
};
return (
<>
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-lg">
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{getOperationLabel()} Contacts</DialogTitle>
<DialogTitle className="transition-colors">{dialogTitle}</DialogTitle>
</DialogHeader>
<div className="space-y-4">
{status === 'idle' && (
<div className="space-y-1">
<p className="text-sm text-neutral-700">
{operation === 'delete' ? 'Permanently delete' : operation === 'subscribe' ? 'Subscribe' : 'Unsubscribe'}{' '}
<span className="font-medium text-neutral-900">{contactIds.length} contact{contactIds.length !== 1 ? 's' : ''}</span>?
<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 leading-relaxed">
{copy.confirmVerb}{' '}
<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>
{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>
)}
{status === 'processing' && (
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="text-neutral-600">Processing contacts...</span>
<span className="text-neutral-900 font-medium">{progress}%</span>
</div>
<div className="w-full bg-neutral-200 rounded-full h-1.5">
<div
className="bg-neutral-900 h-1.5 rounded-full transition-all duration-300"
style={{width: `${progress}%`}}
/>
</div>
</div>
)}
{status === 'completed' && result && (
<div className="space-y-3">
<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</>
)}
<div className="space-y-3 py-1 motion-safe:animate-in motion-safe:fade-in-50 motion-safe:duration-200">
<div className="flex items-baseline justify-between text-sm">
<span className="flex items-center gap-2 text-neutral-600">
{isQueueing && <Loader2 className="h-3.5 w-3.5 animate-spin text-neutral-400" />}
<span>
{isQueueing
? 'Queued — starting up…'
: `${copy.processingLabel} ${targetCount.toLocaleString()} contact${targetCount !== 1 ? 's' : ''}`}
</span>
</span>
<span
className={`tabular-nums font-medium transition-opacity ${
isQueueing ? 'text-neutral-400' : 'text-neutral-900'
}`}
>
{progress}%
</span>
</div>
{result.errors && result.errors.length > 0 && (
<div className="max-h-40 overflow-y-auto border border-neutral-200 rounded-md">
<div className="text-xs text-neutral-600">
{result.errors.slice(0, 10).map((error, idx) => (
<div key={idx} className="px-3 py-2 border-b border-neutral-100 last:border-0 text-red-600">
{error.error}
</div>
))}
{result.errors.length > 10 && (
<div className="px-3 py-2 text-neutral-500">
+{result.errors.length - 10} more errors
</div>
)}
</div>
</div>
)}
<div className="relative w-full bg-neutral-100 rounded-full h-1.5 overflow-hidden">
{isQueueing ? (
<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="bg-neutral-900 h-full rounded-full transition-[width] duration-500 ease-out"
style={{width: `${progress}%`}}
/>
)}
</div>
</div>
)}
{status === 'completed' && result && <BulkResultSummary result={result} />}
{status === 'failed' && (
<div className="flex items-start gap-2 text-sm">
<XCircle className="h-4 w-4 text-red-500 mt-0.5 flex-shrink-0" />
<p className="text-red-600">{errorMessage || 'Please try again.'}</p>
<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">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" strokeWidth={2.25} />
<p className="leading-relaxed">{errorMessage || 'Something went wrong. Please try again.'}</p>
</div>
)}
</div>
@@ -1132,16 +1221,25 @@ function BulkActionsDialog({open, onOpenChange, operation, contactIds, onSuccess
disabled={isProcessing}
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>
</>
) : status === 'completed' ? (
<Button type="button" onClick={handleClose}>
Close
</Button>
) : (
<Button type="button" variant="outline" onClick={handleClose}>
Close
<Button
type="button"
onClick={handleClose}
variant={status === 'completed' ? 'default' : 'outline'}
>
{status === 'completed' ? 'Done' : 'Hide'}
</Button>
)}
</DialogFooter>
@@ -1152,11 +1250,218 @@ function BulkActionsDialog({open, onOpenChange, operation, contactIds, onSuccess
open={showCloseConfirmDialog}
onOpenChange={setShowCloseConfirmDialog}
onConfirm={confirmClose}
title="Close Operation"
description="Operation is still in progress. Are you sure you want to close?"
confirmText="Close Anyway"
variant="destructive"
title="Hide this dialog?"
description="The job will keep running in the background. You won't see the result here, but the contacts will still be updated."
confirmText="Hide"
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>
);
}
+9
View File
@@ -164,4 +164,13 @@
@apply bg-background text-neutral-800 overflow-hidden;
font-feature-settings: "rlig" 1, "calt" 1;
}
}
@keyframes indeterminate {
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(400%);
}
}