From bb72911fa25e3a204095e74c07efbc261154ac30 Mon Sep 17 00:00:00 2001 From: Dries Augustyns Date: Fri, 1 May 2026 19:44:48 +0200 Subject: [PATCH] feat: enhance contact addition with bulk email lookup and subscription options --- apps/api/src/controllers/Contacts.ts | 24 ++ apps/api/src/controllers/Segments.ts | 4 +- apps/api/src/services/ContactService.ts | 17 ++ apps/api/src/services/SegmentService.ts | 27 +- apps/web/src/components/ContactPicker.tsx | 330 ++++++++++++++++------ apps/web/src/pages/segments/[id].tsx | 40 ++- apps/web/src/pages/segments/new.tsx | 12 +- packages/shared/src/schemas/index.ts | 5 + 8 files changed, 334 insertions(+), 125 deletions(-) diff --git a/apps/api/src/controllers/Contacts.ts b/apps/api/src/controllers/Contacts.ts index 6e662c1..defd857 100644 --- a/apps/api/src/controllers/Contacts.ts +++ b/apps/api/src/controllers/Contacts.ts @@ -273,6 +273,30 @@ export class Contacts { }); } + /** + * POST /contacts/lookup + * Bulk-check which emails already exist in the project (max 500) + */ + @Post('lookup') + @Middleware([requireAuth, requireEmailVerified]) + @CatchAsync + public async lookup(req: Request, res: Response, _next: NextFunction) { + const auth = res.locals.auth; + const {emails} = req.body as {emails: string[]}; + + if (!Array.isArray(emails) || emails.length === 0) { + return res.status(400).json({error: 'emails must be a non-empty array'}); + } + + if (emails.length > 500) { + return res.status(400).json({error: 'Maximum 500 emails per lookup'}); + } + + const result = await ContactService.lookup(auth.projectId!, emails); + + return res.status(200).json(result); + } + /** * POST /contacts/import * Import contacts from CSV file diff --git a/apps/api/src/controllers/Segments.ts b/apps/api/src/controllers/Segments.ts index afe37aa..4f9696e 100644 --- a/apps/api/src/controllers/Segments.ts +++ b/apps/api/src/controllers/Segments.ts @@ -155,7 +155,7 @@ export class Segments { public async addMembers(req: Request, res: Response, _next: NextFunction) { const auth = res.locals.auth; const segmentId = req.params.id; - const {emails} = req.body; + const {emails, createMissing, subscribed} = req.body as {emails: string[]; createMissing?: boolean; subscribed?: boolean}; if (!segmentId) { return res.status(400).json({error: 'Segment ID is required'}); @@ -165,7 +165,7 @@ export class Segments { return res.status(400).json({error: 'emails must be a non-empty array'}); } - const result = await SegmentService.addContacts(auth.projectId!, segmentId, emails); + const result = await SegmentService.addContacts(auth.projectId!, segmentId, emails, createMissing ?? false, subscribed ?? true); return res.status(200).json(result); } diff --git a/apps/api/src/services/ContactService.ts b/apps/api/src/services/ContactService.ts index 3976d29..a66fe81 100644 --- a/apps/api/src/services/ContactService.ts +++ b/apps/api/src/services/ContactService.ts @@ -76,6 +76,23 @@ export class ContactService { return contact; } + /** + * Bulk-check which emails exist in the project — single query, safe for up to 500 addresses. + */ + public static async lookup( + projectId: string, + emails: string[], + ): Promise<{found: string[]; notFound: string[]}> { + const rows = await prisma.contact.findMany({ + where: {projectId, email: {in: emails, mode: 'insensitive'}}, + select: {email: true}, + }); + const foundSet = new Set(rows.map(r => r.email.toLowerCase())); + const found = emails.filter(e => foundSet.has(e.toLowerCase())); + const notFound = emails.filter(e => !foundSet.has(e.toLowerCase())); + return {found, notFound}; + } + /** * Find a contact by email (returns null if not found) */ diff --git a/apps/api/src/services/SegmentService.ts b/apps/api/src/services/SegmentService.ts index 6c1a722..cc3d74f 100644 --- a/apps/api/src/services/SegmentService.ts +++ b/apps/api/src/services/SegmentService.ts @@ -328,7 +328,9 @@ export class SegmentService { projectId: string, segmentId: string, emails: string[], - ): Promise<{added: number; notFound: string[]}> { + createMissing = false, + subscribed = true, + ): Promise<{added: number; created: number; notFound: string[]}> { const segment = await this.get(projectId, segmentId); if (segment.type !== 'STATIC') { @@ -336,7 +338,7 @@ export class SegmentService { } // Look up contacts by email (case-insensitive) - const contacts = await prisma.contact.findMany({ + let contacts = await prisma.contact.findMany({ where: { projectId, email: {in: emails, mode: 'insensitive'}, @@ -344,7 +346,24 @@ export class SegmentService { select: {id: true, email: true}, }); - const foundEmails = new Set(contacts.map(c => c.email.toLowerCase())); + let foundEmails = new Set(contacts.map(c => c.email.toLowerCase())); + const missing = emails.filter(e => !foundEmails.has(e.toLowerCase())); + + let created = 0; + if (createMissing && missing.length > 0) { + await prisma.contact.createMany({ + data: missing.map(email => ({projectId, email, subscribed})), + skipDuplicates: true, + }); + created = missing.length; + // Re-fetch to include newly created contacts + contacts = await prisma.contact.findMany({ + where: {projectId, email: {in: emails, mode: 'insensitive'}}, + select: {id: true, email: true}, + }); + foundEmails = new Set(contacts.map(c => c.email.toLowerCase())); + } + const notFound = emails.filter(e => !foundEmails.has(e.toLowerCase())); if (contacts.length > 0) { @@ -377,7 +396,7 @@ export class SegmentService { await prisma.segment.update({where: {id: segmentId}, data: {memberCount}}); } - return {added: contacts.length, notFound}; + return {added: contacts.length, created, notFound}; } /** diff --git a/apps/web/src/components/ContactPicker.tsx b/apps/web/src/components/ContactPicker.tsx index 8dfeaf0..bd30cf2 100644 --- a/apps/web/src/components/ContactPicker.tsx +++ b/apps/web/src/components/ContactPicker.tsx @@ -1,35 +1,61 @@ import type {Contact} from '@plunk/db'; import type {CursorPaginatedResponse} from '@plunk/types'; -import {Input, Popover, PopoverContent, PopoverTrigger} from '@plunk/ui'; -import {Check, ChevronsUpDown, MailCheck, MailX, Search, X} from 'lucide-react'; +import {Button, Input, Label, Popover, PopoverContent, PopoverTrigger, Switch} from '@plunk/ui'; +import {Check, ChevronsUpDown, ClipboardList, Loader2, MailCheck, MailX, Search, Sparkles, X} from 'lucide-react'; import {useEffect, useRef, useState} from 'react'; import useSWR from 'swr'; +import {ContactSchemas} from '@plunk/shared'; +import {network} from '../lib/network'; + +type Mode = 'search' | 'paste'; interface ContactPickerProps { - /** Currently selected emails */ + /** Currently selected emails (search mode) */ selected: string[]; - /** Called when selection changes */ + /** Called when search-mode selection changes */ onChange: (emails: string[]) => void; - /** Emails already in the segment (shown as disabled) */ + /** + * Called by paste mode to submit directly — skips the chip staging area. + * Receives the parsed email list and whether new contacts should be subscribed. + */ + onAdd: (emails: string[], subscribed: boolean) => Promise; + /** Emails already in the segment (shown as disabled in search mode) */ existing?: string[]; placeholder?: string; } -/** - * Searchable multi-select contact picker backed by the /contacts API. - * Only fetches when the user types (safe for large contact lists). - */ +const CHIP_LIMIT = 8; + +function parseEmails(raw: string): string[] { + return raw + .split(/[\n,;]+/) + .map(s => s.trim().toLowerCase()) + .filter(s => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(s)); +} + export function ContactPicker({ selected, onChange, + onAdd, existing = [], placeholder = 'Search contacts...', }: ContactPickerProps) { + const [mode, setMode] = useState('search'); + + // Search mode const [open, setOpen] = useState(false); const [search, setSearch] = useState(''); const [debouncedSearch, setDebouncedSearch] = useState(''); const debounceRef = useRef | null>(null); + // Paste mode + const [pasteText, setPasteText] = useState(''); + const [isLooking, setIsLooking] = useState(false); + const [preview, setPreview] = useState<{found: string[]; notFound: string[]} | null>(null); + const [subscribeNew, setSubscribeNew] = useState(true); + const [debouncedPaste, setDebouncedPaste] = useState(''); + const pasteDebounceRef = useRef | null>(null); + useEffect(() => { if (debounceRef.current) clearTimeout(debounceRef.current); debounceRef.current = setTimeout(() => setDebouncedSearch(search), 300); @@ -38,7 +64,26 @@ export function ContactPicker({ }; }, [search]); - // Only fetch when there's a search term — avoids loading all contacts on open + useEffect(() => { + if (pasteDebounceRef.current) clearTimeout(pasteDebounceRef.current); + pasteDebounceRef.current = setTimeout(() => setDebouncedPaste(pasteText), 400); + return () => { + if (pasteDebounceRef.current) clearTimeout(pasteDebounceRef.current); + }; + }, [pasteText]); + + useEffect(() => { + const emails = parseEmails(debouncedPaste).filter(e => !existing.includes(e)); + if (emails.length === 0) { + setPreview(null); + return; + } + setIsLooking(true); + network.fetch<{found: string[]; notFound: string[]}, typeof ContactSchemas.lookup>( + 'POST', '/contacts/lookup', {emails}, + ).then(setPreview).finally(() => setIsLooking(false)); + }, [debouncedPaste]); + const {data, isLoading} = useSWR>( open && debouncedSearch.length > 0 ? `/contacts?limit=20&search=${encodeURIComponent(debouncedSearch)}` : null, {revalidateOnFocus: false}, @@ -54,97 +99,196 @@ export function ContactPicker({ } }; + const parsedEmails = parseEmails(pasteText); + const newPastedEmails = parsedEmails.filter(e => !existing.includes(e)); + + const handlePasteSubmit = async () => { + if (newPastedEmails.length === 0) return; + await onAdd(newPastedEmails, subscribeNew); + setPasteText(''); + setPreview(null); + }; + + const visibleChips = selected.slice(0, CHIP_LIMIT); + const overflowCount = selected.length - CHIP_LIMIT; + return (
- { setOpen(v); if (!v) setSearch(''); }}> - - - - + + +
- {/* Results */} -
- {debouncedSearch.length === 0 ? ( -

Type to search contacts

- ) : isLoading ? ( -

Searching...

- ) : contacts.length === 0 ? ( -

No contacts found

- ) : ( - contacts.map(contact => { - const isSelected = selected.includes(contact.email); - const isExisting = existing.includes(contact.email); - - return ( - - ); - }) - )} -
- - - - {/* Selected chips */} - {selected.length > 0 && ( -
- {selected.map(email => ( - - {email} + {mode === 'search' ? ( + <> + { setOpen(v); if (!v) setSearch(''); }}> + - - ))} + + +
+ + setSearch(e.target.value)} + className="border-0 p-0 h-8 focus-visible:ring-0 focus-visible:ring-offset-0 text-sm" + autoFocus + /> +
+
+ {debouncedSearch.length === 0 ? ( +

Type to search contacts

+ ) : isLoading ? ( +

Searching...

+ ) : contacts.length === 0 ? ( +

No contacts found

+ ) : ( + contacts.map(contact => { + const isSelected = selected.includes(contact.email); + const isExisting = existing.includes(contact.email); + return ( + + ); + }) + )} +
+
+ + + {/* Chips */} + {selected.length > 0 && ( +
+
+ {visibleChips.map(email => ( + + {email} + + + ))} + {overflowCount > 0 && ( + + +{overflowCount} more + + )} +
+ +
+ )} + + ) : ( + /* Paste mode — self-contained, submits directly via onAdd */ +
+