diff --git a/apps/marketing/next-env.d.ts b/apps/marketing/next-env.d.ts index 1b3be08..830fb59 100644 --- a/apps/marketing/next-env.d.ts +++ b/apps/marketing/next-env.d.ts @@ -1,5 +1,6 @@ /// /// +/// // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/apps/web/next-env.d.ts b/apps/web/next-env.d.ts index 1b3be08..830fb59 100644 --- a/apps/web/next-env.d.ts +++ b/apps/web/next-env.d.ts @@ -1,5 +1,6 @@ /// /// +/// // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/apps/web/src/app/(dashboard)/contacts/[contactBookId]/bulk-upload-contacts.tsx b/apps/web/src/app/(dashboard)/contacts/[contactBookId]/bulk-upload-contacts.tsx index 44a83bd..e512763 100644 --- a/apps/web/src/app/(dashboard)/contacts/[contactBookId]/bulk-upload-contacts.tsx +++ b/apps/web/src/app/(dashboard)/contacts/[contactBookId]/bulk-upload-contacts.tsx @@ -34,6 +34,7 @@ interface ParsedContact { email: string; firstName?: string; lastName?: string; + subscribed?: boolean; isValid: boolean; } @@ -76,45 +77,91 @@ export default function BulkUploadContacts({ const parseContactLine = ( line: string, - ): { email: string; firstName?: string; lastName?: string } | null => { + isFirstLine: boolean = false, + ): { + email: string; + firstName?: string; + lastName?: string; + subscribed?: boolean; + } | null => { const trimmedLine = line.trim(); if (!trimmedLine) return null; - // Split by comma - const parts = trimmedLine.split(",").map((s) => s.trim()); + // Split by comma, handling quoted values + const parts: string[] = []; + let current = ""; + let inQuotes = false; + + for (let i = 0; i < trimmedLine.length; i++) { + const char = trimmedLine[i]; + if (char === '"') { + inQuotes = !inQuotes; + } else if (char === "," && !inQuotes) { + parts.push(current.trim()); + current = ""; + } else { + current += char; + } + } + parts.push(current.trim()); if (parts.length === 0 || !parts[0]) return null; - const email = parts[0].toLowerCase(); + // Check if this is a header row (case-insensitive) + if (isFirstLine) { + const firstPart = parts[0]?.toLowerCase(); + if ( + firstPart === "email" || + firstPart === "e-mail" || + firstPart === "email address" + ) { + return null; // Skip header row + } + } + + const email = parts[0]!.toLowerCase(); // Skip if doesn't look like an email if (!email.includes("@")) return null; - if (parts.length === 1) { - // Just email - return { email }; + // Parse subscribed value (support CSV export format: Email, First Name, Last Name, Subscribed, ...) + let subscribed: boolean | undefined = undefined; + let firstName: string | undefined = undefined; + let lastName: string | undefined = undefined; + + if (parts.length >= 4) { + // Could be: email,firstName,lastName,subscribed + firstName = parts[1] || undefined; + lastName = parts[2] || undefined; + const subscribedValue = parts[3]?.toLowerCase(); + if (subscribedValue === "yes" || subscribedValue === "true") { + subscribed = true; + } else if (subscribedValue === "no" || subscribedValue === "false") { + subscribed = false; + } + } else if (parts.length === 3) { + // email,firstName,lastName + firstName = parts[1] || undefined; + lastName = parts[2] || undefined; } else if (parts.length === 2) { // email,firstName - return { - email, - firstName: parts[1] || undefined, - }; - } else { - // email,firstName,lastName (ignore anything beyond) - return { - email, - firstName: parts[1] || undefined, - lastName: parts[2] || undefined, - }; + firstName = parts[1] || undefined; } + + return { + email, + firstName, + lastName, + subscribed, + }; }; const parseContacts = (text: string): ParsedContact[] => { const lines = text.split("\n"); const contactsMap = new Map(); - for (const line of lines) { - const parsed = parseContactLine(line); + for (let i = 0; i < lines.length; i++) { + const parsed = parseContactLine(lines[i]!, i === 0); if (parsed) { // Use email as key to deduplicate if (!contactsMap.has(parsed.email)) { @@ -201,8 +248,8 @@ export default function BulkUploadContacts({ return; } - if (validContacts.length > 10000) { - setError("Maximum 10,000 contacts allowed per upload"); + if (validContacts.length > 50000) { + setError("Maximum 50,000 contacts allowed per upload"); setProcessing(false); return; } @@ -220,6 +267,7 @@ export default function BulkUploadContacts({ email: c.email, firstName: c.firstName, lastName: c.lastName, + subscribed: c.subscribed, })), }); } catch { @@ -255,23 +303,23 @@ export default function BulkUploadContacts({ Bulk Upload Contacts - - Upload multiple contacts at once. Supports email only or CSV format - (email,firstName,lastName). + + Upload multiple contacts at once. Cannot change from unsubscribed to + subscribed via upload
- - - Text Input - File Upload + + + Text Input + @@ -281,11 +329,11 @@ export default function BulkUploadContacts({ id="contacts" placeholder={`Enter contacts, one per line: -john@example.com,John,Doe -jane@example.com,Jane,Smith +john@example.com,John,Doe,Yes +jane@example.com,Jane,Smith,No bob@example.com -Format: email,firstName,lastName (firstName and lastName are optional)`} +Format: email,firstName,lastName,subscribed (all fields except email are optional)`} value={inputText} onChange={(e) => setInputText(e.target.value)} className="min-h-[150px] font-mono text-sm" @@ -337,7 +385,7 @@ Format: email,firstName,lastName (firstName and lastName are optional)`} : "Upload a .txt or .csv file or drag and drop here"}

