add export contact book option (#318)

This commit is contained in:
KM Koushik
2025-12-14 10:08:54 +11:00
committed by GitHub
parent 461cd949e5
commit 1e79f13bd4
7 changed files with 303 additions and 60 deletions
+1
View File
@@ -1,5 +1,6 @@
/// <reference types="next" /> /// <reference types="next" />
/// <reference types="next/image-types/global" /> /// <reference types="next/image-types/global" />
/// <reference path="./.next/types/routes.d.ts" />
// NOTE: This file should not be edited // NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+1
View File
@@ -1,5 +1,6 @@
/// <reference types="next" /> /// <reference types="next" />
/// <reference types="next/image-types/global" /> /// <reference types="next/image-types/global" />
/// <reference path="./.next/types/routes.d.ts" />
// NOTE: This file should not be edited // NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
@@ -34,6 +34,7 @@ interface ParsedContact {
email: string; email: string;
firstName?: string; firstName?: string;
lastName?: string; lastName?: string;
subscribed?: boolean;
isValid: boolean; isValid: boolean;
} }
@@ -76,45 +77,91 @@ export default function BulkUploadContacts({
const parseContactLine = ( const parseContactLine = (
line: string, 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(); const trimmedLine = line.trim();
if (!trimmedLine) return null; if (!trimmedLine) return null;
// Split by comma // Split by comma, handling quoted values
const parts = trimmedLine.split(",").map((s) => s.trim()); 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; 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 // Skip if doesn't look like an email
if (!email.includes("@")) return null; if (!email.includes("@")) return null;
if (parts.length === 1) { // Parse subscribed value (support CSV export format: Email, First Name, Last Name, Subscribed, ...)
// Just email let subscribed: boolean | undefined = undefined;
return { email }; 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) { } else if (parts.length === 2) {
// email,firstName // email,firstName
return { firstName = parts[1] || undefined;
email,
firstName: parts[1] || undefined,
};
} else {
// email,firstName,lastName (ignore anything beyond)
return {
email,
firstName: parts[1] || undefined,
lastName: parts[2] || undefined,
};
} }
return {
email,
firstName,
lastName,
subscribed,
};
}; };
const parseContacts = (text: string): ParsedContact[] => { const parseContacts = (text: string): ParsedContact[] => {
const lines = text.split("\n"); const lines = text.split("\n");
const contactsMap = new Map<string, ParsedContact>(); const contactsMap = new Map<string, ParsedContact>();
for (const line of lines) { for (let i = 0; i < lines.length; i++) {
const parsed = parseContactLine(line); const parsed = parseContactLine(lines[i]!, i === 0);
if (parsed) { if (parsed) {
// Use email as key to deduplicate // Use email as key to deduplicate
if (!contactsMap.has(parsed.email)) { if (!contactsMap.has(parsed.email)) {
@@ -201,8 +248,8 @@ export default function BulkUploadContacts({
return; return;
} }
if (validContacts.length > 10000) { if (validContacts.length > 50000) {
setError("Maximum 10,000 contacts allowed per upload"); setError("Maximum 50,000 contacts allowed per upload");
setProcessing(false); setProcessing(false);
return; return;
} }
@@ -220,6 +267,7 @@ export default function BulkUploadContacts({
email: c.email, email: c.email,
firstName: c.firstName, firstName: c.firstName,
lastName: c.lastName, lastName: c.lastName,
subscribed: c.subscribed,
})), })),
}); });
} catch { } catch {
@@ -255,23 +303,23 @@ export default function BulkUploadContacts({
<DialogContent className="max-w-3xl"> <DialogContent className="max-w-3xl">
<DialogHeader> <DialogHeader>
<DialogTitle>Bulk Upload Contacts</DialogTitle> <DialogTitle>Bulk Upload Contacts</DialogTitle>
<DialogDescription> <DialogDescription className="text-sm">
Upload multiple contacts at once. Supports email only or CSV format Upload multiple contacts at once. Cannot change from unsubscribed to
(email,firstName,lastName). subscribed via upload
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<Tabs defaultValue="text" className="w-full"> <Tabs defaultValue="text" className="w-full">
<TabsList className="grid w-full grid-cols-2"> <TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="text">
<FileText className="h-4 w-4 mr-2" />
Text Input
</TabsTrigger>
<TabsTrigger value="file"> <TabsTrigger value="file">
<Upload className="h-4 w-4 mr-2" /> <Upload className="h-4 w-4 mr-2" />
File Upload File Upload
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="text">
<FileText className="h-4 w-4 mr-2" />
Text Input
</TabsTrigger>
</TabsList> </TabsList>
<TabsContent value="text" className="space-y-4"> <TabsContent value="text" className="space-y-4">
@@ -281,11 +329,11 @@ export default function BulkUploadContacts({
id="contacts" id="contacts"
placeholder={`Enter contacts, one per line: placeholder={`Enter contacts, one per line:
john@example.com,John,Doe john@example.com,John,Doe,Yes
jane@example.com,Jane,Smith jane@example.com,Jane,Smith,No
bob@example.com 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} value={inputText}
onChange={(e) => setInputText(e.target.value)} onChange={(e) => setInputText(e.target.value)}
className="min-h-[150px] font-mono text-sm" 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"} : "Upload a .txt or .csv file or drag and drop here"}
</p> </p>
<p className="mt-1 text-xs text-muted-foreground"> <p className="mt-1 text-xs text-muted-foreground">
Format: email,firstName,lastName (one per line) Format: email,firstName,lastName,subscribed (one per line)
</p> </p>
</div> </div>
</div> </div>
@@ -359,6 +407,7 @@ Format: email,firstName,lastName (firstName and lastName are optional)`}
<TableHead>Email</TableHead> <TableHead>Email</TableHead>
<TableHead>First Name</TableHead> <TableHead>First Name</TableHead>
<TableHead>Last Name</TableHead> <TableHead>Last Name</TableHead>
<TableHead>Subscribed</TableHead>
<TableHead className="w-[80px]">Status</TableHead> <TableHead className="w-[80px]">Status</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
@@ -378,6 +427,17 @@ Format: email,firstName,lastName (firstName and lastName are optional)`}
<span className="text-muted-foreground">-</span> <span className="text-muted-foreground">-</span>
)} )}
</TableCell> </TableCell>
<TableCell className="text-sm">
{contact.subscribed === undefined ? (
<span className="text-muted-foreground">
Default
</span>
) : contact.subscribed ? (
<span className="text-green">Yes</span>
) : (
<span className="text-red">No</span>
)}
</TableCell>
<TableCell> <TableCell>
{contact.isValid ? ( {contact.isValid ? (
<div className="flex items-center text-green"> <div className="flex items-center text-green">
@@ -32,6 +32,27 @@ import {
TooltipTrigger, TooltipTrigger,
} from "@usesend/ui/src/tooltip"; } from "@usesend/ui/src/tooltip";
import { UnsubscribeReason } from "@prisma/client"; 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) { function getUnsubscribeReason(reason: UnsubscribeReason) {
switch (reason) { switch (reason) {
@@ -48,8 +69,10 @@ function getUnsubscribeReason(reason: UnsubscribeReason) {
export default function ContactList({ export default function ContactList({
contactBookId, contactBookId,
contactBookName,
}: { }: {
contactBookId: string; contactBookId: string;
contactBookName?: string;
}) { }) {
const [page, setPage] = useUrlState("page", "1"); const [page, setPage] = useUrlState("page", "1");
const [status, setStatus] = useUrlState("status"); const [status, setStatus] = useUrlState("status");
@@ -73,10 +96,80 @@ export default function ContactList({
setSearch(value); setSearch(value);
}, 1000); }, 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 ( return (
<TooltipProvider> <TooltipProvider>
<div className="mt-10 flex flex-col gap-4"> <div className="mt-10 flex flex-col gap-4">
<div className="flex justify-between"> <div className="flex justify-between items-center">
<div> <div>
<Input <Input
placeholder="Search by email or name" placeholder="Search by email or name"
@@ -85,6 +178,7 @@ export default function ContactList({
onChange={(e) => debouncedSearch(e.target.value)} onChange={(e) => debouncedSearch(e.target.value)}
/> />
</div> </div>
<div className="flex gap-2">
<Select <Select
value={status ?? "All"} value={status ?? "All"}
onValueChange={(val) => setStatus(val === "All" ? null : val)} onValueChange={(val) => setStatus(val === "All" ? null : val)}
@@ -104,6 +198,23 @@ export default function ContactList({
</SelectItem> </SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
<Button
onClick={handleExport}
disabled={exportQuery.isFetching}
size="sm"
variant="outline"
>
{exportQuery.isFetching ? (
<Spinner
className="w-4 h-4 mr-2"
innerSvgClass="stroke-primary"
/>
) : (
<Download className="w-4 h-4 mr-2" />
)}
Export
</Button>
</div>
</div> </div>
<div className="flex flex-col rounded-xl border border-broder shadow"> <div className="flex flex-col rounded-xl border border-broder shadow">
<Table className=""> <Table className="">
@@ -198,7 +198,10 @@ export default function ContactsPage({
</div> </div>
</div> </div>
<div className="mt-16"> <div className="mt-16">
<ContactList contactBookId={contactBookId} /> <ContactList
contactBookId={contactBookId}
contactBookName={contactBookDetailQuery.data?.name}
/>
</div> </div>
</div> </div>
</div> </div>
+44 -1
View File
@@ -129,7 +129,7 @@ export const contactsRouter = createTRPCRouter({
subscribed: z.boolean().optional(), subscribed: z.boolean().optional(),
}), }),
) )
.max(10000), .max(50000),
}), }),
) )
.mutation(async ({ ctx: { contactBook, team }, input }) => { .mutation(async ({ ctx: { contactBook, team }, input }) => {
@@ -161,4 +161,47 @@ export const contactsRouter = createTRPCRouter({
.mutation(async ({ input }) => { .mutation(async ({ input }) => {
return contactService.deleteContact(input.contactId); 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;
}),
}); });
+29 -5
View File
@@ -11,8 +11,32 @@ export type ContactInput = {
export async function addOrUpdateContact( export async function addOrUpdateContact(
contactBookId: string, 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({ const createdContact = await db.contact.upsert({
where: { where: {
contactBookId_email: { contactBookId_email: {
@@ -26,13 +50,13 @@ export async function addOrUpdateContact(
firstName: contact.firstName, firstName: contact.firstName,
lastName: contact.lastName, lastName: contact.lastName,
properties: contact.properties ?? {}, properties: contact.properties ?? {},
subscribed: contact.subscribed, subscribed: contact.subscribed ?? true, // Default to subscribed for new contacts
}, },
update: { update: {
firstName: contact.firstName, firstName: contact.firstName,
lastName: contact.lastName, lastName: contact.lastName,
properties: contact.properties ?? {}, properties: contact.properties ?? {},
subscribed: contact.subscribed, ...(subscribedValue !== undefined ? { subscribed: subscribedValue } : {}),
}, },
}); });
@@ -41,7 +65,7 @@ export async function addOrUpdateContact(
export async function updateContact( export async function updateContact(
contactId: string, contactId: string,
contact: Partial<ContactInput> contact: Partial<ContactInput>,
) { ) {
return db.contact.update({ return db.contact.update({
where: { where: {
@@ -62,7 +86,7 @@ export async function deleteContact(contactId: string) {
export async function bulkAddContacts( export async function bulkAddContacts(
contactBookId: string, contactBookId: string,
contacts: Array<ContactInput>, contacts: Array<ContactInput>,
teamId?: number teamId?: number,
) { ) {
await ContactQueueService.addBulkContactJobs(contactBookId, contacts, teamId); await ContactQueueService.addBulkContactJobs(contactBookId, contacts, teamId);