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/image-types/global" />
/// <reference path="./.next/types/routes.d.ts" />
// NOTE: This file should not be edited
// 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/image-types/global" />
/// <reference path="./.next/types/routes.d.ts" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
@@ -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<string, ParsedContact>();
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({
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle>Bulk Upload Contacts</DialogTitle>
<DialogDescription>
Upload multiple contacts at once. Supports email only or CSV format
(email,firstName,lastName).
<DialogDescription className="text-sm">
Upload multiple contacts at once. Cannot change from unsubscribed to
subscribed via upload
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<Tabs defaultValue="text" className="w-full">
<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">
<Upload className="h-4 w-4 mr-2" />
File Upload
</TabsTrigger>
<TabsTrigger value="text">
<FileText className="h-4 w-4 mr-2" />
Text Input
</TabsTrigger>
</TabsList>
<TabsContent value="text" className="space-y-4">
@@ -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"}
</p>
<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>
</div>
</div>
@@ -359,6 +407,7 @@ Format: email,firstName,lastName (firstName and lastName are optional)`}
<TableHead>Email</TableHead>
<TableHead>First Name</TableHead>
<TableHead>Last Name</TableHead>
<TableHead>Subscribed</TableHead>
<TableHead className="w-[80px]">Status</TableHead>
</TableRow>
</TableHeader>
@@ -378,6 +427,17 @@ Format: email,firstName,lastName (firstName and lastName are optional)`}
<span className="text-muted-foreground">-</span>
)}
</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>
{contact.isValid ? (
<div className="flex items-center text-green">
@@ -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 (
<TooltipProvider>
<div className="mt-10 flex flex-col gap-4">
<div className="flex justify-between">
<div className="flex justify-between items-center">
<div>
<Input
placeholder="Search by email or name"
@@ -85,25 +178,43 @@ export default function ContactList({
onChange={(e) => debouncedSearch(e.target.value)}
/>
</div>
<Select
value={status ?? "All"}
onValueChange={(val) => setStatus(val === "All" ? null : val)}
>
<SelectTrigger className="w-[180px] capitalize">
{status || "All statuses"}
</SelectTrigger>
<SelectContent>
<SelectItem value="All" className=" capitalize">
All statuses
</SelectItem>
<SelectItem value="Subscribed" className=" capitalize">
Subscribed
</SelectItem>
<SelectItem value="Unsubscribed" className=" capitalize">
Unsubscribed
</SelectItem>
</SelectContent>
</Select>
<div className="flex gap-2">
<Select
value={status ?? "All"}
onValueChange={(val) => setStatus(val === "All" ? null : val)}
>
<SelectTrigger className="w-[180px] capitalize">
{status || "All statuses"}
</SelectTrigger>
<SelectContent>
<SelectItem value="All" className=" capitalize">
All statuses
</SelectItem>
<SelectItem value="Subscribed" className=" capitalize">
Subscribed
</SelectItem>
<SelectItem value="Unsubscribed" className=" capitalize">
Unsubscribed
</SelectItem>
</SelectContent>
</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 className="flex flex-col rounded-xl border border-broder shadow">
<Table className="">
@@ -198,7 +198,10 @@ export default function ContactsPage({
</div>
</div>
<div className="mt-16">
<ContactList contactBookId={contactBookId} />
<ContactList
contactBookId={contactBookId}
contactBookName={contactBookDetailQuery.data?.name}
/>
</div>
</div>
</div>
+44 -1
View File
@@ -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;
}),
});
+29 -5
View File
@@ -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<ContactInput>
contact: Partial<ContactInput>,
) {
return db.contact.update({
where: {
@@ -62,7 +86,7 @@ export async function deleteContact(contactId: string) {
export async function bulkAddContacts(
contactBookId: string,
contacts: Array<ContactInput>,
teamId?: number
teamId?: number,
) {
await ContactQueueService.addBulkContactJobs(contactBookId, contacts, teamId);