add export contact book option (#318)
This commit is contained in:
Vendored
+1
@@ -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.
|
||||
|
||||
Vendored
+1
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user