feat: add contact-book variable registry for campaign personalization (#359)
* feat: add contact-book variable registry for campaign personalization * test: include contact-book variables default in service expectation * fix: address personalization review issues * fix text * fix: normalize contact variable access across contact flows * stuff * fix
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "ContactBook" ADD COLUMN "variables" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[];
|
||||
@@ -296,6 +296,7 @@ model ContactBook {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
teamId Int
|
||||
variables String[] @default([])
|
||||
properties Json
|
||||
doubleOptInEnabled Boolean @default(false)
|
||||
doubleOptInFrom String?
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Spinner } from "@usesend/ui/src/spinner";
|
||||
import { Button } from "@usesend/ui/src/button";
|
||||
import { Input } from "@usesend/ui/src/input";
|
||||
import { Editor } from "@usesend/email-editor";
|
||||
import { use, useState } from "react";
|
||||
import { use, useMemo, useState } from "react";
|
||||
import { Campaign } from "@prisma/client";
|
||||
import {
|
||||
Select,
|
||||
@@ -65,7 +65,7 @@ export default function EditCampaignPage({
|
||||
{ campaignId },
|
||||
{
|
||||
enabled: !!campaignId,
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
@@ -102,7 +102,7 @@ function CampaignEditor({
|
||||
const utils = api.useUtils();
|
||||
|
||||
const [json, setJson] = useState<Record<string, any> | undefined>(
|
||||
campaign.content ? JSON.parse(campaign.content) : undefined
|
||||
campaign.content ? JSON.parse(campaign.content) : undefined,
|
||||
);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [name, setName] = useState(campaign.name);
|
||||
@@ -110,10 +110,10 @@ function CampaignEditor({
|
||||
const [from, setFrom] = useState(campaign.from);
|
||||
const [contactBookId, setContactBookId] = useState(campaign.contactBookId);
|
||||
const [replyTo, setReplyTo] = useState<string | undefined>(
|
||||
campaign.replyTo[0]
|
||||
campaign.replyTo[0],
|
||||
);
|
||||
const [previewText, setPreviewText] = useState<string | null>(
|
||||
campaign.previewText
|
||||
campaign.previewText,
|
||||
);
|
||||
|
||||
const updateCampaignMutation = api.campaign.updateCampaign.useMutation({
|
||||
@@ -136,13 +136,13 @@ function CampaignEditor({
|
||||
|
||||
const deboucedUpdateCampaign = useDebouncedCallback(
|
||||
updateEditorContent,
|
||||
1000
|
||||
1000,
|
||||
);
|
||||
|
||||
const handleFileChange = async (file: File) => {
|
||||
if (file.size > IMAGE_SIZE_LIMIT) {
|
||||
throw new Error(
|
||||
`File should be less than ${IMAGE_SIZE_LIMIT / 1024 / 1024}MB`
|
||||
`File should be less than ${IMAGE_SIZE_LIMIT / 1024 / 1024}MB`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -165,8 +165,17 @@ function CampaignEditor({
|
||||
};
|
||||
|
||||
const contactBook = contactBooksQuery.data?.find(
|
||||
(book) => book.id === contactBookId
|
||||
(book) => book.id === contactBookId,
|
||||
);
|
||||
const editorVariables = useMemo(() => {
|
||||
const baseVariables = ["email", "firstName", "lastName"];
|
||||
const registryVariables = contactBook?.variables ?? [];
|
||||
|
||||
return Array.from(new Set([...baseVariables, ...registryVariables]));
|
||||
}, [contactBook]);
|
||||
const variableSuggestionsHelperText = contactBookId
|
||||
? undefined
|
||||
: "Select the contact book for related variable";
|
||||
|
||||
return (
|
||||
<div className="p-4 container mx-auto ">
|
||||
@@ -196,7 +205,7 @@ function CampaignEditor({
|
||||
toast.error(`${e.message}. Reverting changes.`);
|
||||
setName(campaign.name);
|
||||
},
|
||||
}
|
||||
},
|
||||
);
|
||||
}}
|
||||
/>
|
||||
@@ -251,7 +260,7 @@ function CampaignEditor({
|
||||
toast.error(`${e.message}. Reverting changes.`);
|
||||
setSubject(campaign.subject);
|
||||
},
|
||||
}
|
||||
},
|
||||
);
|
||||
}}
|
||||
className="mt-1 py-1 text-sm block w-full outline-none border-b border-transparent focus:border-border bg-transparent"
|
||||
@@ -291,7 +300,7 @@ function CampaignEditor({
|
||||
toast.error(`${e.message}. Reverting changes.`);
|
||||
setFrom(campaign.from);
|
||||
},
|
||||
}
|
||||
},
|
||||
);
|
||||
}}
|
||||
disabled={isApiCampaign}
|
||||
@@ -327,7 +336,7 @@ function CampaignEditor({
|
||||
toast.error(`${e.message}. Reverting changes.`);
|
||||
setReplyTo(campaign.replyTo[0]);
|
||||
},
|
||||
}
|
||||
},
|
||||
);
|
||||
}}
|
||||
disabled={isApiCampaign}
|
||||
@@ -365,7 +374,7 @@ function CampaignEditor({
|
||||
toast.error(`${e.message}. Reverting changes.`);
|
||||
setPreviewText(campaign.previewText ?? "");
|
||||
},
|
||||
}
|
||||
},
|
||||
);
|
||||
}}
|
||||
className="mt-1 py-1 text-sm block w-full outline-none border-b border-transparent bg-transparent focus:border-border"
|
||||
@@ -397,7 +406,7 @@ function CampaignEditor({
|
||||
onError: () => {
|
||||
setContactBookId(campaign.contactBookId);
|
||||
},
|
||||
}
|
||||
},
|
||||
);
|
||||
setContactBookId(val);
|
||||
}}
|
||||
@@ -435,13 +444,15 @@ function CampaignEditor({
|
||||
<div className=" rounded-lg bg-gray-50 w-[700px] mx-auto p-10">
|
||||
<div className="w-[600px] mx-auto">
|
||||
<Editor
|
||||
key={`campaign-editor-${contactBookId ?? "none"}-${editorVariables.join(",")}`}
|
||||
initialContent={json}
|
||||
onUpdate={(content) => {
|
||||
setJson(content.getJSON());
|
||||
setIsSaving(true);
|
||||
deboucedUpdateCampaign();
|
||||
}}
|
||||
variables={["email", "firstName", "lastName"]}
|
||||
variables={editorVariables}
|
||||
variableSuggestionsHelperText={variableSuggestionsHelperText}
|
||||
uploadImage={
|
||||
campaign.imageUploadSupported ? handleFileChange : undefined
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ import { z } from "zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { toast } from "@usesend/ui/src/toaster";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
const contactsSchema = z.object({
|
||||
contacts: z.string({ required_error: "Contacts are required" }).min(1, {
|
||||
@@ -35,8 +36,14 @@ const contactsSchema = z.object({
|
||||
|
||||
export default function AddContact({
|
||||
contactBookId,
|
||||
trigger,
|
||||
open: controlledOpen,
|
||||
onOpenChange,
|
||||
}: {
|
||||
contactBookId: string;
|
||||
trigger?: ReactNode;
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
@@ -50,6 +57,14 @@ export default function AddContact({
|
||||
});
|
||||
|
||||
const utils = api.useUtils();
|
||||
const dialogTrigger =
|
||||
trigger ??
|
||||
(controlledOpen === undefined ? (
|
||||
<Button>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Add Contacts
|
||||
</Button>
|
||||
) : null);
|
||||
|
||||
async function onContactsAdd(values: z.infer<typeof contactsSchema>) {
|
||||
const contactsArray = values.contacts.split(",").map((email) => ({
|
||||
@@ -64,7 +79,11 @@ export default function AddContact({
|
||||
{
|
||||
onSuccess: async () => {
|
||||
utils.contacts.contacts.invalidate();
|
||||
setOpen(false);
|
||||
if (controlledOpen === undefined) {
|
||||
setOpen(false);
|
||||
} else {
|
||||
onOpenChange?.(false);
|
||||
}
|
||||
toast.success("Contacts queued for processing");
|
||||
},
|
||||
onError: async (error) => {
|
||||
@@ -76,15 +95,21 @@ export default function AddContact({
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(_open) => (_open !== open ? setOpen(_open) : null)}
|
||||
open={controlledOpen ?? open}
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (controlledOpen === undefined) {
|
||||
if (nextOpen !== open) {
|
||||
setOpen(nextOpen);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
onOpenChange?.(nextOpen);
|
||||
}}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Add Contacts
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
{dialogTrigger ? (
|
||||
<DialogTrigger asChild>{dialogTrigger}</DialogTrigger>
|
||||
) : null}
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add new contacts</DialogTitle>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
import { useState, useMemo, type ReactNode } from "react";
|
||||
import { api } from "~/trpc/react";
|
||||
import { getCanonicalContactVariableName } from "~/lib/contact-properties";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -28,24 +29,41 @@ import { toast } from "@usesend/ui/src/toaster";
|
||||
|
||||
interface BulkUploadContactsProps {
|
||||
contactBookId: string;
|
||||
contactBookVariables?: string[];
|
||||
trigger?: ReactNode;
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}
|
||||
|
||||
interface ParsedContact {
|
||||
email: string;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
properties?: Record<string, string>;
|
||||
subscribed?: boolean;
|
||||
isValid: boolean;
|
||||
}
|
||||
|
||||
export default function BulkUploadContacts({
|
||||
contactBookId,
|
||||
contactBookVariables,
|
||||
trigger,
|
||||
open: controlledOpen,
|
||||
onOpenChange,
|
||||
}: BulkUploadContactsProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [inputText, setInputText] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [processing, setProcessing] = useState(false);
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const dialogTrigger =
|
||||
trigger ??
|
||||
(controlledOpen === undefined ? (
|
||||
<Button variant="outline">
|
||||
<Upload className="h-4 w-4 mr-1" />
|
||||
Upload Contacts
|
||||
</Button>
|
||||
) : null);
|
||||
|
||||
const utils = api.useUtils();
|
||||
|
||||
@@ -67,7 +85,11 @@ export default function BulkUploadContacts({
|
||||
setInputText("");
|
||||
setError(null);
|
||||
setProcessing(false);
|
||||
setOpen(false);
|
||||
if (controlledOpen === undefined) {
|
||||
setOpen(false);
|
||||
} else {
|
||||
onOpenChange?.(false);
|
||||
}
|
||||
};
|
||||
|
||||
const validateEmail = (email: string): boolean => {
|
||||
@@ -77,11 +99,12 @@ export default function BulkUploadContacts({
|
||||
|
||||
const parseContactLine = (
|
||||
line: string,
|
||||
isFirstLine: boolean = false,
|
||||
headers?: string[],
|
||||
): {
|
||||
email: string;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
properties?: Record<string, string>;
|
||||
subscribed?: boolean;
|
||||
} | null => {
|
||||
const trimmedLine = line.trim();
|
||||
@@ -107,27 +130,95 @@ export default function BulkUploadContacts({
|
||||
|
||||
if (parts.length === 0 || !parts[0]) return null;
|
||||
|
||||
// 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 firstPart = parts[0]?.toLowerCase();
|
||||
if (
|
||||
firstPart === "email" ||
|
||||
firstPart === "e-mail" ||
|
||||
firstPart === "email address"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const email = parts[0]!.toLowerCase();
|
||||
const getHeader = (index: number) => headers?.[index]?.trim().toLowerCase();
|
||||
|
||||
const email = (
|
||||
headers
|
||||
? parts[
|
||||
headers.findIndex((header) => {
|
||||
const normalized = header.trim().toLowerCase();
|
||||
return (
|
||||
normalized === "email" ||
|
||||
normalized === "e-mail" ||
|
||||
normalized === "email address"
|
||||
);
|
||||
})
|
||||
]
|
||||
: parts[0]
|
||||
)?.toLowerCase();
|
||||
|
||||
// Skip if doesn't look like an email
|
||||
if (!email.includes("@")) return null;
|
||||
if (!email || !email.includes("@")) return null;
|
||||
|
||||
// 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;
|
||||
const properties: Record<string, string> = {};
|
||||
|
||||
if (headers) {
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const header = getHeader(i);
|
||||
if (!header) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
header === "email" ||
|
||||
header === "e-mail" ||
|
||||
header === "email address"
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (header === "firstname" || header === "first name") {
|
||||
firstName = parts[i] || undefined;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (header === "lastname" || header === "last name") {
|
||||
lastName = parts[i] || undefined;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (header === "subscribed") {
|
||||
const subscribedValue = parts[i]?.toLowerCase();
|
||||
if (subscribedValue === "yes" || subscribedValue === "true") {
|
||||
subscribed = true;
|
||||
} else if (subscribedValue === "no" || subscribedValue === "false") {
|
||||
subscribed = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (parts[i]) {
|
||||
const propertyKey =
|
||||
getCanonicalContactVariableName(
|
||||
headers[i]!.trim(),
|
||||
contactBookVariables ?? [],
|
||||
) ?? headers[i]!.trim();
|
||||
|
||||
properties[propertyKey] = parts[i]!;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
email,
|
||||
firstName,
|
||||
lastName,
|
||||
subscribed,
|
||||
properties: Object.keys(properties).length > 0 ? properties : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
if (parts.length >= 4) {
|
||||
// Could be: email,firstName,lastName,subscribed
|
||||
@@ -152,6 +243,7 @@ export default function BulkUploadContacts({
|
||||
email,
|
||||
firstName,
|
||||
lastName,
|
||||
properties: Object.keys(properties).length > 0 ? properties : undefined,
|
||||
subscribed,
|
||||
};
|
||||
};
|
||||
@@ -159,9 +251,25 @@ export default function BulkUploadContacts({
|
||||
const parseContacts = (text: string): ParsedContact[] => {
|
||||
const lines = text.split("\n");
|
||||
const contactsMap = new Map<string, ParsedContact>();
|
||||
const firstLineParts = lines[0]
|
||||
?.split(",")
|
||||
.map((part) => part.trim().replace(/^"|"$/g, ""));
|
||||
const hasEmailHeader =
|
||||
firstLineParts?.some((part) => {
|
||||
const normalized = part.toLowerCase();
|
||||
return (
|
||||
normalized === "email" ||
|
||||
normalized === "e-mail" ||
|
||||
normalized === "email address"
|
||||
);
|
||||
}) ?? false;
|
||||
const hasDataEmail =
|
||||
firstLineParts?.some((part) => validateEmail(part)) ?? false;
|
||||
const hasHeader = hasEmailHeader && !hasDataEmail;
|
||||
const headers = hasHeader ? firstLineParts : undefined;
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const parsed = parseContactLine(lines[i]!, i === 0);
|
||||
const parsed = parseContactLine(lines[i]!, headers);
|
||||
if (parsed) {
|
||||
// Use email as key to deduplicate
|
||||
if (!contactsMap.has(parsed.email)) {
|
||||
@@ -267,6 +375,7 @@ export default function BulkUploadContacts({
|
||||
email: c.email,
|
||||
firstName: c.firstName,
|
||||
lastName: c.lastName,
|
||||
properties: c.properties,
|
||||
subscribed: c.subscribed,
|
||||
})),
|
||||
});
|
||||
@@ -293,13 +402,20 @@ export default function BulkUploadContacts({
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline">
|
||||
<Upload className="h-4 w-4 mr-1" />
|
||||
Upload Contacts
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<Dialog
|
||||
open={controlledOpen ?? open}
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (controlledOpen === undefined) {
|
||||
setOpen(nextOpen);
|
||||
return;
|
||||
}
|
||||
|
||||
onOpenChange?.(nextOpen);
|
||||
}}
|
||||
>
|
||||
{dialogTrigger ? (
|
||||
<DialogTrigger asChild>{dialogTrigger}</DialogTrigger>
|
||||
) : null}
|
||||
<DialogContent className="max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Bulk Upload Contacts</DialogTitle>
|
||||
@@ -385,7 +501,8 @@ Format: email,firstName,lastName,subscribed (all fields except email are optiona
|
||||
: "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,subscribed (one per line)
|
||||
Format: email,firstName,lastName,subscribed (+ optional
|
||||
custom columns)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -26,6 +26,7 @@ import EditContact from "./edit-contact";
|
||||
import { ResendDoubleOptInConfirmation } from "./resend-double-opt-in-confirmation";
|
||||
import { Input } from "@usesend/ui/src/input";
|
||||
import { useDebouncedCallback } from "use-debounce";
|
||||
import { getContactPropertyValue } from "~/lib/contact-properties";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -72,10 +73,12 @@ export default function ContactList({
|
||||
contactBookId,
|
||||
contactBookName,
|
||||
doubleOptInEnabled,
|
||||
contactBookVariables,
|
||||
}: {
|
||||
contactBookId: string;
|
||||
contactBookName?: string;
|
||||
doubleOptInEnabled?: boolean;
|
||||
contactBookVariables?: string[];
|
||||
}) {
|
||||
const [page, setPage] = useUrlState("page", "1");
|
||||
const [status, setStatus] = useUrlState("status");
|
||||
@@ -141,6 +144,7 @@ export default function ContactList({
|
||||
"Subscribed",
|
||||
"Unsubscribe Reason",
|
||||
"Created At",
|
||||
...(contactBookVariables ?? []),
|
||||
];
|
||||
|
||||
// CSV Rows
|
||||
@@ -151,6 +155,15 @@ export default function ContactList({
|
||||
escapeCell(contact.subscribed ? "Yes" : "No"),
|
||||
escapeCell(contact.unsubscribeReason ?? ""),
|
||||
escapeCell(contact.createdAt.toISOString()),
|
||||
...(contactBookVariables ?? []).map((variable) =>
|
||||
escapeCell(
|
||||
getContactPropertyValue(
|
||||
(contact.properties as Record<string, unknown> | undefined) ?? {},
|
||||
variable,
|
||||
contactBookVariables ?? [],
|
||||
) ?? "",
|
||||
),
|
||||
),
|
||||
]);
|
||||
|
||||
// Build CSV with UTF-8 BOM
|
||||
@@ -314,7 +327,10 @@ export default function ContactList({
|
||||
email={contact.email}
|
||||
/>
|
||||
) : null}
|
||||
<EditContact contact={contact} />
|
||||
<EditContact
|
||||
contact={contact}
|
||||
contactBookVariables={contactBookVariables}
|
||||
/>
|
||||
<DeleteContact contact={contact} />
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
@@ -20,31 +19,67 @@ import {
|
||||
} from "@usesend/ui/src/form";
|
||||
|
||||
import { api } from "~/trpc/react";
|
||||
import { useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Edit } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { z } from "zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { toast } from "@usesend/ui/src/toaster";
|
||||
import { Switch } from "@usesend/ui/src/switch";
|
||||
import { Contact } from "@prisma/client";
|
||||
import {
|
||||
getContactPropertyValue,
|
||||
replaceContactVariableValues,
|
||||
} from "~/lib/contact-properties";
|
||||
|
||||
const contactSchema = z.object({
|
||||
email: z.string().email({ message: "Invalid email address" }),
|
||||
firstName: z.string().optional(),
|
||||
lastName: z.string().optional(),
|
||||
properties: z.record(z.string()).optional(),
|
||||
subscribed: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const EditContact: React.FC<{
|
||||
contact: Partial<Contact> & { id: string; contactBookId: string };
|
||||
}> = ({ contact }) => {
|
||||
contactBookVariables?: string[];
|
||||
}> = ({ contact, contactBookVariables }) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const updateContactMutation = api.contacts.updateContact.useMutation();
|
||||
const initialVariableValues = useMemo(() => {
|
||||
const contactProperties =
|
||||
contact.properties && typeof contact.properties === "object"
|
||||
? (contact.properties as Record<string, unknown>)
|
||||
: {};
|
||||
|
||||
return (contactBookVariables ?? []).reduce(
|
||||
(acc, variable) => {
|
||||
acc[variable] =
|
||||
getContactPropertyValue(
|
||||
contactProperties,
|
||||
variable,
|
||||
contactBookVariables ?? [],
|
||||
) ?? "";
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string>,
|
||||
);
|
||||
}, [contact.properties, contactBookVariables]);
|
||||
const [variableValues, setVariableValues] = useState(initialVariableValues);
|
||||
|
||||
useEffect(() => {
|
||||
setVariableValues((prev) =>
|
||||
Object.keys(initialVariableValues).reduce(
|
||||
(acc, key) => {
|
||||
acc[key] = prev[key] ?? initialVariableValues[key] ?? "";
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string>,
|
||||
),
|
||||
);
|
||||
}, [initialVariableValues]);
|
||||
|
||||
const utils = api.useUtils();
|
||||
const router = useRouter();
|
||||
|
||||
const contactForm = useForm<z.infer<typeof contactSchema>>({
|
||||
resolver: zodResolver(contactSchema),
|
||||
@@ -62,6 +97,14 @@ export const EditContact: React.FC<{
|
||||
contactId: contact.id,
|
||||
contactBookId: contact.contactBookId,
|
||||
...values,
|
||||
properties: replaceContactVariableValues(
|
||||
(contact.properties as Record<string, unknown> | null | undefined) ??
|
||||
{},
|
||||
Object.fromEntries(
|
||||
Object.entries(variableValues).filter(([, value]) => value.trim()),
|
||||
),
|
||||
contactBookVariables ?? [],
|
||||
),
|
||||
},
|
||||
{
|
||||
onSuccess: async () => {
|
||||
@@ -72,7 +115,7 @@ export const EditContact: React.FC<{
|
||||
onError: async (error) => {
|
||||
toast.error(error.message);
|
||||
},
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -151,6 +194,28 @@ export const EditContact: React.FC<{
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{(contactBookVariables ?? []).map((variable) => {
|
||||
const variableInputId = `contact-variable-${contact.id}-${variable.replace(/[^a-zA-Z0-9_-]/g, "-")}`;
|
||||
|
||||
return (
|
||||
<FormItem key={variable}>
|
||||
<FormLabel htmlFor={variableInputId}>{variable}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
id={variableInputId}
|
||||
placeholder={variable}
|
||||
value={variableValues[variable] ?? ""}
|
||||
onChange={(e) => {
|
||||
setVariableValues((prev) => ({
|
||||
...prev,
|
||||
[variable]: e.target.value,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
);
|
||||
})}
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
className=" w-[100px] "
|
||||
|
||||
@@ -23,7 +23,8 @@ import {
|
||||
import { Button } from "@usesend/ui/src/button";
|
||||
import { Switch } from "@usesend/ui/src/switch";
|
||||
import { useTheme } from "@usesend/ui";
|
||||
import { use } from "react";
|
||||
import { use, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@usesend/ui/src/card";
|
||||
import { TextWithCopyButton } from "@usesend/ui/src/text-with-copy";
|
||||
import {
|
||||
@@ -35,7 +36,140 @@ import {
|
||||
Megaphone,
|
||||
Shield,
|
||||
ChevronRight,
|
||||
MoreVertical,
|
||||
Plus,
|
||||
Upload,
|
||||
Edit,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import EditContactBook from "../edit-contact-book";
|
||||
import DeleteContactBook from "../delete-contact-book";
|
||||
|
||||
function ContactBookDetailActions({
|
||||
contactBookId,
|
||||
contactBookName,
|
||||
contactBookVariables,
|
||||
}: {
|
||||
contactBookId: string;
|
||||
contactBookName?: string;
|
||||
contactBookVariables?: string[];
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [isAddOpen, setIsAddOpen] = useState(false);
|
||||
const [isBulkUploadOpen, setIsBulkUploadOpen] = useState(false);
|
||||
const [isEditOpen, setIsEditOpen] = useState(false);
|
||||
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
|
||||
const utils = api.useUtils();
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="default" className="gap-1">
|
||||
<MoreVertical className="h-4 -ml-2" />
|
||||
Actions
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-52 rounded-xl p-1" align="end">
|
||||
<div className="flex flex-col">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="justify-start rounded-lg hover:bg-accent"
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
setIsAddOpen(true);
|
||||
}}
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add contacts
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="justify-start rounded-lg hover:bg-accent"
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
setIsBulkUploadOpen(true);
|
||||
}}
|
||||
>
|
||||
<Upload className="mr-2 h-4 w-4" />
|
||||
Bulk upload
|
||||
</Button>
|
||||
{contactBookName ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="justify-start rounded-lg hover:bg-accent"
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
setIsEditOpen(true);
|
||||
}}
|
||||
>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
Edit
|
||||
</Button>
|
||||
) : null}
|
||||
{contactBookName ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="justify-start rounded-lg text-red/80 hover:bg-accent hover:text-red"
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
setIsDeleteOpen(true);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<AddContact
|
||||
contactBookId={contactBookId}
|
||||
open={isAddOpen}
|
||||
onOpenChange={setIsAddOpen}
|
||||
/>
|
||||
<BulkUploadContacts
|
||||
contactBookId={contactBookId}
|
||||
contactBookVariables={contactBookVariables}
|
||||
open={isBulkUploadOpen}
|
||||
onOpenChange={setIsBulkUploadOpen}
|
||||
/>
|
||||
{contactBookName ? (
|
||||
<EditContactBook
|
||||
contactBook={{
|
||||
id: contactBookId,
|
||||
name: contactBookName,
|
||||
variables: contactBookVariables,
|
||||
}}
|
||||
open={isEditOpen}
|
||||
onOpenChange={setIsEditOpen}
|
||||
onSuccess={() =>
|
||||
utils.contacts.getContactBookDetails.invalidate({ contactBookId })
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
{contactBookName ? (
|
||||
<DeleteContactBook
|
||||
contactBook={{ id: contactBookId, name: contactBookName }}
|
||||
open={isDeleteOpen}
|
||||
onOpenChange={setIsDeleteOpen}
|
||||
onSuccess={async () => {
|
||||
await utils.contacts.getContactBookDetails.invalidate({
|
||||
contactBookId,
|
||||
});
|
||||
router.push("/contacts");
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ContactsPage({
|
||||
params,
|
||||
@@ -132,10 +266,11 @@ export default function ContactsPage({
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
</div>
|
||||
<div className="flex gap-4">
|
||||
<BulkUploadContacts contactBookId={contactBookId} />
|
||||
<AddContact contactBookId={contactBookId} />
|
||||
</div>
|
||||
<ContactBookDetailActions
|
||||
contactBookId={contactBookId}
|
||||
contactBookName={contactBookDetailQuery.data?.name}
|
||||
contactBookVariables={contactBookDetailQuery.data?.variables}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-10">
|
||||
@@ -212,6 +347,23 @@ export default function ContactsPage({
|
||||
: "--"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-muted-foreground">Variables</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{(contactBookDetailQuery.data?.variables ?? []).length > 0 ? (
|
||||
contactBookDetailQuery.data?.variables.map((variable) => (
|
||||
<span
|
||||
key={variable}
|
||||
className="font-mono text-xs bg-muted px-2 py-0.5 rounded"
|
||||
>
|
||||
{variable}
|
||||
</span>
|
||||
))
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">--</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -329,6 +481,7 @@ export default function ContactsPage({
|
||||
contactBookId={contactBookId}
|
||||
contactBookName={contactBookDetailQuery.data?.name}
|
||||
doubleOptInEnabled={contactBookDetailQuery.data?.doubleOptInEnabled}
|
||||
contactBookVariables={contactBookDetailQuery.data?.variables}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -33,6 +33,7 @@ const contactBookSchema = z.object({
|
||||
name: z.string({ required_error: "Name is required" }).min(1, {
|
||||
message: "Name is required",
|
||||
}),
|
||||
variables: z.string().optional(),
|
||||
});
|
||||
|
||||
export default function AddContactBook() {
|
||||
@@ -51,6 +52,7 @@ export default function AddContactBook() {
|
||||
resolver: zodResolver(contactBookSchema),
|
||||
defaultValues: {
|
||||
name: "",
|
||||
variables: "",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -63,6 +65,10 @@ export default function AddContactBook() {
|
||||
createContactBookMutation.mutate(
|
||||
{
|
||||
name: values.name,
|
||||
variables: values.variables
|
||||
?.split(",")
|
||||
.map((variable) => variable.trim())
|
||||
.filter(Boolean),
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
@@ -71,6 +77,9 @@ export default function AddContactBook() {
|
||||
setOpen(false);
|
||||
toast.success("Contact book created successfully");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message);
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -125,6 +134,25 @@ export default function AddContactBook() {
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={contactBookForm.control}
|
||||
name="variables"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Variables</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="registrationCode, company, plan"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Optional comma-separated variable names for campaign
|
||||
personalization.
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
className=" w-[100px]"
|
||||
|
||||
@@ -7,10 +7,15 @@ import { ContactBook } from "@prisma/client";
|
||||
import { toast } from "@usesend/ui/src/toaster";
|
||||
import { Trash2 } from "lucide-react";
|
||||
import { z } from "zod";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export const DeleteContactBook: React.FC<{
|
||||
contactBook: Partial<ContactBook> & { id: string };
|
||||
}> = ({ contactBook }) => {
|
||||
trigger?: ReactNode;
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
onSuccess?: () => void | Promise<void>;
|
||||
}> = ({ contactBook, trigger, open, onOpenChange, onSuccess }) => {
|
||||
const deleteContactBookMutation =
|
||||
api.contacts.deleteContactBook.useMutation();
|
||||
const utils = api.useUtils();
|
||||
@@ -34,14 +39,23 @@ export const DeleteContactBook: React.FC<{
|
||||
contactBookId: contactBook.id,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
onSuccess: async () => {
|
||||
utils.contacts.getContactBooks.invalidate();
|
||||
await onSuccess?.();
|
||||
toast.success(`Contact book deleted`);
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const dialogTrigger =
|
||||
trigger ??
|
||||
(open === undefined ? (
|
||||
<Button variant="ghost" size="sm" className="p-0 hover:bg-transparent ">
|
||||
<Trash2 className="h-[18px] w-[18px] text-red/80 hover:text-red/70" />
|
||||
</Button>
|
||||
) : null);
|
||||
|
||||
return (
|
||||
<DeleteResource
|
||||
title="Delete Contact Book"
|
||||
@@ -49,11 +63,9 @@ export const DeleteContactBook: React.FC<{
|
||||
schema={contactBookSchema}
|
||||
isLoading={deleteContactBookMutation.isPending}
|
||||
onConfirm={onContactBookDelete}
|
||||
trigger={
|
||||
<Button variant="ghost" size="sm" className="p-0 hover:bg-transparent ">
|
||||
<Trash2 className="h-[18px] w-[18px] text-red/80 hover:text-red/70" />
|
||||
</Button>
|
||||
}
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
trigger={dialogTrigger}
|
||||
confirmLabel="Delete Contact Book"
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
@@ -24,63 +25,99 @@ import { z } from "zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { toast } from "@usesend/ui/src/toaster";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
const contactBookSchema = z.object({
|
||||
name: z.string().min(1, { message: "Name is required" }),
|
||||
variables: z.string().optional(),
|
||||
});
|
||||
|
||||
export const EditContactBook: React.FC<{
|
||||
contactBook: { id: string; name: string };
|
||||
}> = ({ contactBook }) => {
|
||||
contactBook: { id: string; name: string; variables?: string[] };
|
||||
trigger?: ReactNode;
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
onSuccess?: () => void | Promise<void>;
|
||||
}> = ({
|
||||
contactBook,
|
||||
trigger,
|
||||
open: controlledOpen,
|
||||
onOpenChange,
|
||||
onSuccess,
|
||||
}) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const updateContactBookMutation =
|
||||
api.contacts.updateContactBook.useMutation();
|
||||
|
||||
const utils = api.useUtils();
|
||||
const dialogTrigger =
|
||||
trigger ??
|
||||
(controlledOpen === undefined ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="p-0 hover:bg-transparent"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Edit className="h-4 w-4 text-foreground/80 hover:text-foreground/70" />
|
||||
</Button>
|
||||
) : null);
|
||||
|
||||
const contactBookForm = useForm<z.infer<typeof contactBookSchema>>({
|
||||
resolver: zodResolver(contactBookSchema),
|
||||
defaultValues: {
|
||||
name: contactBook.name || "",
|
||||
variables: (contactBook.variables ?? []).join(", "),
|
||||
},
|
||||
});
|
||||
|
||||
async function onContactBookUpdate(
|
||||
values: z.infer<typeof contactBookSchema>
|
||||
values: z.infer<typeof contactBookSchema>,
|
||||
) {
|
||||
updateContactBookMutation.mutate(
|
||||
{
|
||||
contactBookId: contactBook.id,
|
||||
...values,
|
||||
name: values.name,
|
||||
variables: values.variables
|
||||
?.split(",")
|
||||
.map((variable) => variable.trim())
|
||||
.filter(Boolean),
|
||||
},
|
||||
{
|
||||
onSuccess: async () => {
|
||||
utils.contacts.getContactBooks.invalidate();
|
||||
setOpen(false);
|
||||
await onSuccess?.();
|
||||
if (controlledOpen === undefined) {
|
||||
setOpen(false);
|
||||
} else {
|
||||
onOpenChange?.(false);
|
||||
}
|
||||
toast.success("Contact book updated successfully");
|
||||
},
|
||||
onError: async (error) => {
|
||||
toast.error(error.message);
|
||||
},
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(_open) => (_open !== open ? setOpen(_open) : null)}
|
||||
open={controlledOpen ?? open}
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (controlledOpen === undefined) {
|
||||
if (nextOpen !== open) {
|
||||
setOpen(nextOpen);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
onOpenChange?.(nextOpen);
|
||||
}}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="p-0 hover:bg-transparent"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Edit className="h-4 w-4 text-foreground/80 hover:text-foreground/70" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
{dialogTrigger ? (
|
||||
<DialogTrigger asChild>{dialogTrigger}</DialogTrigger>
|
||||
) : null}
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Contact Book</DialogTitle>
|
||||
@@ -104,6 +141,25 @@ export const EditContactBook: React.FC<{
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={contactBookForm.control}
|
||||
name="variables"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Variables</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="registrationCode, company, plan"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Comma-separated variable names available in campaigns for
|
||||
this contact book.
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
className=" w-[100px]"
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
export function getCanonicalContactVariableName(
|
||||
key: string,
|
||||
allowedVariables: string[] = [],
|
||||
) {
|
||||
const normalizedKey = key.trim().toLowerCase();
|
||||
|
||||
return allowedVariables.find(
|
||||
(variable) => variable.toLowerCase() === normalizedKey,
|
||||
);
|
||||
}
|
||||
|
||||
export function normalizeContactProperties(
|
||||
properties?: Record<string, unknown> | null,
|
||||
allowedVariables: string[] = [],
|
||||
) {
|
||||
const normalizedProperties: Record<string, unknown> = {};
|
||||
|
||||
for (const [key, value] of Object.entries(properties ?? {})) {
|
||||
const canonicalKey = getCanonicalContactVariableName(key, allowedVariables);
|
||||
normalizedProperties[canonicalKey ?? key] = value;
|
||||
}
|
||||
|
||||
return normalizedProperties;
|
||||
}
|
||||
|
||||
export function getContactPropertyValue(
|
||||
properties: Record<string, unknown> | null | undefined,
|
||||
key: string,
|
||||
allowedVariables: string[] = [],
|
||||
) {
|
||||
const normalizedKey = key.toLowerCase();
|
||||
const canonicalKey = getCanonicalContactVariableName(key, allowedVariables);
|
||||
|
||||
const propertyKey = Object.keys(properties ?? {}).find((candidate) => {
|
||||
const normalizedCandidate = candidate.toLowerCase();
|
||||
|
||||
return (
|
||||
normalizedCandidate === normalizedKey ||
|
||||
normalizedCandidate === canonicalKey?.toLowerCase()
|
||||
);
|
||||
});
|
||||
|
||||
const propertyValue = propertyKey ? properties?.[propertyKey] : undefined;
|
||||
|
||||
if (
|
||||
typeof propertyValue === "string" ||
|
||||
typeof propertyValue === "number" ||
|
||||
typeof propertyValue === "boolean"
|
||||
) {
|
||||
return String(propertyValue);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function mergeContactProperties(
|
||||
existingProperties?: Record<string, unknown> | null,
|
||||
incomingProperties?: Record<string, unknown> | null,
|
||||
allowedVariables: string[] = [],
|
||||
) {
|
||||
return {
|
||||
...normalizeContactProperties(existingProperties, allowedVariables),
|
||||
...normalizeContactProperties(incomingProperties, allowedVariables),
|
||||
};
|
||||
}
|
||||
|
||||
export function replaceContactVariableValues(
|
||||
existingProperties: Record<string, unknown> | null | undefined,
|
||||
variableValues: Record<string, unknown>,
|
||||
allowedVariables: string[] = [],
|
||||
) {
|
||||
const normalizedExistingProperties = normalizeContactProperties(
|
||||
existingProperties,
|
||||
allowedVariables,
|
||||
);
|
||||
|
||||
for (const key of Object.keys(normalizedExistingProperties)) {
|
||||
if (getCanonicalContactVariableName(key, allowedVariables)) {
|
||||
delete normalizedExistingProperties[key];
|
||||
}
|
||||
}
|
||||
|
||||
return mergeContactProperties(
|
||||
normalizedExistingProperties,
|
||||
variableValues,
|
||||
allowedVariables,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
getContactPropertyValue,
|
||||
normalizeContactProperties,
|
||||
replaceContactVariableValues,
|
||||
} from "~/lib/contact-properties";
|
||||
|
||||
describe("contact-properties", () => {
|
||||
it("normalizes registered property keys to the canonical variable casing", () => {
|
||||
expect(
|
||||
normalizeContactProperties(
|
||||
{
|
||||
Company: "Acme",
|
||||
tier: "gold",
|
||||
PlanName: "Pro",
|
||||
},
|
||||
["company", "planName"],
|
||||
),
|
||||
).toEqual({
|
||||
company: "Acme",
|
||||
tier: "gold",
|
||||
planName: "Pro",
|
||||
});
|
||||
});
|
||||
|
||||
it("reads property values case-insensitively for registered variables", () => {
|
||||
expect(
|
||||
getContactPropertyValue(
|
||||
{
|
||||
Company: "Acme",
|
||||
},
|
||||
"company",
|
||||
["company"],
|
||||
),
|
||||
).toBe("Acme");
|
||||
});
|
||||
|
||||
it("replaces registry-backed values while preserving unrelated properties", () => {
|
||||
expect(
|
||||
replaceContactVariableValues(
|
||||
{
|
||||
Company: "Old Co",
|
||||
tier: "gold",
|
||||
notes: "keep me",
|
||||
},
|
||||
{
|
||||
company: "New Co",
|
||||
},
|
||||
["company", "plan"],
|
||||
),
|
||||
).toEqual({
|
||||
notes: "keep me",
|
||||
tier: "gold",
|
||||
company: "New Co",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -14,6 +14,10 @@ export const ContactBookSchema = z.object({
|
||||
description: "Custom properties for the contact book",
|
||||
example: { customField1: "value1" },
|
||||
}),
|
||||
variables: z.array(z.string()).openapi({
|
||||
description: "Allowed personalization variables for contacts in this book",
|
||||
example: ["registrationCode", "company"],
|
||||
}),
|
||||
emoji: z.string().openapi({
|
||||
description: "The emoji associated with the contact book",
|
||||
example: "📙",
|
||||
|
||||
@@ -11,201 +11,204 @@ import * as contactService from "~/server/service/contact-service";
|
||||
import * as contactBookService from "~/server/service/contact-book-service";
|
||||
|
||||
export const contactsRouter = createTRPCRouter({
|
||||
getContactBooks: teamProcedure
|
||||
.input(z.object({ search: z.string().optional() }))
|
||||
.query(async ({ ctx: { team }, input }) => {
|
||||
return contactBookService.getContactBooks(team.id, input.search);
|
||||
}),
|
||||
getContactBooks: teamProcedure
|
||||
.input(z.object({ search: z.string().optional() }))
|
||||
.query(async ({ ctx: { team }, input }) => {
|
||||
return contactBookService.getContactBooks(team.id, input.search);
|
||||
}),
|
||||
|
||||
createContactBook: teamProcedure
|
||||
.input(
|
||||
z.object({
|
||||
name: z.string(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx: { team }, input }) => {
|
||||
const { name } = input;
|
||||
return contactBookService.createContactBook(team.id, name);
|
||||
}),
|
||||
createContactBook: teamProcedure
|
||||
.input(
|
||||
z.object({
|
||||
name: z.string(),
|
||||
variables: z.array(z.string()).optional(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx: { team }, input }) => {
|
||||
const { name, variables } = input;
|
||||
return contactBookService.createContactBook(team.id, name, variables);
|
||||
}),
|
||||
|
||||
getContactBookDetails: contactBookProcedure.query(
|
||||
async ({ ctx: { contactBook } }) => {
|
||||
const { totalContacts, unsubscribedContacts, campaigns } =
|
||||
await contactBookService.getContactBookDetails(contactBook.id);
|
||||
getContactBookDetails: contactBookProcedure.query(
|
||||
async ({ ctx: { contactBook } }) => {
|
||||
const { totalContacts, unsubscribedContacts, campaigns } =
|
||||
await contactBookService.getContactBookDetails(contactBook.id);
|
||||
|
||||
return {
|
||||
...contactBook,
|
||||
totalContacts,
|
||||
unsubscribedContacts,
|
||||
campaigns,
|
||||
};
|
||||
},
|
||||
),
|
||||
return {
|
||||
...contactBook,
|
||||
totalContacts,
|
||||
unsubscribedContacts,
|
||||
campaigns,
|
||||
};
|
||||
},
|
||||
),
|
||||
|
||||
updateContactBook: contactBookProcedure
|
||||
.input(
|
||||
z.object({
|
||||
contactBookId: z.string(),
|
||||
name: z.string().optional(),
|
||||
properties: z.record(z.string()).optional(),
|
||||
emoji: z.string().optional(),
|
||||
doubleOptInEnabled: z.boolean().optional(),
|
||||
doubleOptInFrom: z.string().nullable().optional(),
|
||||
doubleOptInSubject: z.string().optional(),
|
||||
doubleOptInContent: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx: { contactBook }, input }) => {
|
||||
const { contactBookId, ...data } = input;
|
||||
return contactBookService.updateContactBook(contactBook.id, data);
|
||||
}),
|
||||
updateContactBook: contactBookProcedure
|
||||
.input(
|
||||
z.object({
|
||||
contactBookId: z.string(),
|
||||
name: z.string().optional(),
|
||||
properties: z.record(z.string()).optional(),
|
||||
emoji: z.string().optional(),
|
||||
doubleOptInEnabled: z.boolean().optional(),
|
||||
doubleOptInFrom: z.string().nullable().optional(),
|
||||
doubleOptInSubject: z.string().optional(),
|
||||
doubleOptInContent: z.string().optional(),
|
||||
variables: z.array(z.string()).optional(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx: { contactBook }, input }) => {
|
||||
const { contactBookId, ...data } = input;
|
||||
return contactBookService.updateContactBook(contactBook.id, data);
|
||||
}),
|
||||
|
||||
deleteContactBook: contactBookProcedure
|
||||
.input(z.object({ contactBookId: z.string() }))
|
||||
.mutation(async ({ ctx: { contactBook }, input }) => {
|
||||
return contactBookService.deleteContactBook(contactBook.id);
|
||||
}),
|
||||
deleteContactBook: contactBookProcedure
|
||||
.input(z.object({ contactBookId: z.string() }))
|
||||
.mutation(async ({ ctx: { contactBook }, input }) => {
|
||||
return contactBookService.deleteContactBook(contactBook.id);
|
||||
}),
|
||||
|
||||
contacts: contactBookProcedure
|
||||
.input(
|
||||
z.object({
|
||||
page: z.number().optional(),
|
||||
subscribed: z.boolean().optional(),
|
||||
search: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx: { db }, input }) => {
|
||||
const page = input.page || 1;
|
||||
const limit = 30;
|
||||
const offset = (page - 1) * limit;
|
||||
contacts: contactBookProcedure
|
||||
.input(
|
||||
z.object({
|
||||
page: z.number().optional(),
|
||||
subscribed: z.boolean().optional(),
|
||||
search: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx: { db }, input }) => {
|
||||
const page = input.page || 1;
|
||||
const limit = 30;
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
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 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 countP = db.contact.count({ where: whereConditions });
|
||||
const countP = db.contact.count({ where: whereConditions });
|
||||
|
||||
const contactsP = db.contact.findMany({
|
||||
where: whereConditions,
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
subscribed: true,
|
||||
createdAt: true,
|
||||
contactBookId: true,
|
||||
unsubscribeReason: true,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
skip: offset,
|
||||
take: limit,
|
||||
});
|
||||
const contactsP = db.contact.findMany({
|
||||
where: whereConditions,
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
properties: true,
|
||||
subscribed: true,
|
||||
createdAt: true,
|
||||
contactBookId: true,
|
||||
unsubscribeReason: true,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
skip: offset,
|
||||
take: limit,
|
||||
});
|
||||
|
||||
const [contacts, count] = await Promise.all([contactsP, countP]);
|
||||
const [contacts, count] = await Promise.all([contactsP, countP]);
|
||||
|
||||
return { contacts, totalPage: Math.ceil(count / limit) };
|
||||
}),
|
||||
return { contacts, totalPage: Math.ceil(count / limit) };
|
||||
}),
|
||||
|
||||
addContacts: contactBookProcedure
|
||||
.input(
|
||||
z.object({
|
||||
contacts: z
|
||||
.array(
|
||||
z.object({
|
||||
email: z.string(),
|
||||
firstName: z.string().optional(),
|
||||
lastName: z.string().optional(),
|
||||
properties: z.record(z.string()).optional(),
|
||||
subscribed: z.boolean().optional(),
|
||||
}),
|
||||
)
|
||||
.max(50000),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx: { contactBook, team }, input }) => {
|
||||
return contactService.bulkAddContacts(
|
||||
contactBook.id,
|
||||
input.contacts,
|
||||
team.id,
|
||||
);
|
||||
}),
|
||||
addContacts: contactBookProcedure
|
||||
.input(
|
||||
z.object({
|
||||
contacts: z
|
||||
.array(
|
||||
z.object({
|
||||
email: z.string(),
|
||||
firstName: z.string().optional(),
|
||||
lastName: z.string().optional(),
|
||||
properties: z.record(z.string()).optional(),
|
||||
subscribed: z.boolean().optional(),
|
||||
}),
|
||||
)
|
||||
.max(50000),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx: { contactBook, team }, input }) => {
|
||||
return contactService.bulkAddContacts(
|
||||
contactBook.id,
|
||||
input.contacts,
|
||||
team.id,
|
||||
);
|
||||
}),
|
||||
|
||||
updateContact: contactBookProcedure
|
||||
.input(
|
||||
z.object({
|
||||
contactId: z.string(),
|
||||
email: z.string().optional(),
|
||||
firstName: z.string().optional(),
|
||||
lastName: z.string().optional(),
|
||||
properties: z.record(z.string()).optional(),
|
||||
subscribed: z.boolean().optional(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx: { contactBook, team }, input }) => {
|
||||
const { contactId, ...contact } = input;
|
||||
const updatedContact = await contactService.updateContactInContactBook(
|
||||
contactId,
|
||||
contactBook.id,
|
||||
contact,
|
||||
team.id,
|
||||
);
|
||||
updateContact: contactBookProcedure
|
||||
.input(
|
||||
z.object({
|
||||
contactId: z.string(),
|
||||
email: z.string().optional(),
|
||||
firstName: z.string().optional(),
|
||||
lastName: z.string().optional(),
|
||||
properties: z.record(z.string()).optional(),
|
||||
subscribed: z.boolean().optional(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx: { contactBook, team }, input }) => {
|
||||
const { contactId, ...contact } = input;
|
||||
const updatedContact = await contactService.updateContactInContactBook(
|
||||
contactId,
|
||||
contactBook.id,
|
||||
contact,
|
||||
team.id,
|
||||
);
|
||||
|
||||
if (!updatedContact) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Contact not found",
|
||||
});
|
||||
}
|
||||
if (!updatedContact) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Contact not found",
|
||||
});
|
||||
}
|
||||
|
||||
return updatedContact;
|
||||
}),
|
||||
return updatedContact;
|
||||
}),
|
||||
|
||||
deleteContact: contactBookProcedure
|
||||
.input(z.object({ contactId: z.string() }))
|
||||
.mutation(async ({ ctx: { contactBook, team }, input }) => {
|
||||
const deletedContact = await contactService.deleteContactInContactBook(
|
||||
input.contactId,
|
||||
contactBook.id,
|
||||
team.id,
|
||||
);
|
||||
deleteContact: contactBookProcedure
|
||||
.input(z.object({ contactId: z.string() }))
|
||||
.mutation(async ({ ctx: { contactBook, team }, input }) => {
|
||||
const deletedContact = await contactService.deleteContactInContactBook(
|
||||
input.contactId,
|
||||
contactBook.id,
|
||||
team.id,
|
||||
);
|
||||
|
||||
if (!deletedContact) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Contact not found",
|
||||
});
|
||||
}
|
||||
if (!deletedContact) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Contact not found",
|
||||
});
|
||||
}
|
||||
|
||||
return deletedContact;
|
||||
}),
|
||||
return deletedContact;
|
||||
}),
|
||||
|
||||
bulkDeleteContacts: contactBookProcedure
|
||||
.input(z.object({ contactIds: z.array(z.string()).min(1).max(1000) }))
|
||||
.mutation(async ({ ctx: { contactBook, team }, input }) => {
|
||||
const deletedContacts =
|
||||
await contactService.bulkDeleteContactsInContactBook(
|
||||
input.contactIds,
|
||||
contactBook.id,
|
||||
team.id,
|
||||
);
|
||||
bulkDeleteContacts: contactBookProcedure
|
||||
.input(z.object({ contactIds: z.array(z.string()).min(1).max(1000) }))
|
||||
.mutation(async ({ ctx: { contactBook, team }, input }) => {
|
||||
const deletedContacts =
|
||||
await contactService.bulkDeleteContactsInContactBook(
|
||||
input.contactIds,
|
||||
contactBook.id,
|
||||
team.id,
|
||||
);
|
||||
|
||||
return { count: deletedContacts.length };
|
||||
}),
|
||||
return { count: deletedContacts.length };
|
||||
}),
|
||||
|
||||
resendDoubleOptInConfirmation: contactBookProcedure
|
||||
.input(z.object({ contactId: z.string() }))
|
||||
@@ -276,6 +279,7 @@ export const contactsRouter = createTRPCRouter({
|
||||
email: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
properties: true,
|
||||
subscribed: true,
|
||||
unsubscribeReason: true,
|
||||
createdAt: true,
|
||||
|
||||
@@ -56,6 +56,7 @@ function buildContactBook(overrides?: Record<string, unknown>) {
|
||||
name: "Newsletter",
|
||||
teamId: 1,
|
||||
properties: {},
|
||||
variables: [],
|
||||
emoji: "📙",
|
||||
doubleOptInEnabled: true,
|
||||
doubleOptInFrom: null,
|
||||
@@ -116,6 +117,7 @@ describe("POST /v1/contactBooks", () => {
|
||||
expect(mockCreateContactBook).toHaveBeenCalledWith(
|
||||
1,
|
||||
"Newsletter",
|
||||
undefined,
|
||||
mockTransactionClient,
|
||||
);
|
||||
expect(mockUpdateContactBook).not.toHaveBeenCalled();
|
||||
@@ -169,6 +171,7 @@ describe("POST /v1/contactBooks", () => {
|
||||
expect(mockCreateContactBook).toHaveBeenCalledWith(
|
||||
1,
|
||||
"Product Updates",
|
||||
undefined,
|
||||
mockTransactionClient,
|
||||
);
|
||||
expect(mockUpdateContactBook).toHaveBeenCalledWith(
|
||||
@@ -224,6 +227,45 @@ describe("POST /v1/contactBooks", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("passes variables through createContactBook and returns them", async () => {
|
||||
const created = buildContactBook({
|
||||
id: "cb_4",
|
||||
name: "Customers",
|
||||
variables: ["company", "plan"],
|
||||
});
|
||||
mockCreateContactBook.mockResolvedValue(created);
|
||||
|
||||
const app = getApp();
|
||||
createContactBookRoute(app);
|
||||
|
||||
const response = await app.request("http://localhost/api/v1/contactBooks", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: "Bearer test-key",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: "Customers",
|
||||
variables: ["company", "plan"],
|
||||
}),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockCreateContactBook).toHaveBeenCalledWith(
|
||||
1,
|
||||
"Customers",
|
||||
["company", "plan"],
|
||||
mockTransactionClient,
|
||||
);
|
||||
|
||||
const body = await response.json();
|
||||
expect(body).toMatchObject({
|
||||
id: "cb_4",
|
||||
name: "Customers",
|
||||
variables: ["company", "plan"],
|
||||
});
|
||||
});
|
||||
|
||||
it("returns BAD_REQUEST when name is missing", async () => {
|
||||
const app = getApp();
|
||||
createContactBookRoute(app);
|
||||
|
||||
@@ -23,6 +23,7 @@ const route = createRoute({
|
||||
doubleOptInFrom: z.string().nullable().optional(),
|
||||
doubleOptInSubject: z.string().optional(),
|
||||
doubleOptInContent: z.string().optional(),
|
||||
variables: z.array(z.string()).optional(),
|
||||
}),
|
||||
},
|
||||
},
|
||||
@@ -54,7 +55,12 @@ function createContactBook(app: PublicAPIApp) {
|
||||
body.doubleOptInContent !== undefined;
|
||||
|
||||
const contactBook = await db.$transaction(async (tx) => {
|
||||
const created = await createContactBookService(team.id, body.name, tx);
|
||||
const created = await createContactBookService(
|
||||
team.id,
|
||||
body.name,
|
||||
body.variables,
|
||||
tx,
|
||||
);
|
||||
|
||||
if (!hasOptionalFields) {
|
||||
return created;
|
||||
@@ -77,6 +83,7 @@ function createContactBook(app: PublicAPIApp) {
|
||||
return c.json({
|
||||
...contactBook,
|
||||
properties: contactBook.properties as Record<string, string>,
|
||||
variables: contactBook.variables,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -5,81 +5,81 @@ import { db } from "~/server/db";
|
||||
import { UnsendApiError } from "../../api-error";
|
||||
|
||||
const route = createRoute({
|
||||
method: "get",
|
||||
path: "/v1/contactBooks/{contactBookId}",
|
||||
request: {
|
||||
params: z.object({
|
||||
contactBookId: z.string().openapi({
|
||||
param: {
|
||||
name: "contactBookId",
|
||||
in: "path",
|
||||
},
|
||||
example: "clx1234567890",
|
||||
}),
|
||||
}),
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: ContactBookSchema,
|
||||
},
|
||||
},
|
||||
description: "Retrieve the contact book",
|
||||
},
|
||||
403: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z.object({
|
||||
error: z.string(),
|
||||
}),
|
||||
},
|
||||
},
|
||||
description:
|
||||
"Forbidden - API key doesn't have access to this contact book",
|
||||
},
|
||||
404: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z.object({
|
||||
error: z.string(),
|
||||
}),
|
||||
},
|
||||
},
|
||||
description: "Contact book not found",
|
||||
},
|
||||
},
|
||||
method: "get",
|
||||
path: "/v1/contactBooks/{contactBookId}",
|
||||
request: {
|
||||
params: z.object({
|
||||
contactBookId: z.string().openapi({
|
||||
param: {
|
||||
name: "contactBookId",
|
||||
in: "path",
|
||||
},
|
||||
example: "clx1234567890",
|
||||
}),
|
||||
}),
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: ContactBookSchema,
|
||||
},
|
||||
},
|
||||
description: "Retrieve the contact book",
|
||||
},
|
||||
403: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z.object({
|
||||
error: z.string(),
|
||||
}),
|
||||
},
|
||||
},
|
||||
description:
|
||||
"Forbidden - API key doesn't have access to this contact book",
|
||||
},
|
||||
404: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z.object({
|
||||
error: z.string(),
|
||||
}),
|
||||
},
|
||||
},
|
||||
description: "Contact book not found",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
function getContactBook(app: PublicAPIApp) {
|
||||
app.openapi(route, async (c) => {
|
||||
const team = c.var.team;
|
||||
const contactBookId = c.req.valid("param").contactBookId;
|
||||
app.openapi(route, async (c) => {
|
||||
const team = c.var.team;
|
||||
const contactBookId = c.req.valid("param").contactBookId;
|
||||
|
||||
const contactBook = await db.contactBook.findFirst({
|
||||
where: {
|
||||
id: contactBookId,
|
||||
teamId: team.id,
|
||||
},
|
||||
include: {
|
||||
_count: {
|
||||
select: { contacts: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
const contactBook = await db.contactBook.findFirst({
|
||||
where: {
|
||||
id: contactBookId,
|
||||
teamId: team.id,
|
||||
},
|
||||
include: {
|
||||
_count: {
|
||||
select: { contacts: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!contactBook) {
|
||||
throw new UnsendApiError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Contact book not found",
|
||||
});
|
||||
}
|
||||
if (!contactBook) {
|
||||
throw new UnsendApiError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Contact book not found",
|
||||
});
|
||||
}
|
||||
|
||||
return c.json({
|
||||
...contactBook,
|
||||
properties: contactBook.properties as Record<string, string>,
|
||||
});
|
||||
});
|
||||
return c.json({
|
||||
...contactBook,
|
||||
properties: contactBook.properties as Record<string, string>,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export default getContactBook;
|
||||
|
||||
@@ -4,34 +4,35 @@ import { PublicAPIApp } from "~/server/public-api/hono";
|
||||
import { getContactBooks as getContactBooksService } from "~/server/service/contact-book-service";
|
||||
|
||||
const route = createRoute({
|
||||
method: "get",
|
||||
path: "/v1/contactBooks",
|
||||
responses: {
|
||||
200: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z.array(ContactBookSchema),
|
||||
},
|
||||
},
|
||||
description: "Retrieve contact books accessible by the API key",
|
||||
},
|
||||
},
|
||||
method: "get",
|
||||
path: "/v1/contactBooks",
|
||||
responses: {
|
||||
200: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z.array(ContactBookSchema),
|
||||
},
|
||||
},
|
||||
description: "Retrieve contact books accessible by the API key",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
function getContactBooks(app: PublicAPIApp) {
|
||||
app.openapi(route, async (c) => {
|
||||
const team = c.var.team;
|
||||
app.openapi(route, async (c) => {
|
||||
const team = c.var.team;
|
||||
|
||||
const contactBooks = await getContactBooksService(team.id);
|
||||
const contactBooks = await getContactBooksService(team.id);
|
||||
|
||||
// Ensure properties is a Record<string, string>
|
||||
const sanitizedContactBooks = contactBooks.map((contactBook) => ({
|
||||
...contactBook,
|
||||
properties: contactBook.properties as Record<string, string>,
|
||||
}));
|
||||
// Ensure properties is a Record<string, string>
|
||||
const sanitizedContactBooks = contactBooks.map((contactBook) => ({
|
||||
...contactBook,
|
||||
properties: contactBook.properties as Record<string, string>,
|
||||
variables: contactBook.variables,
|
||||
}));
|
||||
|
||||
return c.json(sanitizedContactBooks);
|
||||
});
|
||||
return c.json(sanitizedContactBooks);
|
||||
});
|
||||
}
|
||||
|
||||
export default getContactBooks;
|
||||
|
||||
@@ -29,6 +29,7 @@ const route = createRoute({
|
||||
doubleOptInFrom: z.string().nullable().optional(),
|
||||
doubleOptInSubject: z.string().optional(),
|
||||
doubleOptInContent: z.string().optional(),
|
||||
variables: z.array(z.string()).optional(),
|
||||
}),
|
||||
},
|
||||
},
|
||||
@@ -80,6 +81,7 @@ function updateContactBook(app: PublicAPIApp) {
|
||||
return c.json({
|
||||
...updated,
|
||||
properties: updated.properties as Record<string, string>,
|
||||
variables: updated.variables,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { EmailRenderer } from "@usesend/email-editor/src/renderer";
|
||||
import { db } from "../db";
|
||||
import { createHash } from "crypto";
|
||||
import { env } from "~/env";
|
||||
import { getContactPropertyValue } from "~/lib/contact-properties";
|
||||
import {
|
||||
Campaign,
|
||||
Contact,
|
||||
@@ -36,13 +37,88 @@ const CAMPAIGN_UNSUB_PLACEHOLDER_REGEXES =
|
||||
});
|
||||
|
||||
const CONTACT_VARIABLE_REGEX =
|
||||
/\{\{\s*(?:contact\.)?(email|firstName|lastName)(?:,fallback=([^}]+))?\s*\}\}/gi;
|
||||
/\{\{\s*(?:contact\.)?([a-zA-Z0-9_]+)(?:,fallback=([^}]+))?\s*\}\}/gi;
|
||||
|
||||
const BUILT_IN_CONTACT_VARIABLES = ["email", "firstName", "lastName"] as const;
|
||||
|
||||
function getContactReplacementValue({
|
||||
contact,
|
||||
key,
|
||||
allowedVariables,
|
||||
}: {
|
||||
contact: Contact;
|
||||
key: string;
|
||||
allowedVariables: string[];
|
||||
}) {
|
||||
const normalizedKey = key.toLowerCase();
|
||||
|
||||
if (normalizedKey === "email") {
|
||||
return contact.email;
|
||||
}
|
||||
|
||||
if (normalizedKey === "firstname") {
|
||||
return contact.firstName;
|
||||
}
|
||||
|
||||
if (normalizedKey === "lastname") {
|
||||
return contact.lastName;
|
||||
}
|
||||
|
||||
const variableMap = new Map(
|
||||
allowedVariables.map((variable) => [variable.toLowerCase(), variable]),
|
||||
);
|
||||
const matchedVariable = variableMap.get(normalizedKey);
|
||||
if (!matchedVariable) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (!contact.properties || typeof contact.properties !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return getContactPropertyValue(
|
||||
contact.properties as Record<string, unknown>,
|
||||
matchedVariable,
|
||||
allowedVariables,
|
||||
);
|
||||
}
|
||||
|
||||
function createCaseInsensitiveVariableValues(
|
||||
values: Record<string, string | null | undefined>,
|
||||
) {
|
||||
const normalizedValues = Object.entries(values).reduce(
|
||||
(acc, [key, value]) => {
|
||||
if (value !== undefined) {
|
||||
acc[key] = value;
|
||||
acc[key.toLowerCase()] = value;
|
||||
}
|
||||
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string | null>,
|
||||
);
|
||||
|
||||
return new Proxy(normalizedValues, {
|
||||
get(target, prop, receiver) {
|
||||
if (typeof prop === "string") {
|
||||
const exact = Reflect.get(target, prop, receiver);
|
||||
if (exact !== undefined) {
|
||||
return exact;
|
||||
}
|
||||
|
||||
return Reflect.get(target, prop.toLowerCase(), receiver);
|
||||
}
|
||||
|
||||
return Reflect.get(target, prop, receiver);
|
||||
},
|
||||
}) as Record<string, string | null>;
|
||||
}
|
||||
|
||||
function campaignHasUnsubscribePlaceholder(
|
||||
...sources: Array<string | null | undefined>
|
||||
) {
|
||||
return CAMPAIGN_UNSUB_PLACEHOLDER_REGEXES.some((regex) =>
|
||||
sources.some((source) => (source ? regex.test(source) : false))
|
||||
sources.some((source) => (source ? regex.test(source) : false)),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -52,25 +128,38 @@ function replaceUnsubscribePlaceholders(html: string, url: string) {
|
||||
}, html);
|
||||
}
|
||||
|
||||
function replaceContactVariables(html: string, contact: Contact) {
|
||||
function replaceContactVariables(
|
||||
html: string,
|
||||
contact: Contact,
|
||||
allowedVariables: string[],
|
||||
) {
|
||||
return html.replace(
|
||||
CONTACT_VARIABLE_REGEX,
|
||||
(_, key: string, fallback?: string) => {
|
||||
const valueMap: Record<string, string | null | undefined> = {
|
||||
email: contact.email,
|
||||
firstname: contact.firstName,
|
||||
lastname: contact.lastName,
|
||||
};
|
||||
|
||||
(match: string, key: string, fallback?: string) => {
|
||||
const normalizedKey = key.toLowerCase();
|
||||
const contactValue = valueMap[normalizedKey];
|
||||
const isBuiltIn = BUILT_IN_CONTACT_VARIABLES.some(
|
||||
(variable) => variable.toLowerCase() === normalizedKey,
|
||||
);
|
||||
const isAllowedRegistryVariable = allowedVariables.some(
|
||||
(variable) => variable.toLowerCase() === normalizedKey,
|
||||
);
|
||||
|
||||
if (!isBuiltIn && !isAllowedRegistryVariable) {
|
||||
return match;
|
||||
}
|
||||
|
||||
const contactValue = getContactReplacementValue({
|
||||
contact,
|
||||
key,
|
||||
allowedVariables,
|
||||
});
|
||||
|
||||
if (contactValue && contactValue.length > 0) {
|
||||
return contactValue;
|
||||
}
|
||||
|
||||
return fallback ?? "";
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -87,7 +176,7 @@ function sanitizeAddressList(addresses?: string | string[]) {
|
||||
}
|
||||
|
||||
async function prepareCampaignHtml(
|
||||
campaign: Campaign
|
||||
campaign: Campaign,
|
||||
): Promise<{ campaign: Campaign; html: string }> {
|
||||
if (campaign.content) {
|
||||
try {
|
||||
@@ -120,10 +209,12 @@ async function renderCampaignHtmlForContact({
|
||||
campaign,
|
||||
contact,
|
||||
unsubscribeUrl,
|
||||
allowedVariables,
|
||||
}: {
|
||||
campaign: Campaign;
|
||||
contact: Contact;
|
||||
unsubscribeUrl: string;
|
||||
allowedVariables: string[];
|
||||
}) {
|
||||
if (campaign.content) {
|
||||
try {
|
||||
@@ -135,13 +226,31 @@ async function renderCampaignHtmlForContact({
|
||||
linkValues[token] = unsubscribeUrl;
|
||||
}
|
||||
|
||||
const variableValues = createCaseInsensitiveVariableValues({
|
||||
email: contact.email,
|
||||
firstName: contact.firstName,
|
||||
lastName: contact.lastName,
|
||||
...allowedVariables.reduce(
|
||||
(acc, variable) => {
|
||||
const value = getContactReplacementValue({
|
||||
contact,
|
||||
key: variable,
|
||||
allowedVariables,
|
||||
});
|
||||
|
||||
if (value !== undefined) {
|
||||
acc[variable] = value;
|
||||
}
|
||||
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string | null | undefined>,
|
||||
),
|
||||
});
|
||||
|
||||
return renderer.render({
|
||||
shouldReplaceVariableValues: true,
|
||||
variableValues: {
|
||||
email: contact.email,
|
||||
firstName: contact.firstName,
|
||||
lastName: contact.lastName,
|
||||
},
|
||||
variableValues,
|
||||
linkValues,
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -155,7 +264,7 @@ async function renderCampaignHtmlForContact({
|
||||
}
|
||||
|
||||
let html = replaceUnsubscribePlaceholders(campaign.html, unsubscribeUrl);
|
||||
html = replaceContactVariables(html, contact);
|
||||
html = replaceContactVariables(html, contact, allowedVariables);
|
||||
|
||||
return html;
|
||||
}
|
||||
@@ -245,7 +354,7 @@ export async function createCampaignFromApi({
|
||||
|
||||
const unsubPlaceholderFound = campaignHasUnsubscribePlaceholder(
|
||||
sanitizedContent,
|
||||
sanitizedHtml
|
||||
sanitizedHtml,
|
||||
);
|
||||
|
||||
if (!unsubPlaceholderFound) {
|
||||
@@ -351,7 +460,7 @@ export async function sendCampaign(id: string) {
|
||||
|
||||
const unsubPlaceholderFound = campaignHasUnsubscribePlaceholder(
|
||||
campaign.content,
|
||||
html
|
||||
html,
|
||||
);
|
||||
|
||||
if (!unsubPlaceholderFound) {
|
||||
@@ -430,7 +539,7 @@ export async function scheduleCampaign({
|
||||
|
||||
const unsubPlaceholderFound = campaignHasUnsubscribePlaceholder(
|
||||
campaign.content,
|
||||
html
|
||||
html,
|
||||
);
|
||||
if (!unsubPlaceholderFound) {
|
||||
throw new UnsendApiError({
|
||||
@@ -699,6 +808,7 @@ export async function deleteCampaign(id: string, teamId: number) {
|
||||
type CampaignEmailJob = {
|
||||
contact: Contact;
|
||||
campaign: Campaign;
|
||||
allowedVariables: string[];
|
||||
emailConfig: {
|
||||
from: string;
|
||||
subject: string;
|
||||
@@ -714,12 +824,12 @@ type CampaignEmailJob = {
|
||||
};
|
||||
|
||||
async function processContactEmail(jobData: CampaignEmailJob) {
|
||||
const { contact, campaign, emailConfig } = jobData;
|
||||
const { contact, campaign, emailConfig, allowedVariables } = jobData;
|
||||
|
||||
const unsubscribeUrl = createUnsubUrl(contact.id, emailConfig.campaignId);
|
||||
const oneClickUnsubUrl = createOneClickUnsubUrl(
|
||||
contact.id,
|
||||
emailConfig.campaignId
|
||||
emailConfig.campaignId,
|
||||
);
|
||||
|
||||
// Check for suppressed emails before processing
|
||||
@@ -734,18 +844,18 @@ async function processContactEmail(jobData: CampaignEmailJob) {
|
||||
|
||||
const suppressionResults = await SuppressionService.checkMultipleEmails(
|
||||
allEmailsToCheck,
|
||||
emailConfig.teamId
|
||||
emailConfig.teamId,
|
||||
);
|
||||
|
||||
// Filter each field separately
|
||||
const filteredToEmails = toEmails.filter(
|
||||
(email) => !suppressionResults[email]
|
||||
(email) => !suppressionResults[email],
|
||||
);
|
||||
const filteredCcEmails = ccEmails.filter(
|
||||
(email) => !suppressionResults[email]
|
||||
(email) => !suppressionResults[email],
|
||||
);
|
||||
const filteredBccEmails = bccEmails.filter(
|
||||
(email) => !suppressionResults[email]
|
||||
(email) => !suppressionResults[email],
|
||||
);
|
||||
|
||||
// Check if the contact's email (TO recipient) is suppressed
|
||||
@@ -755,6 +865,7 @@ async function processContactEmail(jobData: CampaignEmailJob) {
|
||||
campaign,
|
||||
contact,
|
||||
unsubscribeUrl,
|
||||
allowedVariables,
|
||||
});
|
||||
|
||||
if (isContactSuppressed) {
|
||||
@@ -765,7 +876,7 @@ async function processContactEmail(jobData: CampaignEmailJob) {
|
||||
campaignId: emailConfig.campaignId,
|
||||
teamId: emailConfig.teamId,
|
||||
},
|
||||
"Contact email is suppressed. Creating suppressed email record."
|
||||
"Contact email is suppressed. Creating suppressed email record.",
|
||||
);
|
||||
|
||||
const email = await db.email.create({
|
||||
@@ -821,7 +932,7 @@ async function processContactEmail(jobData: CampaignEmailJob) {
|
||||
campaignId: emailConfig.campaignId,
|
||||
teamId: emailConfig.teamId,
|
||||
},
|
||||
"Some CC recipients were suppressed and filtered out from campaign email."
|
||||
"Some CC recipients were suppressed and filtered out from campaign email.",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -833,7 +944,7 @@ async function processContactEmail(jobData: CampaignEmailJob) {
|
||||
campaignId: emailConfig.campaignId,
|
||||
teamId: emailConfig.teamId,
|
||||
},
|
||||
"Some BCC recipients were suppressed and filtered out from campaign email."
|
||||
"Some BCC recipients were suppressed and filtered out from campaign email.",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -866,7 +977,7 @@ async function processContactEmail(jobData: CampaignEmailJob) {
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{ err: error },
|
||||
"Failed to create campaign email record so skipping email sending"
|
||||
"Failed to create campaign email record so skipping email sending",
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -877,14 +988,14 @@ async function processContactEmail(jobData: CampaignEmailJob) {
|
||||
emailConfig.teamId,
|
||||
emailConfig.region,
|
||||
false,
|
||||
oneClickUnsubUrl
|
||||
oneClickUnsubUrl,
|
||||
);
|
||||
}
|
||||
|
||||
export async function updateCampaignAnalytics(
|
||||
campaignId: string,
|
||||
emailStatus: EmailStatus,
|
||||
hardBounce: boolean = false
|
||||
hardBounce: boolean = false,
|
||||
) {
|
||||
const campaign = await db.campaign.findUnique({
|
||||
where: { id: campaignId },
|
||||
@@ -941,7 +1052,7 @@ export class CampaignBatchService {
|
||||
connection: getRedis(),
|
||||
prefix: BULL_PREFIX,
|
||||
skipVersionCheck: true,
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
static worker = new Worker(
|
||||
@@ -987,6 +1098,16 @@ export class CampaignBatchService {
|
||||
|
||||
const contacts = await db.contact.findMany({ where, ...pagination });
|
||||
|
||||
const contactBook = await db.contactBook.findUnique({
|
||||
where: { id: campaign.contactBookId },
|
||||
select: { variables: true },
|
||||
});
|
||||
|
||||
const allowedVariables = [
|
||||
...BUILT_IN_CONTACT_VARIABLES,
|
||||
...(contactBook?.variables ?? []),
|
||||
];
|
||||
|
||||
if (contacts.length === 0) {
|
||||
// No more contacts -> mark SENT
|
||||
await db.campaign.update({
|
||||
@@ -1019,6 +1140,7 @@ export class CampaignBatchService {
|
||||
await processContactEmail({
|
||||
contact,
|
||||
campaign,
|
||||
allowedVariables,
|
||||
emailConfig: {
|
||||
from: campaign.from,
|
||||
subject: campaign.subject,
|
||||
@@ -1041,7 +1163,12 @@ export class CampaignBatchService {
|
||||
data: { lastCursor: newCursor, lastSentAt: new Date() },
|
||||
});
|
||||
}),
|
||||
{ connection: getRedis(), concurrency: 20, prefix: BULL_PREFIX, skipVersionCheck: true }
|
||||
{
|
||||
connection: getRedis(),
|
||||
concurrency: 20,
|
||||
prefix: BULL_PREFIX,
|
||||
skipVersionCheck: true,
|
||||
},
|
||||
);
|
||||
|
||||
static async queueBatch({
|
||||
@@ -1066,7 +1193,7 @@ export class CampaignBatchService {
|
||||
if (elapsedMs < windowMs) {
|
||||
logger.debug(
|
||||
{ campaignId, remainingMs: windowMs - elapsedMs },
|
||||
"Defensive skip enqueue; window not elapsed"
|
||||
"Defensive skip enqueue; window not elapsed",
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -1074,14 +1201,14 @@ export class CampaignBatchService {
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
{ err, campaignId },
|
||||
"Failed defensive window check; proceeding to enqueue"
|
||||
"Failed defensive window check; proceeding to enqueue",
|
||||
);
|
||||
}
|
||||
|
||||
await this.batchQueue.add(
|
||||
`campaign-${campaignId}`,
|
||||
{ campaignId, teamId },
|
||||
{ jobId: `campaign-batch:${campaignId}`, ...DEFAULT_QUEUE_OPTIONS }
|
||||
{ jobId: `campaign-batch:${campaignId}`, ...DEFAULT_QUEUE_OPTIONS },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import { CampaignStatus } from "@prisma/client";
|
||||
import { db } from "../db";
|
||||
import { LimitService } from "./limit-service";
|
||||
import { UnsendApiError } from "../public-api/api-error";
|
||||
import {
|
||||
DEFAULT_DOUBLE_OPT_IN_CONTENT,
|
||||
DEFAULT_DOUBLE_OPT_IN_SUBJECT,
|
||||
hasDoubleOptInUrlPlaceholder,
|
||||
} from "~/lib/constants/double-opt-in";
|
||||
import { db } from "../db";
|
||||
import { UnsendApiError } from "../public-api/api-error";
|
||||
import { validateDomainFromEmail } from "./domain-service";
|
||||
import { LimitService } from "./limit-service";
|
||||
import {
|
||||
normalizeContactBookVariables,
|
||||
validateContactBookVariables,
|
||||
} from "./contact-variable-service";
|
||||
|
||||
type ContactBookDbClient = Pick<typeof db, "contactBook">;
|
||||
|
||||
@@ -22,6 +26,7 @@ export async function getContactBooks(teamId: number, search?: string) {
|
||||
name: true,
|
||||
teamId: true,
|
||||
properties: true,
|
||||
variables: true,
|
||||
emoji: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
@@ -39,6 +44,7 @@ export async function getContactBooks(teamId: number, search?: string) {
|
||||
export async function createContactBook(
|
||||
teamId: number,
|
||||
name: string,
|
||||
variables?: string[],
|
||||
client: ContactBookDbClient = db,
|
||||
) {
|
||||
const { isLimitReached, reason } =
|
||||
@@ -51,11 +57,23 @@ export async function createContactBook(
|
||||
});
|
||||
}
|
||||
|
||||
const normalizedVariables = normalizeContactBookVariables(variables);
|
||||
|
||||
try {
|
||||
validateContactBookVariables(normalizedVariables);
|
||||
} catch (error) {
|
||||
throw new UnsendApiError({
|
||||
code: "BAD_REQUEST",
|
||||
message: error instanceof Error ? error.message : "Invalid variables",
|
||||
});
|
||||
}
|
||||
|
||||
const created = await client.contactBook.create({
|
||||
data: {
|
||||
name,
|
||||
teamId,
|
||||
properties: {},
|
||||
variables: normalizedVariables,
|
||||
doubleOptInEnabled: true,
|
||||
doubleOptInSubject: DEFAULT_DOUBLE_OPT_IN_SUBJECT,
|
||||
doubleOptInContent: DEFAULT_DOUBLE_OPT_IN_CONTENT,
|
||||
@@ -98,6 +116,7 @@ export async function updateContactBook(
|
||||
name?: string;
|
||||
properties?: Record<string, string>;
|
||||
emoji?: string;
|
||||
variables?: string[];
|
||||
doubleOptInEnabled?: boolean;
|
||||
doubleOptInFrom?: string | null;
|
||||
doubleOptInSubject?: string;
|
||||
@@ -105,7 +124,39 @@ export async function updateContactBook(
|
||||
},
|
||||
client: ContactBookDbClient = db,
|
||||
) {
|
||||
const updateData = { ...data };
|
||||
const restData = { ...data };
|
||||
delete restData.variables;
|
||||
|
||||
const normalizedVariables =
|
||||
data.variables === undefined
|
||||
? undefined
|
||||
: normalizeContactBookVariables(data.variables);
|
||||
|
||||
if (normalizedVariables !== undefined) {
|
||||
try {
|
||||
validateContactBookVariables(normalizedVariables);
|
||||
} catch (error) {
|
||||
throw new UnsendApiError({
|
||||
code: "BAD_REQUEST",
|
||||
message: error instanceof Error ? error.message : "Invalid variables",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const updateData: {
|
||||
name?: string;
|
||||
properties?: Record<string, string>;
|
||||
emoji?: string;
|
||||
variables?: string[];
|
||||
doubleOptInEnabled?: boolean;
|
||||
doubleOptInSubject?: string;
|
||||
doubleOptInContent?: string;
|
||||
} = {
|
||||
...restData,
|
||||
...(normalizedVariables !== undefined
|
||||
? { variables: normalizedVariables }
|
||||
: {}),
|
||||
};
|
||||
|
||||
if (data.doubleOptInFrom !== undefined) {
|
||||
const normalizedFrom = data.doubleOptInFrom?.trim() ?? "";
|
||||
|
||||
@@ -84,6 +84,7 @@ describe("contact-book-service", () => {
|
||||
name: "Newsletter",
|
||||
teamId: 12,
|
||||
properties: {},
|
||||
variables: [],
|
||||
doubleOptInEnabled: true,
|
||||
doubleOptInSubject: DEFAULT_DOUBLE_OPT_IN_SUBJECT,
|
||||
doubleOptInContent: DEFAULT_DOUBLE_OPT_IN_CONTENT,
|
||||
|
||||
@@ -3,6 +3,10 @@ import {
|
||||
type ContactPayload,
|
||||
type ContactWebhookEventType,
|
||||
} from "@usesend/lib/src/webhook/webhook-events";
|
||||
import {
|
||||
mergeContactProperties,
|
||||
normalizeContactProperties,
|
||||
} from "~/lib/contact-properties";
|
||||
import { db } from "../db";
|
||||
import { ContactQueueService } from "./contact-queue-service";
|
||||
import { WebhookService } from "./webhook-service";
|
||||
@@ -29,6 +33,7 @@ export async function addOrUpdateContact(
|
||||
select: {
|
||||
doubleOptInEnabled: true,
|
||||
teamId: true,
|
||||
variables: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -47,6 +52,7 @@ export async function addOrUpdateContact(
|
||||
select: {
|
||||
subscribed: true,
|
||||
unsubscribeReason: true,
|
||||
properties: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -75,6 +81,19 @@ export async function addOrUpdateContact(
|
||||
existingContact === null &&
|
||||
!isExplicitUnsubscribeRequest;
|
||||
|
||||
const normalizedProperties =
|
||||
contact.properties === undefined
|
||||
? undefined
|
||||
: normalizeContactProperties(contact.properties, contactBook.variables);
|
||||
const mergedProperties =
|
||||
normalizedProperties === undefined
|
||||
? undefined
|
||||
: mergeContactProperties(
|
||||
(existingContact?.properties as Record<string, unknown> | null) ?? {},
|
||||
normalizedProperties,
|
||||
contactBook.variables,
|
||||
);
|
||||
|
||||
const savedContact = await db.contact.upsert({
|
||||
where: {
|
||||
contactBookId_email: {
|
||||
@@ -87,7 +106,7 @@ export async function addOrUpdateContact(
|
||||
email: contact.email,
|
||||
firstName: contact.firstName,
|
||||
lastName: contact.lastName,
|
||||
properties: contact.properties ?? {},
|
||||
properties: normalizedProperties ?? {},
|
||||
subscribed: shouldCreatePendingContact
|
||||
? false
|
||||
: (contact.subscribed ?? true),
|
||||
@@ -100,7 +119,9 @@ export async function addOrUpdateContact(
|
||||
update: {
|
||||
firstName: contact.firstName,
|
||||
lastName: contact.lastName,
|
||||
properties: contact.properties ?? {},
|
||||
...(mergedProperties !== undefined
|
||||
? { properties: mergedProperties }
|
||||
: {}),
|
||||
...(subscribedValue !== undefined
|
||||
? {
|
||||
subscribed: subscribedValue,
|
||||
@@ -168,12 +189,29 @@ export async function updateContactInContactBook(
|
||||
return null;
|
||||
}
|
||||
|
||||
const contactBook = await db.contactBook.findUnique({
|
||||
where: { id: contactBookId },
|
||||
select: { variables: true },
|
||||
});
|
||||
|
||||
const mergedProperties =
|
||||
contact.properties === undefined
|
||||
? undefined
|
||||
: mergeContactProperties(
|
||||
(existingContact.properties as Record<string, unknown> | null) ?? {},
|
||||
contact.properties,
|
||||
contactBook?.variables ?? [],
|
||||
);
|
||||
|
||||
const updatedContact = await db.contact.update({
|
||||
where: {
|
||||
id: contactId,
|
||||
},
|
||||
data: {
|
||||
...contact,
|
||||
...(mergedProperties !== undefined
|
||||
? { properties: mergedProperties }
|
||||
: {}),
|
||||
...(contact.subscribed !== undefined
|
||||
? {
|
||||
unsubscribeReason: contact.subscribed
|
||||
@@ -243,7 +281,7 @@ export async function bulkDeleteContactsInContactBook(
|
||||
),
|
||||
);
|
||||
|
||||
return contacts;
|
||||
return contacts;
|
||||
}
|
||||
|
||||
export async function resendDoubleOptInConfirmationInContactBook(
|
||||
|
||||
@@ -14,6 +14,7 @@ const {
|
||||
contact: {
|
||||
findFirst: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
update: vi.fn(),
|
||||
upsert: vi.fn(),
|
||||
},
|
||||
},
|
||||
@@ -53,6 +54,7 @@ vi.mock("~/server/logger/log", () => ({
|
||||
import {
|
||||
addOrUpdateContact,
|
||||
resendDoubleOptInConfirmationInContactBook,
|
||||
updateContactInContactBook,
|
||||
} from "~/server/service/contact-service";
|
||||
|
||||
const createdAt = new Date("2026-02-08T00:00:00.000Z");
|
||||
@@ -62,6 +64,7 @@ describe("contact-service addOrUpdateContact", () => {
|
||||
mockDb.contactBook.findUnique.mockReset();
|
||||
mockDb.contact.findFirst.mockReset();
|
||||
mockDb.contact.findUnique.mockReset();
|
||||
mockDb.contact.update.mockReset();
|
||||
mockDb.contact.upsert.mockReset();
|
||||
mockWebhookEmit.mockReset();
|
||||
mockSendDoubleOptInConfirmationEmail.mockReset();
|
||||
@@ -233,6 +236,134 @@ describe("contact-service addOrUpdateContact", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("canonicalizes registered property keys when creating contacts", async () => {
|
||||
mockDb.contactBook.findUnique.mockResolvedValue({
|
||||
doubleOptInEnabled: false,
|
||||
teamId: 7,
|
||||
variables: ["company"],
|
||||
});
|
||||
mockDb.contact.findUnique.mockResolvedValue(null);
|
||||
mockDb.contact.upsert.mockResolvedValue({
|
||||
id: "contact_8",
|
||||
email: "frank@example.com",
|
||||
contactBookId: "book_1",
|
||||
subscribed: true,
|
||||
properties: { company: "Acme", tier: "gold" },
|
||||
firstName: null,
|
||||
lastName: null,
|
||||
createdAt,
|
||||
updatedAt: createdAt,
|
||||
});
|
||||
|
||||
await addOrUpdateContact(
|
||||
"book_1",
|
||||
{
|
||||
email: "frank@example.com",
|
||||
properties: {
|
||||
Company: "Acme",
|
||||
tier: "gold",
|
||||
},
|
||||
},
|
||||
7,
|
||||
);
|
||||
|
||||
const upsertArgs = mockDb.contact.upsert.mock.calls[0]?.[0];
|
||||
expect(upsertArgs.create.properties).toEqual({
|
||||
company: "Acme",
|
||||
tier: "gold",
|
||||
});
|
||||
expect(upsertArgs.update.properties).toEqual({
|
||||
company: "Acme",
|
||||
tier: "gold",
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves existing properties when upserting without properties", async () => {
|
||||
mockDb.contactBook.findUnique.mockResolvedValue({
|
||||
doubleOptInEnabled: false,
|
||||
teamId: 7,
|
||||
variables: ["company"],
|
||||
});
|
||||
mockDb.contact.findUnique.mockResolvedValue({
|
||||
subscribed: true,
|
||||
unsubscribeReason: null,
|
||||
properties: {
|
||||
company: "Acme",
|
||||
tier: "gold",
|
||||
},
|
||||
});
|
||||
mockDb.contact.upsert.mockResolvedValue({
|
||||
id: "contact_10",
|
||||
email: "preserve@example.com",
|
||||
contactBookId: "book_1",
|
||||
subscribed: true,
|
||||
properties: {
|
||||
company: "Acme",
|
||||
tier: "gold",
|
||||
},
|
||||
firstName: "Updated",
|
||||
lastName: null,
|
||||
createdAt,
|
||||
updatedAt: createdAt,
|
||||
});
|
||||
|
||||
await addOrUpdateContact(
|
||||
"book_1",
|
||||
{ email: "preserve@example.com", firstName: "Updated" },
|
||||
7,
|
||||
);
|
||||
|
||||
const upsertArgs = mockDb.contact.upsert.mock.calls[0]?.[0];
|
||||
expect(upsertArgs.update).not.toHaveProperty("properties");
|
||||
});
|
||||
|
||||
it("merges existing properties when upserting with partial properties", async () => {
|
||||
mockDb.contactBook.findUnique.mockResolvedValue({
|
||||
doubleOptInEnabled: false,
|
||||
teamId: 7,
|
||||
variables: ["company"],
|
||||
});
|
||||
mockDb.contact.findUnique.mockResolvedValue({
|
||||
subscribed: true,
|
||||
unsubscribeReason: null,
|
||||
properties: {
|
||||
Company: "Old Co",
|
||||
tier: "gold",
|
||||
},
|
||||
});
|
||||
mockDb.contact.upsert.mockResolvedValue({
|
||||
id: "contact_11",
|
||||
email: "merge@example.com",
|
||||
contactBookId: "book_1",
|
||||
subscribed: true,
|
||||
properties: {
|
||||
company: "New Co",
|
||||
tier: "gold",
|
||||
},
|
||||
firstName: null,
|
||||
lastName: null,
|
||||
createdAt,
|
||||
updatedAt: createdAt,
|
||||
});
|
||||
|
||||
await addOrUpdateContact(
|
||||
"book_1",
|
||||
{
|
||||
email: "merge@example.com",
|
||||
properties: {
|
||||
company: "New Co",
|
||||
},
|
||||
},
|
||||
7,
|
||||
);
|
||||
|
||||
const upsertArgs = mockDb.contact.upsert.mock.calls[0]?.[0];
|
||||
expect(upsertArgs.update.properties).toEqual({
|
||||
company: "New Co",
|
||||
tier: "gold",
|
||||
});
|
||||
});
|
||||
|
||||
it("throws when contact book does not exist", async () => {
|
||||
mockDb.contactBook.findUnique.mockResolvedValue(null);
|
||||
|
||||
@@ -325,4 +456,59 @@ describe("contact-service addOrUpdateContact", () => {
|
||||
).resolves.toBeNull();
|
||||
expect(mockSendDoubleOptInConfirmationEmail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("merges contact properties on update and canonicalizes registry variables", async () => {
|
||||
mockDb.contact.findFirst.mockResolvedValue({
|
||||
id: "contact_9",
|
||||
email: "grace@example.com",
|
||||
contactBookId: "book_1",
|
||||
subscribed: true,
|
||||
unsubscribeReason: null,
|
||||
properties: {
|
||||
Company: "Old Co",
|
||||
notes: "keep me",
|
||||
},
|
||||
createdAt,
|
||||
updatedAt: createdAt,
|
||||
});
|
||||
mockDb.contactBook.findUnique.mockResolvedValue({
|
||||
variables: ["company", "plan"],
|
||||
});
|
||||
mockDb.contact.update.mockResolvedValue({
|
||||
id: "contact_9",
|
||||
email: "grace@example.com",
|
||||
contactBookId: "book_1",
|
||||
subscribed: true,
|
||||
unsubscribeReason: null,
|
||||
properties: {
|
||||
company: "New Co",
|
||||
notes: "keep me",
|
||||
},
|
||||
createdAt,
|
||||
updatedAt: createdAt,
|
||||
});
|
||||
|
||||
await updateContactInContactBook(
|
||||
"contact_9",
|
||||
"book_1",
|
||||
{
|
||||
properties: {
|
||||
company: "New Co",
|
||||
},
|
||||
},
|
||||
7,
|
||||
);
|
||||
|
||||
expect(mockDb.contact.update).toHaveBeenCalledWith({
|
||||
where: {
|
||||
id: "contact_9",
|
||||
},
|
||||
data: {
|
||||
properties: {
|
||||
company: "New Co",
|
||||
notes: "keep me",
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
export const CONTACT_VARIABLE_NAME_REGEX = /^[a-zA-Z0-9_]+$/;
|
||||
const RESERVED_CONTACT_VARIABLES = new Set(["email", "firstname", "lastname"]);
|
||||
|
||||
export function normalizeContactBookVariables(variables?: string[]): string[] {
|
||||
if (!variables) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const deduped = new Map<string, string>();
|
||||
|
||||
for (const variable of variables) {
|
||||
const trimmed = variable.trim();
|
||||
if (!trimmed) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const normalized = trimmed.toLowerCase();
|
||||
if (!deduped.has(normalized)) {
|
||||
deduped.set(normalized, trimmed);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(deduped.values());
|
||||
}
|
||||
|
||||
export function validateContactBookVariables(variables: string[]) {
|
||||
for (const variable of variables) {
|
||||
if (!CONTACT_VARIABLE_NAME_REGEX.test(variable)) {
|
||||
throw new Error(
|
||||
`Variable "${variable}" contains invalid characters. Use only letters, numbers, and underscores.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (RESERVED_CONTACT_VARIABLES.has(variable.toLowerCase())) {
|
||||
throw new Error(
|
||||
`Variable "${variable}" is reserved. Use a different name.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user