- Format: email,firstName,lastName (one per line) + Format: email,firstName,lastName,subscribed (one per line)

@@ -359,6 +407,7 @@ Format: email,firstName,lastName (firstName and lastName are optional)`} Email First Name Last Name + Subscribed Status @@ -378,6 +427,17 @@ Format: email,firstName,lastName (firstName and lastName are optional)`} - )} + + {contact.subscribed === undefined ? ( + + Default + + ) : contact.subscribed ? ( + Yes + ) : ( + No + )} + {contact.isValid ? (
diff --git a/apps/web/src/app/(dashboard)/contacts/[contactBookId]/contact-list.tsx b/apps/web/src/app/(dashboard)/contacts/[contactBookId]/contact-list.tsx index 709707a..281e54a 100644 --- a/apps/web/src/app/(dashboard)/contacts/[contactBookId]/contact-list.tsx +++ b/apps/web/src/app/(dashboard)/contacts/[contactBookId]/contact-list.tsx @@ -32,6 +32,27 @@ import { TooltipTrigger, } from "@usesend/ui/src/tooltip"; import { UnsubscribeReason } from "@prisma/client"; +import { Download } from "lucide-react"; + +function sanitizeFilename( + name: string | undefined, + fallback = "contacts", +): string { + if (!name) return fallback; + + // Remove or replace unsafe characters: + // - Path separators: / \ + // - Reserved characters: : * ? " < > | + // - Control characters (0x00-0x1F, 0x7F) + // - Single quotes and backticks + const sanitized = name.replace(/[/\\:*?"<>|'\x00-\x1F\x7F]/g, "-").trim(); + + // Limit length to prevent excessively long filenames (max 100 chars) + const limited = sanitized.slice(0, 100).trim(); + + // Return fallback if result is empty after sanitization + return limited || fallback; +} function getUnsubscribeReason(reason: UnsubscribeReason) { switch (reason) { @@ -48,8 +69,10 @@ function getUnsubscribeReason(reason: UnsubscribeReason) { export default function ContactList({ contactBookId, + contactBookName, }: { contactBookId: string; + contactBookName?: string; }) { const [page, setPage] = useUrlState("page", "1"); const [status, setStatus] = useUrlState("status"); @@ -73,10 +96,80 @@ export default function ContactList({ setSearch(value); }, 1000); + const exportQuery = api.contacts.exportContacts.useQuery( + { + contactBookId, + search: search ?? undefined, + subscribed: + status === "Subscribed" + ? true + : status === "Unsubscribed" + ? false + : undefined, + }, + { + enabled: false, + }, + ); + + const escapeCell = (str: string): string => { + // Wrap in quotes if contains comma, newline, or quote + if (str.includes(",") || str.includes("\n") || str.includes('"')) { + return `"${str.replace(/"/g, '""')}"`; + } + return str; + }; + + const handleExport = async () => { + const result = await exportQuery.refetch(); + if (!result.data) return; + + // CSV Header + const headers = [ + "Email", + "First Name", + "Last Name", + "Subscribed", + "Unsubscribe Reason", + "Created At", + ]; + + // CSV Rows + const rows = result.data.map((contact) => [ + escapeCell(contact.email ?? ""), + escapeCell(contact.firstName ?? ""), + escapeCell(contact.lastName ?? ""), + escapeCell(contact.subscribed ? "Yes" : "No"), + escapeCell(contact.unsubscribeReason ?? ""), + escapeCell(contact.createdAt.toISOString()), + ]); + + // Build CSV with UTF-8 BOM + const csvContent = [ + headers.map(escapeCell).join(","), + ...rows.map((row) => row.join(",")), + ].join("\n"); + const blob = new Blob([csvContent], { + type: "text/csv;charset=utf-8;", + }); + + // Download + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + const today = new Date().toISOString().split("T")[0]; + const safeContactBookName = sanitizeFilename(contactBookName); + link.download = `contacts-${safeContactBookName}-${today}.csv`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); + }; + return (
-
+
debouncedSearch(e.target.value)} />
- +
+ + +
diff --git a/apps/web/src/app/(dashboard)/contacts/[contactBookId]/page.tsx b/apps/web/src/app/(dashboard)/contacts/[contactBookId]/page.tsx index 1830b4e..57135b4 100644 --- a/apps/web/src/app/(dashboard)/contacts/[contactBookId]/page.tsx +++ b/apps/web/src/app/(dashboard)/contacts/[contactBookId]/page.tsx @@ -198,7 +198,10 @@ export default function ContactsPage({
- +
diff --git a/apps/web/src/server/api/routers/contacts.ts b/apps/web/src/server/api/routers/contacts.ts index 70258bb..416b291 100644 --- a/apps/web/src/server/api/routers/contacts.ts +++ b/apps/web/src/server/api/routers/contacts.ts @@ -129,7 +129,7 @@ export const contactsRouter = createTRPCRouter({ subscribed: z.boolean().optional(), }), ) - .max(10000), + .max(50000), }), ) .mutation(async ({ ctx: { contactBook, team }, input }) => { @@ -161,4 +161,47 @@ export const contactsRouter = createTRPCRouter({ .mutation(async ({ input }) => { return contactService.deleteContact(input.contactId); }), + + exportContacts: contactBookProcedure + .input( + z.object({ + subscribed: z.boolean().optional(), + search: z.string().optional(), + }), + ) + .query(async ({ ctx: { db }, input }) => { + const whereConditions: Prisma.ContactFindManyArgs["where"] = { + contactBookId: input.contactBookId, + ...(input.subscribed !== undefined + ? { subscribed: input.subscribed } + : {}), + ...(input.search + ? { + OR: [ + { email: { contains: input.search, mode: "insensitive" } }, + { firstName: { contains: input.search, mode: "insensitive" } }, + { lastName: { contains: input.search, mode: "insensitive" } }, + ], + } + : {}), + }; + + const contacts = await db.contact.findMany({ + where: whereConditions, + select: { + email: true, + firstName: true, + lastName: true, + subscribed: true, + unsubscribeReason: true, + createdAt: true, + }, + orderBy: { + createdAt: "desc", + }, + take: 100000, // Limit to 100k contacts to prevent memory issues + }); + + return contacts; + }), }); diff --git a/apps/web/src/server/service/contact-service.ts b/apps/web/src/server/service/contact-service.ts index 6d5b082..6d49eec 100644 --- a/apps/web/src/server/service/contact-service.ts +++ b/apps/web/src/server/service/contact-service.ts @@ -11,8 +11,32 @@ export type ContactInput = { export async function addOrUpdateContact( contactBookId: string, - contact: ContactInput + contact: ContactInput, ) { + // Check if contact exists to handle subscribed logic + const existingContact = await db.contact.findUnique({ + where: { + contactBookId_email: { + contactBookId, + email: contact.email, + }, + }, + select: { + subscribed: true, + }, + }); + + // Determine subscribed value for update + // Only allow Yes→No transitions (allow unsubscribe, prevent re-subscribe) + let subscribedValue: boolean | undefined = contact.subscribed; + if (existingContact && contact.subscribed !== undefined) { + // Block No→Yes (prevent re-subscribe via CSV), allow all other transitions + if (!existingContact.subscribed && contact.subscribed) { + subscribedValue = undefined; // Block re-subscribe + } + // All other cases (Yes→No, Yes→Yes, No→No) are allowed naturally + } + const createdContact = await db.contact.upsert({ where: { contactBookId_email: { @@ -26,13 +50,13 @@ export async function addOrUpdateContact( firstName: contact.firstName, lastName: contact.lastName, properties: contact.properties ?? {}, - subscribed: contact.subscribed, + subscribed: contact.subscribed ?? true, // Default to subscribed for new contacts }, update: { firstName: contact.firstName, lastName: contact.lastName, properties: contact.properties ?? {}, - subscribed: contact.subscribed, + ...(subscribedValue !== undefined ? { subscribed: subscribedValue } : {}), }, }); @@ -41,7 +65,7 @@ export async function addOrUpdateContact( export async function updateContact( contactId: string, - contact: Partial + contact: Partial, ) { return db.contact.update({ where: { @@ -62,7 +86,7 @@ export async function deleteContact(contactId: string) { export async function bulkAddContacts( contactBookId: string, contacts: Array, - teamId?: number + teamId?: number, ) { await ContactQueueService.addBulkContactJobs(contactBookId, contacts, teamId);