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