From de6335e99999242f81e9eda9a20aeccab80a2de4 Mon Sep 17 00:00:00 2001 From: Dries Augustyns Date: Sun, 10 May 2026 10:40:40 +0200 Subject: [PATCH] feat: implement bulk contact action selector for improved flexibility in bulk operations --- apps/api/src/controllers/Contacts.ts | 111 ++-- apps/api/src/jobs/bulk-contact-processor.ts | 171 +++-- apps/api/src/services/ContactService.ts | 78 +-- apps/api/src/services/QueueService.ts | 5 +- apps/web/src/pages/contacts/index.tsx | 663 ++++++++++++++------ apps/web/src/styles/globals.css | 9 + packages/shared/src/schemas/index.ts | 18 +- packages/types/src/jobs/import.ts | 13 +- 8 files changed, 713 insertions(+), 355 deletions(-) diff --git a/apps/api/src/controllers/Contacts.ts b/apps/api/src/controllers/Contacts.ts index defd857..e7ea7df 100644 --- a/apps/api/src/controllers/Contacts.ts +++ b/apps/api/src/controllers/Contacts.ts @@ -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}`, + }); + } +} diff --git a/apps/api/src/jobs/bulk-contact-processor.ts b/apps/api/src/jobs/bulk-contact-processor.ts index 280df01..a30cf25 100644 --- a/apps/api/src/jobs/bulk-contact-processor.ts +++ b/apps/api/src/jobs/bulk-contact-processor.ts @@ -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): 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( bulkContactQueue.name, async (job: Job) => { - 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, }, ); diff --git a/apps/api/src/services/ContactService.ts b/apps/api/src/services/ContactService.ts index a66fe81..fe07113 100644 --- a/apps/api/src/services/ContactService.ts +++ b/apps/api/src/services/ContactService.ts @@ -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}; } /** diff --git a/apps/api/src/services/QueueService.ts b/apps/api/src/services/QueueService.ts index ef8f38f..af17fa7 100644 --- a/apps/api/src/services/QueueService.ts +++ b/apps/api/src/services/QueueService.ts @@ -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> { return bulkContactQueue.add( 'bulk-contact-action', - {projectId, contactIds, operation}, + {projectId, operation, selector}, { jobId: `bulk-${operation}-${projectId}-${Date.now()}`, }, diff --git a/apps/web/src/pages/contacts/index.tsx b/apps/web/src/pages/contacts/index.tsx index 2b16292..0a4ff88 100644 --- a/apps/web/src/pages/contacts/index.tsx +++ b/apps/web/src/pages/contacts/index.tsx @@ -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(null); const [totalCount, setTotalCount] = useState(0); const [selectedContacts, setSelectedContacts] = useState>(new Set()); + const [selectAllMatching, setSelectAllMatching] = useState(false); + const [excludedContacts, setExcludedContacts] = useState>(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() {

Contacts

- Manage your email subscribers and their data.{' '} - {totalCount > 0 ? `${totalCount.toLocaleString()} total contacts` : ''} + Manage your email subscribers and their data.

@@ -191,45 +248,68 @@ export default function ContactsPage() {
- {/* Search */} -
- - setSearchInput(e.target.value)} - className="pl-10 pr-10" - /> - {searchInput && ( - - )} -
- - {/* Bulk Actions Toolbar */} - {selectedContacts.size > 0 && ( - - -
-
- - {selectedContacts.size} contact{selectedContacts.size !== 1 ? 's' : ''} selected + {/* Contacts Table */} + + {/* 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. */} +
+ {effectiveSelectionCount === 0 ? ( +
+
+ + setSearchInput(e.target.value)} + className="pl-10 pr-9 h-10" + /> + {searchInput && ( + + )} +
+ {totalCount > 0 && ( + + {totalCount.toLocaleString()} {search ? 'matching' : 'total'} -
+ )} +
+ ) : ( +
+
+ + {effectiveSelectionCount.toLocaleString()} selected + + {!selectAllMatching && allOnPageSelected && totalCount > contacts.length && ( + + )} + -
- - - )} - - {/* Contacts Table */} - - - All Contacts - - View and manage your contact list. - {totalCount > 0 && ` ${totalCount.toLocaleString()} total contacts`} - - - + )} +
+ {isLoading && contacts.length === 0 ? ( -
+
) : contacts.length === 0 ? ( - setShowCreateDialog(true)}> - - Add Contact - - ) : undefined - } - /> +
+ setShowCreateDialog(true)}> + + Add Contact + + ) : undefined + } + /> +
) : ( <> {/* Desktop Table View - Hidden on mobile */} @@ -289,7 +371,7 @@ export default function ContactsPage() { 0} + checked={allOnPageSelected} onCheckedChange={handleSelectAll} /> @@ -312,7 +394,7 @@ export default function ContactsPage() { handleSelectContact(contact.id)} /> @@ -360,7 +442,7 @@ export default function ContactsPage() {
{/* Mobile Card View - Only visible on mobile */} -
+
{contacts.map(contact => (
0 || data?.hasMore) && ( -
+
Showing {currentPage * pageSize + 1} to{' '} {currentPage * pageSize + contacts.length} @@ -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(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 ( <> - + - {getOperationLabel()} Contacts + {dialogTitle}
{status === 'idle' && ( -
-

- {operation === 'delete' ? 'Permanently delete' : operation === 'subscribe' ? 'Subscribe' : 'Unsubscribe'}{' '} - {contactIds.length} contact{contactIds.length !== 1 ? 's' : ''}? +

+

+ {copy.confirmVerb}{' '} + + {targetCount.toLocaleString()} contact{targetCount !== 1 ? 's' : ''} + + ? + {copy.skipNote && {copy.skipNote}}

{operation === 'delete' && ( -

This action cannot be undone.

+
+ +

+ This action cannot be undone. Contacts and their event history will be permanently removed. +

+
+ )} + {selector.mode === 'query' && ( +

+ Contacts are evaluated when the job runs — any added in the meantime may also be included. +

)}
)} {status === 'processing' && ( -
-
- Processing contacts... - {progress}% -
-
-
-
-
- )} - - {status === 'completed' && result && ( -
-
- - - {result.successCount} succeeded - {result.failureCount > 0 && ( - <>, {result.failureCount} failed - )} +
+
+ + {isQueueing && } + + {isQueueing + ? 'Queued — starting up…' + : `${copy.processingLabel} ${targetCount.toLocaleString()} contact${targetCount !== 1 ? 's' : ''}`} + + + + {progress}%
- - {result.errors && result.errors.length > 0 && ( -
-
- {result.errors.slice(0, 10).map((error, idx) => ( -
- {error.error} -
- ))} - {result.errors.length > 10 && ( -
- +{result.errors.length - 10} more errors -
- )} -
-
- )} +
+ {isQueueing ? ( +
+ ) : ( +
+ )} +
)} + {status === 'completed' && result && } + {status === 'failed' && ( -
- -

{errorMessage || 'Please try again.'}

+
+ +

{errorMessage || 'Something went wrong. Please try again.'}

)}
@@ -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} + + + ) : status === 'failed' ? ( + <> + + - ) : status === 'completed' ? ( - ) : ( - )} @@ -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 ( +
+
+ {rows.map(row => { + const isPrimary = !!row.primary; + const isDanger = row.tone === 'danger'; + return ( +
+ {/* Status mark — only on the primary row. Subsequent rows leave the + same column blank to keep the labels in a single visual track. */} +
+ {isPrimary && ( +
+ {noChanges ? ( + + ) : ( + + )} +
+ )} +
+
+ {row.label} +
+
+ {row.count.toLocaleString()} +
+
+ ); + })} +
+ + {total > 1 && rows.length > 1 && ( +
+ Total processed + {total.toLocaleString()} +
+ )} + + {result.errors && result.errors.length > 0 && ( +
+ + Show error details ({result.errors.length.toLocaleString()}) + +
+ {result.errors.slice(0, 10).map((error, idx) => ( +
+ {error.error} +
+ ))} + {result.errors.length > 10 && ( +
+ +{(result.errors.length - 10).toLocaleString()} more +
+ )} +
+
+ )} +
+ ); +} + diff --git a/apps/web/src/styles/globals.css b/apps/web/src/styles/globals.css index 5b4dd96..89953f3 100644 --- a/apps/web/src/styles/globals.css +++ b/apps/web/src/styles/globals.css @@ -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%); + } } \ No newline at end of file diff --git a/packages/shared/src/schemas/index.ts b/packages/shared/src/schemas/index.ts index bbe2cb1..5cc0963 100644 --- a/packages/shared/src/schemas/index.ts +++ b/packages/shared/src/schemas/index.ts @@ -96,9 +96,21 @@ export const ContactSchemas = { subscribed: z.boolean().default(true), data: jsonSchema.optional(), }), - bulkAction: z.object({ - contactIds: z.array(uuid).min(1).max(1000), - }), + bulkAction: z.discriminatedUnion('mode', [ + 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({ emails: z.array(z.string().email()).min(1).max(500), }), diff --git a/packages/types/src/jobs/import.ts b/packages/types/src/jobs/import.ts index 6b4eba1..5a1c88b 100644 --- a/packages/types/src/jobs/import.ts +++ b/packages/types/src/jobs/import.ts @@ -12,12 +12,23 @@ export interface ContactImportJobData { 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) * Used by: bulkContactQueue worker */ export interface BulkContactActionJobData { projectId: string; - contactIds: string[]; operation: 'subscribe' | 'unsubscribe' | 'delete'; + selector: BulkContactActionSelector; }