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:
KM Koushik
2026-03-08 00:03:58 +11:00
committed by GitHub
parent d97e445ea0
commit 62e0a1db88
29 changed files with 1564 additions and 406 deletions
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "ContactBook" ADD COLUMN "variables" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[];
+1
View File
@@ -296,6 +296,7 @@ model ContactBook {
id String @id @default(cuid()) id String @id @default(cuid())
name String name String
teamId Int teamId Int
variables String[] @default([])
properties Json properties Json
doubleOptInEnabled Boolean @default(false) doubleOptInEnabled Boolean @default(false)
doubleOptInFrom String? doubleOptInFrom String?
@@ -5,7 +5,7 @@ import { Spinner } from "@usesend/ui/src/spinner";
import { Button } from "@usesend/ui/src/button"; import { Button } from "@usesend/ui/src/button";
import { Input } from "@usesend/ui/src/input"; import { Input } from "@usesend/ui/src/input";
import { Editor } from "@usesend/email-editor"; import { Editor } from "@usesend/email-editor";
import { use, useState } from "react"; import { use, useMemo, useState } from "react";
import { Campaign } from "@prisma/client"; import { Campaign } from "@prisma/client";
import { import {
Select, Select,
@@ -65,7 +65,7 @@ export default function EditCampaignPage({
{ campaignId }, { campaignId },
{ {
enabled: !!campaignId, enabled: !!campaignId,
} },
); );
if (isLoading) { if (isLoading) {
@@ -102,7 +102,7 @@ function CampaignEditor({
const utils = api.useUtils(); const utils = api.useUtils();
const [json, setJson] = useState<Record<string, any> | undefined>( 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 [isSaving, setIsSaving] = useState(false);
const [name, setName] = useState(campaign.name); const [name, setName] = useState(campaign.name);
@@ -110,10 +110,10 @@ function CampaignEditor({
const [from, setFrom] = useState(campaign.from); const [from, setFrom] = useState(campaign.from);
const [contactBookId, setContactBookId] = useState(campaign.contactBookId); const [contactBookId, setContactBookId] = useState(campaign.contactBookId);
const [replyTo, setReplyTo] = useState<string | undefined>( const [replyTo, setReplyTo] = useState<string | undefined>(
campaign.replyTo[0] campaign.replyTo[0],
); );
const [previewText, setPreviewText] = useState<string | null>( const [previewText, setPreviewText] = useState<string | null>(
campaign.previewText campaign.previewText,
); );
const updateCampaignMutation = api.campaign.updateCampaign.useMutation({ const updateCampaignMutation = api.campaign.updateCampaign.useMutation({
@@ -136,13 +136,13 @@ function CampaignEditor({
const deboucedUpdateCampaign = useDebouncedCallback( const deboucedUpdateCampaign = useDebouncedCallback(
updateEditorContent, updateEditorContent,
1000 1000,
); );
const handleFileChange = async (file: File) => { const handleFileChange = async (file: File) => {
if (file.size > IMAGE_SIZE_LIMIT) { if (file.size > IMAGE_SIZE_LIMIT) {
throw new Error( 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( 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 ( return (
<div className="p-4 container mx-auto "> <div className="p-4 container mx-auto ">
@@ -196,7 +205,7 @@ function CampaignEditor({
toast.error(`${e.message}. Reverting changes.`); toast.error(`${e.message}. Reverting changes.`);
setName(campaign.name); setName(campaign.name);
}, },
} },
); );
}} }}
/> />
@@ -251,7 +260,7 @@ function CampaignEditor({
toast.error(`${e.message}. Reverting changes.`); toast.error(`${e.message}. Reverting changes.`);
setSubject(campaign.subject); setSubject(campaign.subject);
}, },
} },
); );
}} }}
className="mt-1 py-1 text-sm block w-full outline-none border-b border-transparent focus:border-border bg-transparent" 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.`); toast.error(`${e.message}. Reverting changes.`);
setFrom(campaign.from); setFrom(campaign.from);
}, },
} },
); );
}} }}
disabled={isApiCampaign} disabled={isApiCampaign}
@@ -327,7 +336,7 @@ function CampaignEditor({
toast.error(`${e.message}. Reverting changes.`); toast.error(`${e.message}. Reverting changes.`);
setReplyTo(campaign.replyTo[0]); setReplyTo(campaign.replyTo[0]);
}, },
} },
); );
}} }}
disabled={isApiCampaign} disabled={isApiCampaign}
@@ -365,7 +374,7 @@ function CampaignEditor({
toast.error(`${e.message}. Reverting changes.`); toast.error(`${e.message}. Reverting changes.`);
setPreviewText(campaign.previewText ?? ""); setPreviewText(campaign.previewText ?? "");
}, },
} },
); );
}} }}
className="mt-1 py-1 text-sm block w-full outline-none border-b border-transparent bg-transparent focus:border-border" 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: () => { onError: () => {
setContactBookId(campaign.contactBookId); setContactBookId(campaign.contactBookId);
}, },
} },
); );
setContactBookId(val); setContactBookId(val);
}} }}
@@ -435,13 +444,15 @@ function CampaignEditor({
<div className=" rounded-lg bg-gray-50 w-[700px] mx-auto p-10"> <div className=" rounded-lg bg-gray-50 w-[700px] mx-auto p-10">
<div className="w-[600px] mx-auto"> <div className="w-[600px] mx-auto">
<Editor <Editor
key={`campaign-editor-${contactBookId ?? "none"}-${editorVariables.join(",")}`}
initialContent={json} initialContent={json}
onUpdate={(content) => { onUpdate={(content) => {
setJson(content.getJSON()); setJson(content.getJSON());
setIsSaving(true); setIsSaving(true);
deboucedUpdateCampaign(); deboucedUpdateCampaign();
}} }}
variables={["email", "firstName", "lastName"]} variables={editorVariables}
variableSuggestionsHelperText={variableSuggestionsHelperText}
uploadImage={ uploadImage={
campaign.imageUploadSupported ? handleFileChange : undefined campaign.imageUploadSupported ? handleFileChange : undefined
} }
@@ -26,6 +26,7 @@ import { z } from "zod";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { toast } from "@usesend/ui/src/toaster"; import { toast } from "@usesend/ui/src/toaster";
import type { ReactNode } from "react";
const contactsSchema = z.object({ const contactsSchema = z.object({
contacts: z.string({ required_error: "Contacts are required" }).min(1, { contacts: z.string({ required_error: "Contacts are required" }).min(1, {
@@ -35,8 +36,14 @@ const contactsSchema = z.object({
export default function AddContact({ export default function AddContact({
contactBookId, contactBookId,
trigger,
open: controlledOpen,
onOpenChange,
}: { }: {
contactBookId: string; contactBookId: string;
trigger?: ReactNode;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}) { }) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
@@ -50,6 +57,14 @@ export default function AddContact({
}); });
const utils = api.useUtils(); 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>) { async function onContactsAdd(values: z.infer<typeof contactsSchema>) {
const contactsArray = values.contacts.split(",").map((email) => ({ const contactsArray = values.contacts.split(",").map((email) => ({
@@ -64,7 +79,11 @@ export default function AddContact({
{ {
onSuccess: async () => { onSuccess: async () => {
utils.contacts.contacts.invalidate(); utils.contacts.contacts.invalidate();
setOpen(false); if (controlledOpen === undefined) {
setOpen(false);
} else {
onOpenChange?.(false);
}
toast.success("Contacts queued for processing"); toast.success("Contacts queued for processing");
}, },
onError: async (error) => { onError: async (error) => {
@@ -76,15 +95,21 @@ export default function AddContact({
return ( return (
<Dialog <Dialog
open={open} open={controlledOpen ?? open}
onOpenChange={(_open) => (_open !== open ? setOpen(_open) : null)} onOpenChange={(nextOpen) => {
if (controlledOpen === undefined) {
if (nextOpen !== open) {
setOpen(nextOpen);
}
return;
}
onOpenChange?.(nextOpen);
}}
> >
<DialogTrigger asChild> {dialogTrigger ? (
<Button> <DialogTrigger asChild>{dialogTrigger}</DialogTrigger>
<Plus className="h-4 w-4 mr-1" /> ) : null}
Add Contacts
</Button>
</DialogTrigger>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>Add new contacts</DialogTitle> <DialogTitle>Add new contacts</DialogTitle>
@@ -1,7 +1,8 @@
"use client"; "use client";
import { useState, useMemo } from "react"; import { useState, useMemo, type ReactNode } from "react";
import { api } from "~/trpc/react"; import { api } from "~/trpc/react";
import { getCanonicalContactVariableName } from "~/lib/contact-properties";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -28,24 +29,41 @@ import { toast } from "@usesend/ui/src/toaster";
interface BulkUploadContactsProps { interface BulkUploadContactsProps {
contactBookId: string; contactBookId: string;
contactBookVariables?: string[];
trigger?: ReactNode;
open?: boolean;
onOpenChange?: (open: boolean) => void;
} }
interface ParsedContact { interface ParsedContact {
email: string; email: string;
firstName?: string; firstName?: string;
lastName?: string; lastName?: string;
properties?: Record<string, string>;
subscribed?: boolean; subscribed?: boolean;
isValid: boolean; isValid: boolean;
} }
export default function BulkUploadContacts({ export default function BulkUploadContacts({
contactBookId, contactBookId,
contactBookVariables,
trigger,
open: controlledOpen,
onOpenChange,
}: BulkUploadContactsProps) { }: BulkUploadContactsProps) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [inputText, setInputText] = useState(""); const [inputText, setInputText] = useState("");
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [processing, setProcessing] = useState(false); const [processing, setProcessing] = useState(false);
const [isDragOver, setIsDragOver] = 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(); const utils = api.useUtils();
@@ -67,7 +85,11 @@ export default function BulkUploadContacts({
setInputText(""); setInputText("");
setError(null); setError(null);
setProcessing(false); setProcessing(false);
setOpen(false); if (controlledOpen === undefined) {
setOpen(false);
} else {
onOpenChange?.(false);
}
}; };
const validateEmail = (email: string): boolean => { const validateEmail = (email: string): boolean => {
@@ -77,11 +99,12 @@ export default function BulkUploadContacts({
const parseContactLine = ( const parseContactLine = (
line: string, line: string,
isFirstLine: boolean = false, headers?: string[],
): { ): {
email: string; email: string;
firstName?: string; firstName?: string;
lastName?: string; lastName?: string;
properties?: Record<string, string>;
subscribed?: boolean; subscribed?: boolean;
} | null => { } | null => {
const trimmedLine = line.trim(); const trimmedLine = line.trim();
@@ -107,27 +130,95 @@ export default function BulkUploadContacts({
if (parts.length === 0 || !parts[0]) return null; if (parts.length === 0 || !parts[0]) return null;
// Check if this is a header row (case-insensitive) const firstPart = parts[0]?.toLowerCase();
if (isFirstLine) { if (
const firstPart = parts[0]?.toLowerCase(); firstPart === "email" ||
if ( firstPart === "e-mail" ||
firstPart === "email" || firstPart === "email address"
firstPart === "e-mail" || ) {
firstPart === "email address" return null;
) {
return null; // Skip header row
}
} }
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 // 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, ...) // Parse subscribed value (support CSV export format: Email, First Name, Last Name, Subscribed, ...)
let subscribed: boolean | undefined = undefined; let subscribed: boolean | undefined = undefined;
let firstName: string | undefined = undefined; let firstName: string | undefined = undefined;
let lastName: 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) { if (parts.length >= 4) {
// Could be: email,firstName,lastName,subscribed // Could be: email,firstName,lastName,subscribed
@@ -152,6 +243,7 @@ export default function BulkUploadContacts({
email, email,
firstName, firstName,
lastName, lastName,
properties: Object.keys(properties).length > 0 ? properties : undefined,
subscribed, subscribed,
}; };
}; };
@@ -159,9 +251,25 @@ export default function BulkUploadContacts({
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>();
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++) { for (let i = 0; i < lines.length; i++) {
const parsed = parseContactLine(lines[i]!, i === 0); const parsed = parseContactLine(lines[i]!, headers);
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)) {
@@ -267,6 +375,7 @@ export default function BulkUploadContacts({
email: c.email, email: c.email,
firstName: c.firstName, firstName: c.firstName,
lastName: c.lastName, lastName: c.lastName,
properties: c.properties,
subscribed: c.subscribed, subscribed: c.subscribed,
})), })),
}); });
@@ -293,13 +402,20 @@ export default function BulkUploadContacts({
); );
return ( return (
<Dialog open={open} onOpenChange={setOpen}> <Dialog
<DialogTrigger asChild> open={controlledOpen ?? open}
<Button variant="outline"> onOpenChange={(nextOpen) => {
<Upload className="h-4 w-4 mr-1" /> if (controlledOpen === undefined) {
Upload Contacts setOpen(nextOpen);
</Button> return;
</DialogTrigger> }
onOpenChange?.(nextOpen);
}}
>
{dialogTrigger ? (
<DialogTrigger asChild>{dialogTrigger}</DialogTrigger>
) : null}
<DialogContent className="max-w-3xl"> <DialogContent className="max-w-3xl">
<DialogHeader> <DialogHeader>
<DialogTitle>Bulk Upload Contacts</DialogTitle> <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"} : "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,subscribed (one per line) Format: email,firstName,lastName,subscribed (+ optional
custom columns)
</p> </p>
</div> </div>
</div> </div>
@@ -26,6 +26,7 @@ import EditContact from "./edit-contact";
import { ResendDoubleOptInConfirmation } from "./resend-double-opt-in-confirmation"; import { ResendDoubleOptInConfirmation } from "./resend-double-opt-in-confirmation";
import { Input } from "@usesend/ui/src/input"; import { Input } from "@usesend/ui/src/input";
import { useDebouncedCallback } from "use-debounce"; import { useDebouncedCallback } from "use-debounce";
import { getContactPropertyValue } from "~/lib/contact-properties";
import { import {
Tooltip, Tooltip,
TooltipContent, TooltipContent,
@@ -72,10 +73,12 @@ export default function ContactList({
contactBookId, contactBookId,
contactBookName, contactBookName,
doubleOptInEnabled, doubleOptInEnabled,
contactBookVariables,
}: { }: {
contactBookId: string; contactBookId: string;
contactBookName?: string; contactBookName?: string;
doubleOptInEnabled?: boolean; doubleOptInEnabled?: boolean;
contactBookVariables?: string[];
}) { }) {
const [page, setPage] = useUrlState("page", "1"); const [page, setPage] = useUrlState("page", "1");
const [status, setStatus] = useUrlState("status"); const [status, setStatus] = useUrlState("status");
@@ -141,6 +144,7 @@ export default function ContactList({
"Subscribed", "Subscribed",
"Unsubscribe Reason", "Unsubscribe Reason",
"Created At", "Created At",
...(contactBookVariables ?? []),
]; ];
// CSV Rows // CSV Rows
@@ -151,6 +155,15 @@ export default function ContactList({
escapeCell(contact.subscribed ? "Yes" : "No"), escapeCell(contact.subscribed ? "Yes" : "No"),
escapeCell(contact.unsubscribeReason ?? ""), escapeCell(contact.unsubscribeReason ?? ""),
escapeCell(contact.createdAt.toISOString()), escapeCell(contact.createdAt.toISOString()),
...(contactBookVariables ?? []).map((variable) =>
escapeCell(
getContactPropertyValue(
(contact.properties as Record<string, unknown> | undefined) ?? {},
variable,
contactBookVariables ?? [],
) ?? "",
),
),
]); ]);
// Build CSV with UTF-8 BOM // Build CSV with UTF-8 BOM
@@ -314,7 +327,10 @@ export default function ContactList({
email={contact.email} email={contact.email}
/> />
) : null} ) : null}
<EditContact contact={contact} /> <EditContact
contact={contact}
contactBookVariables={contactBookVariables}
/>
<DeleteContact contact={contact} /> <DeleteContact contact={contact} />
</div> </div>
</TableCell> </TableCell>
@@ -12,7 +12,6 @@ import {
import { import {
Form, Form,
FormControl, FormControl,
FormDescription,
FormField, FormField,
FormItem, FormItem,
FormLabel, FormLabel,
@@ -20,31 +19,67 @@ import {
} from "@usesend/ui/src/form"; } from "@usesend/ui/src/form";
import { api } from "~/trpc/react"; import { api } from "~/trpc/react";
import { useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { Edit } from "lucide-react"; import { Edit } from "lucide-react";
import { useRouter } from "next/navigation";
import { z } from "zod"; import { z } from "zod";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { toast } from "@usesend/ui/src/toaster"; import { toast } from "@usesend/ui/src/toaster";
import { Switch } from "@usesend/ui/src/switch"; import { Switch } from "@usesend/ui/src/switch";
import { Contact } from "@prisma/client"; import { Contact } from "@prisma/client";
import {
getContactPropertyValue,
replaceContactVariableValues,
} from "~/lib/contact-properties";
const contactSchema = z.object({ const contactSchema = z.object({
email: z.string().email({ message: "Invalid email address" }), email: z.string().email({ message: "Invalid email address" }),
firstName: z.string().optional(), firstName: z.string().optional(),
lastName: z.string().optional(), lastName: z.string().optional(),
properties: z.record(z.string()).optional(),
subscribed: z.boolean().optional(), subscribed: z.boolean().optional(),
}); });
export const EditContact: React.FC<{ export const EditContact: React.FC<{
contact: Partial<Contact> & { id: string; contactBookId: string }; contact: Partial<Contact> & { id: string; contactBookId: string };
}> = ({ contact }) => { contactBookVariables?: string[];
}> = ({ contact, contactBookVariables }) => {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const updateContactMutation = api.contacts.updateContact.useMutation(); 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 utils = api.useUtils();
const router = useRouter();
const contactForm = useForm<z.infer<typeof contactSchema>>({ const contactForm = useForm<z.infer<typeof contactSchema>>({
resolver: zodResolver(contactSchema), resolver: zodResolver(contactSchema),
@@ -62,6 +97,14 @@ export const EditContact: React.FC<{
contactId: contact.id, contactId: contact.id,
contactBookId: contact.contactBookId, contactBookId: contact.contactBookId,
...values, ...values,
properties: replaceContactVariableValues(
(contact.properties as Record<string, unknown> | null | undefined) ??
{},
Object.fromEntries(
Object.entries(variableValues).filter(([, value]) => value.trim()),
),
contactBookVariables ?? [],
),
}, },
{ {
onSuccess: async () => { onSuccess: async () => {
@@ -72,7 +115,7 @@ export const EditContact: React.FC<{
onError: async (error) => { onError: async (error) => {
toast.error(error.message); toast.error(error.message);
}, },
} },
); );
} }
@@ -151,6 +194,28 @@ export const EditContact: React.FC<{
</FormItem> </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"> <div className="flex justify-end">
<Button <Button
className=" w-[100px] " className=" w-[100px] "
@@ -23,7 +23,8 @@ import {
import { Button } from "@usesend/ui/src/button"; import { Button } from "@usesend/ui/src/button";
import { Switch } from "@usesend/ui/src/switch"; import { Switch } from "@usesend/ui/src/switch";
import { useTheme } from "@usesend/ui"; 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 { Card, CardContent, CardHeader, CardTitle } from "@usesend/ui/src/card";
import { TextWithCopyButton } from "@usesend/ui/src/text-with-copy"; import { TextWithCopyButton } from "@usesend/ui/src/text-with-copy";
import { import {
@@ -35,7 +36,140 @@ import {
Megaphone, Megaphone,
Shield, Shield,
ChevronRight, ChevronRight,
MoreVertical,
Plus,
Upload,
Edit,
Trash2,
} from "lucide-react"; } 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({ export default function ContactsPage({
params, params,
@@ -132,10 +266,11 @@ export default function ContactsPage({
</BreadcrumbList> </BreadcrumbList>
</Breadcrumb> </Breadcrumb>
</div> </div>
<div className="flex gap-4"> <ContactBookDetailActions
<BulkUploadContacts contactBookId={contactBookId} /> contactBookId={contactBookId}
<AddContact contactBookId={contactBookId} /> contactBookName={contactBookDetailQuery.data?.name}
</div> contactBookVariables={contactBookDetailQuery.data?.variables}
/>
</div> </div>
<div className="mt-10"> <div className="mt-10">
@@ -212,6 +347,23 @@ export default function ContactsPage({
: "--"} : "--"}
</p> </p>
</div> </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> </CardContent>
</Card> </Card>
@@ -329,6 +481,7 @@ export default function ContactsPage({
contactBookId={contactBookId} contactBookId={contactBookId}
contactBookName={contactBookDetailQuery.data?.name} contactBookName={contactBookDetailQuery.data?.name}
doubleOptInEnabled={contactBookDetailQuery.data?.doubleOptInEnabled} doubleOptInEnabled={contactBookDetailQuery.data?.doubleOptInEnabled}
contactBookVariables={contactBookDetailQuery.data?.variables}
/> />
</div> </div>
</div> </div>
@@ -33,6 +33,7 @@ const contactBookSchema = z.object({
name: z.string({ required_error: "Name is required" }).min(1, { name: z.string({ required_error: "Name is required" }).min(1, {
message: "Name is required", message: "Name is required",
}), }),
variables: z.string().optional(),
}); });
export default function AddContactBook() { export default function AddContactBook() {
@@ -51,6 +52,7 @@ export default function AddContactBook() {
resolver: zodResolver(contactBookSchema), resolver: zodResolver(contactBookSchema),
defaultValues: { defaultValues: {
name: "", name: "",
variables: "",
}, },
}); });
@@ -63,6 +65,10 @@ export default function AddContactBook() {
createContactBookMutation.mutate( createContactBookMutation.mutate(
{ {
name: values.name, name: values.name,
variables: values.variables
?.split(",")
.map((variable) => variable.trim())
.filter(Boolean),
}, },
{ {
onSuccess: () => { onSuccess: () => {
@@ -71,6 +77,9 @@ export default function AddContactBook() {
setOpen(false); setOpen(false);
toast.success("Contact book created successfully"); toast.success("Contact book created successfully");
}, },
onError: (error) => {
toast.error(error.message);
},
}, },
); );
} }
@@ -125,6 +134,25 @@ export default function AddContactBook() {
</FormItem> </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"> <div className="flex justify-end">
<Button <Button
className=" w-[100px]" className=" w-[100px]"
@@ -7,10 +7,15 @@ import { ContactBook } from "@prisma/client";
import { toast } from "@usesend/ui/src/toaster"; import { toast } from "@usesend/ui/src/toaster";
import { Trash2 } from "lucide-react"; import { Trash2 } from "lucide-react";
import { z } from "zod"; import { z } from "zod";
import type { ReactNode } from "react";
export const DeleteContactBook: React.FC<{ export const DeleteContactBook: React.FC<{
contactBook: Partial<ContactBook> & { id: string }; 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 = const deleteContactBookMutation =
api.contacts.deleteContactBook.useMutation(); api.contacts.deleteContactBook.useMutation();
const utils = api.useUtils(); const utils = api.useUtils();
@@ -34,14 +39,23 @@ export const DeleteContactBook: React.FC<{
contactBookId: contactBook.id, contactBookId: contactBook.id,
}, },
{ {
onSuccess: () => { onSuccess: async () => {
utils.contacts.getContactBooks.invalidate(); utils.contacts.getContactBooks.invalidate();
await onSuccess?.();
toast.success(`Contact book deleted`); 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 ( return (
<DeleteResource <DeleteResource
title="Delete Contact Book" title="Delete Contact Book"
@@ -49,11 +63,9 @@ export const DeleteContactBook: React.FC<{
schema={contactBookSchema} schema={contactBookSchema}
isLoading={deleteContactBookMutation.isPending} isLoading={deleteContactBookMutation.isPending}
onConfirm={onContactBookDelete} onConfirm={onContactBookDelete}
trigger={ open={open}
<Button variant="ghost" size="sm" className="p-0 hover:bg-transparent "> onOpenChange={onOpenChange}
<Trash2 className="h-[18px] w-[18px] text-red/80 hover:text-red/70" /> trigger={dialogTrigger}
</Button>
}
confirmLabel="Delete Contact Book" confirmLabel="Delete Contact Book"
/> />
); );
@@ -12,6 +12,7 @@ import {
import { import {
Form, Form,
FormControl, FormControl,
FormDescription,
FormField, FormField,
FormItem, FormItem,
FormLabel, FormLabel,
@@ -24,63 +25,99 @@ import { z } from "zod";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { toast } from "@usesend/ui/src/toaster"; import { toast } from "@usesend/ui/src/toaster";
import type { ReactNode } from "react";
const contactBookSchema = z.object({ const contactBookSchema = z.object({
name: z.string().min(1, { message: "Name is required" }), name: z.string().min(1, { message: "Name is required" }),
variables: z.string().optional(),
}); });
export const EditContactBook: React.FC<{ export const EditContactBook: React.FC<{
contactBook: { id: string; name: string }; contactBook: { id: string; name: string; variables?: string[] };
}> = ({ contactBook }) => { trigger?: ReactNode;
open?: boolean;
onOpenChange?: (open: boolean) => void;
onSuccess?: () => void | Promise<void>;
}> = ({
contactBook,
trigger,
open: controlledOpen,
onOpenChange,
onSuccess,
}) => {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const updateContactBookMutation = const updateContactBookMutation =
api.contacts.updateContactBook.useMutation(); api.contacts.updateContactBook.useMutation();
const utils = api.useUtils(); 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>>({ const contactBookForm = useForm<z.infer<typeof contactBookSchema>>({
resolver: zodResolver(contactBookSchema), resolver: zodResolver(contactBookSchema),
defaultValues: { defaultValues: {
name: contactBook.name || "", name: contactBook.name || "",
variables: (contactBook.variables ?? []).join(", "),
}, },
}); });
async function onContactBookUpdate( async function onContactBookUpdate(
values: z.infer<typeof contactBookSchema> values: z.infer<typeof contactBookSchema>,
) { ) {
updateContactBookMutation.mutate( updateContactBookMutation.mutate(
{ {
contactBookId: contactBook.id, contactBookId: contactBook.id,
...values, name: values.name,
variables: values.variables
?.split(",")
.map((variable) => variable.trim())
.filter(Boolean),
}, },
{ {
onSuccess: async () => { onSuccess: async () => {
utils.contacts.getContactBooks.invalidate(); utils.contacts.getContactBooks.invalidate();
setOpen(false); await onSuccess?.();
if (controlledOpen === undefined) {
setOpen(false);
} else {
onOpenChange?.(false);
}
toast.success("Contact book updated successfully"); toast.success("Contact book updated successfully");
}, },
onError: async (error) => { onError: async (error) => {
toast.error(error.message); toast.error(error.message);
}, },
} },
); );
} }
return ( return (
<Dialog <Dialog
open={open} open={controlledOpen ?? open}
onOpenChange={(_open) => (_open !== open ? setOpen(_open) : null)} onOpenChange={(nextOpen) => {
if (controlledOpen === undefined) {
if (nextOpen !== open) {
setOpen(nextOpen);
}
return;
}
onOpenChange?.(nextOpen);
}}
> >
<DialogTrigger asChild> {dialogTrigger ? (
<Button <DialogTrigger asChild>{dialogTrigger}</DialogTrigger>
variant="ghost" ) : null}
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>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>Edit Contact Book</DialogTitle> <DialogTitle>Edit Contact Book</DialogTitle>
@@ -104,6 +141,25 @@ export const EditContactBook: React.FC<{
</FormItem> </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"> <div className="flex justify-end">
<Button <Button
className=" w-[100px]" className=" w-[100px]"
+88
View File
@@ -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", description: "Custom properties for the contact book",
example: { customField1: "value1" }, 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({ emoji: z.string().openapi({
description: "The emoji associated with the contact book", description: "The emoji associated with the contact book",
example: "📙", example: "📙",
+179 -175
View File
@@ -11,201 +11,204 @@ import * as contactService from "~/server/service/contact-service";
import * as contactBookService from "~/server/service/contact-book-service"; import * as contactBookService from "~/server/service/contact-book-service";
export const contactsRouter = createTRPCRouter({ export const contactsRouter = createTRPCRouter({
getContactBooks: teamProcedure getContactBooks: teamProcedure
.input(z.object({ search: z.string().optional() })) .input(z.object({ search: z.string().optional() }))
.query(async ({ ctx: { team }, input }) => { .query(async ({ ctx: { team }, input }) => {
return contactBookService.getContactBooks(team.id, input.search); return contactBookService.getContactBooks(team.id, input.search);
}), }),
createContactBook: teamProcedure createContactBook: teamProcedure
.input( .input(
z.object({ z.object({
name: z.string(), name: z.string(),
}), variables: z.array(z.string()).optional(),
) }),
.mutation(async ({ ctx: { team }, input }) => { )
const { name } = input; .mutation(async ({ ctx: { team }, input }) => {
return contactBookService.createContactBook(team.id, name); const { name, variables } = input;
}), return contactBookService.createContactBook(team.id, name, variables);
}),
getContactBookDetails: contactBookProcedure.query( getContactBookDetails: contactBookProcedure.query(
async ({ ctx: { contactBook } }) => { async ({ ctx: { contactBook } }) => {
const { totalContacts, unsubscribedContacts, campaigns } = const { totalContacts, unsubscribedContacts, campaigns } =
await contactBookService.getContactBookDetails(contactBook.id); await contactBookService.getContactBookDetails(contactBook.id);
return { return {
...contactBook, ...contactBook,
totalContacts, totalContacts,
unsubscribedContacts, unsubscribedContacts,
campaigns, campaigns,
}; };
}, },
), ),
updateContactBook: contactBookProcedure updateContactBook: contactBookProcedure
.input( .input(
z.object({ z.object({
contactBookId: z.string(), contactBookId: z.string(),
name: z.string().optional(), name: z.string().optional(),
properties: z.record(z.string()).optional(), properties: z.record(z.string()).optional(),
emoji: z.string().optional(), emoji: z.string().optional(),
doubleOptInEnabled: z.boolean().optional(), doubleOptInEnabled: z.boolean().optional(),
doubleOptInFrom: z.string().nullable().optional(), doubleOptInFrom: z.string().nullable().optional(),
doubleOptInSubject: z.string().optional(), doubleOptInSubject: z.string().optional(),
doubleOptInContent: z.string().optional(), doubleOptInContent: z.string().optional(),
}), variables: z.array(z.string()).optional(),
) }),
.mutation(async ({ ctx: { contactBook }, input }) => { )
const { contactBookId, ...data } = input; .mutation(async ({ ctx: { contactBook }, input }) => {
return contactBookService.updateContactBook(contactBook.id, data); const { contactBookId, ...data } = input;
}), return contactBookService.updateContactBook(contactBook.id, data);
}),
deleteContactBook: contactBookProcedure deleteContactBook: contactBookProcedure
.input(z.object({ contactBookId: z.string() })) .input(z.object({ contactBookId: z.string() }))
.mutation(async ({ ctx: { contactBook }, input }) => { .mutation(async ({ ctx: { contactBook }, input }) => {
return contactBookService.deleteContactBook(contactBook.id); return contactBookService.deleteContactBook(contactBook.id);
}), }),
contacts: contactBookProcedure contacts: contactBookProcedure
.input( .input(
z.object({ z.object({
page: z.number().optional(), page: z.number().optional(),
subscribed: z.boolean().optional(), subscribed: z.boolean().optional(),
search: z.string().optional(), search: z.string().optional(),
}), }),
) )
.query(async ({ ctx: { db }, input }) => { .query(async ({ ctx: { db }, input }) => {
const page = input.page || 1; const page = input.page || 1;
const limit = 30; const limit = 30;
const offset = (page - 1) * limit; const offset = (page - 1) * limit;
const whereConditions: Prisma.ContactFindManyArgs["where"] = { const whereConditions: Prisma.ContactFindManyArgs["where"] = {
contactBookId: input.contactBookId, contactBookId: input.contactBookId,
...(input.subscribed !== undefined ...(input.subscribed !== undefined
? { subscribed: input.subscribed } ? { subscribed: input.subscribed }
: {}), : {}),
...(input.search ...(input.search
? { ? {
OR: [ OR: [
{ email: { contains: input.search, mode: "insensitive" } }, { email: { contains: input.search, mode: "insensitive" } },
{ firstName: { contains: input.search, mode: "insensitive" } }, { firstName: { contains: input.search, mode: "insensitive" } },
{ lastName: { 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({ const contactsP = db.contact.findMany({
where: whereConditions, where: whereConditions,
select: { select: {
id: true, id: true,
email: true, email: true,
firstName: true, firstName: true,
lastName: true, lastName: true,
subscribed: true, properties: true,
createdAt: true, subscribed: true,
contactBookId: true, createdAt: true,
unsubscribeReason: true, contactBookId: true,
}, unsubscribeReason: true,
orderBy: { },
createdAt: "desc", orderBy: {
}, createdAt: "desc",
skip: offset, },
take: limit, 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 addContacts: contactBookProcedure
.input( .input(
z.object({ z.object({
contacts: z contacts: z
.array( .array(
z.object({ z.object({
email: z.string(), email: z.string(),
firstName: z.string().optional(), firstName: z.string().optional(),
lastName: z.string().optional(), lastName: z.string().optional(),
properties: z.record(z.string()).optional(), properties: z.record(z.string()).optional(),
subscribed: z.boolean().optional(), subscribed: z.boolean().optional(),
}), }),
) )
.max(50000), .max(50000),
}), }),
) )
.mutation(async ({ ctx: { contactBook, team }, input }) => { .mutation(async ({ ctx: { contactBook, team }, input }) => {
return contactService.bulkAddContacts( return contactService.bulkAddContacts(
contactBook.id, contactBook.id,
input.contacts, input.contacts,
team.id, team.id,
); );
}), }),
updateContact: contactBookProcedure updateContact: contactBookProcedure
.input( .input(
z.object({ z.object({
contactId: z.string(), contactId: z.string(),
email: z.string().optional(), email: z.string().optional(),
firstName: z.string().optional(), firstName: z.string().optional(),
lastName: z.string().optional(), lastName: z.string().optional(),
properties: z.record(z.string()).optional(), properties: z.record(z.string()).optional(),
subscribed: z.boolean().optional(), subscribed: z.boolean().optional(),
}), }),
) )
.mutation(async ({ ctx: { contactBook, team }, input }) => { .mutation(async ({ ctx: { contactBook, team }, input }) => {
const { contactId, ...contact } = input; const { contactId, ...contact } = input;
const updatedContact = await contactService.updateContactInContactBook( const updatedContact = await contactService.updateContactInContactBook(
contactId, contactId,
contactBook.id, contactBook.id,
contact, contact,
team.id, team.id,
); );
if (!updatedContact) { if (!updatedContact) {
throw new TRPCError({ throw new TRPCError({
code: "NOT_FOUND", code: "NOT_FOUND",
message: "Contact not found", message: "Contact not found",
}); });
} }
return updatedContact; return updatedContact;
}), }),
deleteContact: contactBookProcedure deleteContact: contactBookProcedure
.input(z.object({ contactId: z.string() })) .input(z.object({ contactId: z.string() }))
.mutation(async ({ ctx: { contactBook, team }, input }) => { .mutation(async ({ ctx: { contactBook, team }, input }) => {
const deletedContact = await contactService.deleteContactInContactBook( const deletedContact = await contactService.deleteContactInContactBook(
input.contactId, input.contactId,
contactBook.id, contactBook.id,
team.id, team.id,
); );
if (!deletedContact) { if (!deletedContact) {
throw new TRPCError({ throw new TRPCError({
code: "NOT_FOUND", code: "NOT_FOUND",
message: "Contact not found", message: "Contact not found",
}); });
} }
return deletedContact; return deletedContact;
}), }),
bulkDeleteContacts: contactBookProcedure bulkDeleteContacts: contactBookProcedure
.input(z.object({ contactIds: z.array(z.string()).min(1).max(1000) })) .input(z.object({ contactIds: z.array(z.string()).min(1).max(1000) }))
.mutation(async ({ ctx: { contactBook, team }, input }) => { .mutation(async ({ ctx: { contactBook, team }, input }) => {
const deletedContacts = const deletedContacts =
await contactService.bulkDeleteContactsInContactBook( await contactService.bulkDeleteContactsInContactBook(
input.contactIds, input.contactIds,
contactBook.id, contactBook.id,
team.id, team.id,
); );
return { count: deletedContacts.length }; return { count: deletedContacts.length };
}), }),
resendDoubleOptInConfirmation: contactBookProcedure resendDoubleOptInConfirmation: contactBookProcedure
.input(z.object({ contactId: z.string() })) .input(z.object({ contactId: z.string() }))
@@ -276,6 +279,7 @@ export const contactsRouter = createTRPCRouter({
email: true, email: true,
firstName: true, firstName: true,
lastName: true, lastName: true,
properties: true,
subscribed: true, subscribed: true,
unsubscribeReason: true, unsubscribeReason: true,
createdAt: true, createdAt: true,
@@ -56,6 +56,7 @@ function buildContactBook(overrides?: Record<string, unknown>) {
name: "Newsletter", name: "Newsletter",
teamId: 1, teamId: 1,
properties: {}, properties: {},
variables: [],
emoji: "📙", emoji: "📙",
doubleOptInEnabled: true, doubleOptInEnabled: true,
doubleOptInFrom: null, doubleOptInFrom: null,
@@ -116,6 +117,7 @@ describe("POST /v1/contactBooks", () => {
expect(mockCreateContactBook).toHaveBeenCalledWith( expect(mockCreateContactBook).toHaveBeenCalledWith(
1, 1,
"Newsletter", "Newsletter",
undefined,
mockTransactionClient, mockTransactionClient,
); );
expect(mockUpdateContactBook).not.toHaveBeenCalled(); expect(mockUpdateContactBook).not.toHaveBeenCalled();
@@ -169,6 +171,7 @@ describe("POST /v1/contactBooks", () => {
expect(mockCreateContactBook).toHaveBeenCalledWith( expect(mockCreateContactBook).toHaveBeenCalledWith(
1, 1,
"Product Updates", "Product Updates",
undefined,
mockTransactionClient, mockTransactionClient,
); );
expect(mockUpdateContactBook).toHaveBeenCalledWith( 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 () => { it("returns BAD_REQUEST when name is missing", async () => {
const app = getApp(); const app = getApp();
createContactBookRoute(app); createContactBookRoute(app);
@@ -23,6 +23,7 @@ const route = createRoute({
doubleOptInFrom: z.string().nullable().optional(), doubleOptInFrom: z.string().nullable().optional(),
doubleOptInSubject: z.string().optional(), doubleOptInSubject: z.string().optional(),
doubleOptInContent: z.string().optional(), doubleOptInContent: z.string().optional(),
variables: z.array(z.string()).optional(),
}), }),
}, },
}, },
@@ -54,7 +55,12 @@ function createContactBook(app: PublicAPIApp) {
body.doubleOptInContent !== undefined; body.doubleOptInContent !== undefined;
const contactBook = await db.$transaction(async (tx) => { 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) { if (!hasOptionalFields) {
return created; return created;
@@ -77,6 +83,7 @@ function createContactBook(app: PublicAPIApp) {
return c.json({ return c.json({
...contactBook, ...contactBook,
properties: contactBook.properties as Record<string, string>, properties: contactBook.properties as Record<string, string>,
variables: contactBook.variables,
}); });
}); });
} }
@@ -5,81 +5,81 @@ import { db } from "~/server/db";
import { UnsendApiError } from "../../api-error"; import { UnsendApiError } from "../../api-error";
const route = createRoute({ const route = createRoute({
method: "get", method: "get",
path: "/v1/contactBooks/{contactBookId}", path: "/v1/contactBooks/{contactBookId}",
request: { request: {
params: z.object({ params: z.object({
contactBookId: z.string().openapi({ contactBookId: z.string().openapi({
param: { param: {
name: "contactBookId", name: "contactBookId",
in: "path", in: "path",
}, },
example: "clx1234567890", example: "clx1234567890",
}), }),
}), }),
}, },
responses: { responses: {
200: { 200: {
content: { content: {
"application/json": { "application/json": {
schema: ContactBookSchema, schema: ContactBookSchema,
}, },
}, },
description: "Retrieve the contact book", description: "Retrieve the contact book",
}, },
403: { 403: {
content: { content: {
"application/json": { "application/json": {
schema: z.object({ schema: z.object({
error: z.string(), error: z.string(),
}), }),
}, },
}, },
description: description:
"Forbidden - API key doesn't have access to this contact book", "Forbidden - API key doesn't have access to this contact book",
}, },
404: { 404: {
content: { content: {
"application/json": { "application/json": {
schema: z.object({ schema: z.object({
error: z.string(), error: z.string(),
}), }),
}, },
}, },
description: "Contact book not found", description: "Contact book not found",
}, },
}, },
}); });
function getContactBook(app: PublicAPIApp) { function getContactBook(app: PublicAPIApp) {
app.openapi(route, async (c) => { app.openapi(route, async (c) => {
const team = c.var.team; const team = c.var.team;
const contactBookId = c.req.valid("param").contactBookId; const contactBookId = c.req.valid("param").contactBookId;
const contactBook = await db.contactBook.findFirst({ const contactBook = await db.contactBook.findFirst({
where: { where: {
id: contactBookId, id: contactBookId,
teamId: team.id, teamId: team.id,
}, },
include: { include: {
_count: { _count: {
select: { contacts: true }, select: { contacts: true },
}, },
}, },
}); });
if (!contactBook) { if (!contactBook) {
throw new UnsendApiError({ throw new UnsendApiError({
code: "NOT_FOUND", code: "NOT_FOUND",
message: "Contact book not found", message: "Contact book not found",
}); });
} }
return c.json({ return c.json({
...contactBook, ...contactBook,
properties: contactBook.properties as Record<string, string>, properties: contactBook.properties as Record<string, string>,
}); });
}); });
} }
export default getContactBook; export default getContactBook;
@@ -4,34 +4,35 @@ import { PublicAPIApp } from "~/server/public-api/hono";
import { getContactBooks as getContactBooksService } from "~/server/service/contact-book-service"; import { getContactBooks as getContactBooksService } from "~/server/service/contact-book-service";
const route = createRoute({ const route = createRoute({
method: "get", method: "get",
path: "/v1/contactBooks", path: "/v1/contactBooks",
responses: { responses: {
200: { 200: {
content: { content: {
"application/json": { "application/json": {
schema: z.array(ContactBookSchema), schema: z.array(ContactBookSchema),
}, },
}, },
description: "Retrieve contact books accessible by the API key", description: "Retrieve contact books accessible by the API key",
}, },
}, },
}); });
function getContactBooks(app: PublicAPIApp) { function getContactBooks(app: PublicAPIApp) {
app.openapi(route, async (c) => { app.openapi(route, async (c) => {
const team = c.var.team; const team = c.var.team;
const contactBooks = await getContactBooksService(team.id); const contactBooks = await getContactBooksService(team.id);
// Ensure properties is a Record<string, string> // Ensure properties is a Record<string, string>
const sanitizedContactBooks = contactBooks.map((contactBook) => ({ const sanitizedContactBooks = contactBooks.map((contactBook) => ({
...contactBook, ...contactBook,
properties: contactBook.properties as Record<string, string>, properties: contactBook.properties as Record<string, string>,
})); variables: contactBook.variables,
}));
return c.json(sanitizedContactBooks); return c.json(sanitizedContactBooks);
}); });
} }
export default getContactBooks; export default getContactBooks;
@@ -29,6 +29,7 @@ const route = createRoute({
doubleOptInFrom: z.string().nullable().optional(), doubleOptInFrom: z.string().nullable().optional(),
doubleOptInSubject: z.string().optional(), doubleOptInSubject: z.string().optional(),
doubleOptInContent: z.string().optional(), doubleOptInContent: z.string().optional(),
variables: z.array(z.string()).optional(),
}), }),
}, },
}, },
@@ -80,6 +81,7 @@ function updateContactBook(app: PublicAPIApp) {
return c.json({ return c.json({
...updated, ...updated,
properties: updated.properties as Record<string, string>, properties: updated.properties as Record<string, string>,
variables: updated.variables,
}); });
}); });
} }
+166 -39
View File
@@ -2,6 +2,7 @@ import { EmailRenderer } from "@usesend/email-editor/src/renderer";
import { db } from "../db"; import { db } from "../db";
import { createHash } from "crypto"; import { createHash } from "crypto";
import { env } from "~/env"; import { env } from "~/env";
import { getContactPropertyValue } from "~/lib/contact-properties";
import { import {
Campaign, Campaign,
Contact, Contact,
@@ -36,13 +37,88 @@ const CAMPAIGN_UNSUB_PLACEHOLDER_REGEXES =
}); });
const CONTACT_VARIABLE_REGEX = 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( function campaignHasUnsubscribePlaceholder(
...sources: Array<string | null | undefined> ...sources: Array<string | null | undefined>
) { ) {
return CAMPAIGN_UNSUB_PLACEHOLDER_REGEXES.some((regex) => 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); }, html);
} }
function replaceContactVariables(html: string, contact: Contact) { function replaceContactVariables(
html: string,
contact: Contact,
allowedVariables: string[],
) {
return html.replace( return html.replace(
CONTACT_VARIABLE_REGEX, CONTACT_VARIABLE_REGEX,
(_, key: string, fallback?: string) => { (match: string, key: string, fallback?: string) => {
const valueMap: Record<string, string | null | undefined> = {
email: contact.email,
firstname: contact.firstName,
lastname: contact.lastName,
};
const normalizedKey = key.toLowerCase(); 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) { if (contactValue && contactValue.length > 0) {
return contactValue; return contactValue;
} }
return fallback ?? ""; return fallback ?? "";
} },
); );
} }
@@ -87,7 +176,7 @@ function sanitizeAddressList(addresses?: string | string[]) {
} }
async function prepareCampaignHtml( async function prepareCampaignHtml(
campaign: Campaign campaign: Campaign,
): Promise<{ campaign: Campaign; html: string }> { ): Promise<{ campaign: Campaign; html: string }> {
if (campaign.content) { if (campaign.content) {
try { try {
@@ -120,10 +209,12 @@ async function renderCampaignHtmlForContact({
campaign, campaign,
contact, contact,
unsubscribeUrl, unsubscribeUrl,
allowedVariables,
}: { }: {
campaign: Campaign; campaign: Campaign;
contact: Contact; contact: Contact;
unsubscribeUrl: string; unsubscribeUrl: string;
allowedVariables: string[];
}) { }) {
if (campaign.content) { if (campaign.content) {
try { try {
@@ -135,13 +226,31 @@ async function renderCampaignHtmlForContact({
linkValues[token] = unsubscribeUrl; 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({ return renderer.render({
shouldReplaceVariableValues: true, shouldReplaceVariableValues: true,
variableValues: { variableValues,
email: contact.email,
firstName: contact.firstName,
lastName: contact.lastName,
},
linkValues, linkValues,
}); });
} catch (error) { } catch (error) {
@@ -155,7 +264,7 @@ async function renderCampaignHtmlForContact({
} }
let html = replaceUnsubscribePlaceholders(campaign.html, unsubscribeUrl); let html = replaceUnsubscribePlaceholders(campaign.html, unsubscribeUrl);
html = replaceContactVariables(html, contact); html = replaceContactVariables(html, contact, allowedVariables);
return html; return html;
} }
@@ -245,7 +354,7 @@ export async function createCampaignFromApi({
const unsubPlaceholderFound = campaignHasUnsubscribePlaceholder( const unsubPlaceholderFound = campaignHasUnsubscribePlaceholder(
sanitizedContent, sanitizedContent,
sanitizedHtml sanitizedHtml,
); );
if (!unsubPlaceholderFound) { if (!unsubPlaceholderFound) {
@@ -351,7 +460,7 @@ export async function sendCampaign(id: string) {
const unsubPlaceholderFound = campaignHasUnsubscribePlaceholder( const unsubPlaceholderFound = campaignHasUnsubscribePlaceholder(
campaign.content, campaign.content,
html html,
); );
if (!unsubPlaceholderFound) { if (!unsubPlaceholderFound) {
@@ -430,7 +539,7 @@ export async function scheduleCampaign({
const unsubPlaceholderFound = campaignHasUnsubscribePlaceholder( const unsubPlaceholderFound = campaignHasUnsubscribePlaceholder(
campaign.content, campaign.content,
html html,
); );
if (!unsubPlaceholderFound) { if (!unsubPlaceholderFound) {
throw new UnsendApiError({ throw new UnsendApiError({
@@ -699,6 +808,7 @@ export async function deleteCampaign(id: string, teamId: number) {
type CampaignEmailJob = { type CampaignEmailJob = {
contact: Contact; contact: Contact;
campaign: Campaign; campaign: Campaign;
allowedVariables: string[];
emailConfig: { emailConfig: {
from: string; from: string;
subject: string; subject: string;
@@ -714,12 +824,12 @@ type CampaignEmailJob = {
}; };
async function processContactEmail(jobData: 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 unsubscribeUrl = createUnsubUrl(contact.id, emailConfig.campaignId);
const oneClickUnsubUrl = createOneClickUnsubUrl( const oneClickUnsubUrl = createOneClickUnsubUrl(
contact.id, contact.id,
emailConfig.campaignId emailConfig.campaignId,
); );
// Check for suppressed emails before processing // Check for suppressed emails before processing
@@ -734,18 +844,18 @@ async function processContactEmail(jobData: CampaignEmailJob) {
const suppressionResults = await SuppressionService.checkMultipleEmails( const suppressionResults = await SuppressionService.checkMultipleEmails(
allEmailsToCheck, allEmailsToCheck,
emailConfig.teamId emailConfig.teamId,
); );
// Filter each field separately // Filter each field separately
const filteredToEmails = toEmails.filter( const filteredToEmails = toEmails.filter(
(email) => !suppressionResults[email] (email) => !suppressionResults[email],
); );
const filteredCcEmails = ccEmails.filter( const filteredCcEmails = ccEmails.filter(
(email) => !suppressionResults[email] (email) => !suppressionResults[email],
); );
const filteredBccEmails = bccEmails.filter( const filteredBccEmails = bccEmails.filter(
(email) => !suppressionResults[email] (email) => !suppressionResults[email],
); );
// Check if the contact's email (TO recipient) is suppressed // Check if the contact's email (TO recipient) is suppressed
@@ -755,6 +865,7 @@ async function processContactEmail(jobData: CampaignEmailJob) {
campaign, campaign,
contact, contact,
unsubscribeUrl, unsubscribeUrl,
allowedVariables,
}); });
if (isContactSuppressed) { if (isContactSuppressed) {
@@ -765,7 +876,7 @@ async function processContactEmail(jobData: CampaignEmailJob) {
campaignId: emailConfig.campaignId, campaignId: emailConfig.campaignId,
teamId: emailConfig.teamId, 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({ const email = await db.email.create({
@@ -821,7 +932,7 @@ async function processContactEmail(jobData: CampaignEmailJob) {
campaignId: emailConfig.campaignId, campaignId: emailConfig.campaignId,
teamId: emailConfig.teamId, 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, campaignId: emailConfig.campaignId,
teamId: emailConfig.teamId, 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) { } catch (error) {
logger.error( logger.error(
{ err: error }, { err: error },
"Failed to create campaign email record so skipping email sending" "Failed to create campaign email record so skipping email sending",
); );
return; return;
} }
@@ -877,14 +988,14 @@ async function processContactEmail(jobData: CampaignEmailJob) {
emailConfig.teamId, emailConfig.teamId,
emailConfig.region, emailConfig.region,
false, false,
oneClickUnsubUrl oneClickUnsubUrl,
); );
} }
export async function updateCampaignAnalytics( export async function updateCampaignAnalytics(
campaignId: string, campaignId: string,
emailStatus: EmailStatus, emailStatus: EmailStatus,
hardBounce: boolean = false hardBounce: boolean = false,
) { ) {
const campaign = await db.campaign.findUnique({ const campaign = await db.campaign.findUnique({
where: { id: campaignId }, where: { id: campaignId },
@@ -941,7 +1052,7 @@ export class CampaignBatchService {
connection: getRedis(), connection: getRedis(),
prefix: BULL_PREFIX, prefix: BULL_PREFIX,
skipVersionCheck: true, skipVersionCheck: true,
} },
); );
static worker = new Worker( static worker = new Worker(
@@ -987,6 +1098,16 @@ export class CampaignBatchService {
const contacts = await db.contact.findMany({ where, ...pagination }); 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) { if (contacts.length === 0) {
// No more contacts -> mark SENT // No more contacts -> mark SENT
await db.campaign.update({ await db.campaign.update({
@@ -1019,6 +1140,7 @@ export class CampaignBatchService {
await processContactEmail({ await processContactEmail({
contact, contact,
campaign, campaign,
allowedVariables,
emailConfig: { emailConfig: {
from: campaign.from, from: campaign.from,
subject: campaign.subject, subject: campaign.subject,
@@ -1041,7 +1163,12 @@ export class CampaignBatchService {
data: { lastCursor: newCursor, lastSentAt: new Date() }, 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({ static async queueBatch({
@@ -1066,7 +1193,7 @@ export class CampaignBatchService {
if (elapsedMs < windowMs) { if (elapsedMs < windowMs) {
logger.debug( logger.debug(
{ campaignId, remainingMs: windowMs - elapsedMs }, { campaignId, remainingMs: windowMs - elapsedMs },
"Defensive skip enqueue; window not elapsed" "Defensive skip enqueue; window not elapsed",
); );
return; return;
} }
@@ -1074,14 +1201,14 @@ export class CampaignBatchService {
} catch (err) { } catch (err) {
logger.warn( logger.warn(
{ err, campaignId }, { err, campaignId },
"Failed defensive window check; proceeding to enqueue" "Failed defensive window check; proceeding to enqueue",
); );
} }
await this.batchQueue.add( await this.batchQueue.add(
`campaign-${campaignId}`, `campaign-${campaignId}`,
{ campaignId, teamId }, { 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 { CampaignStatus } from "@prisma/client";
import { db } from "../db";
import { LimitService } from "./limit-service";
import { UnsendApiError } from "../public-api/api-error";
import { import {
DEFAULT_DOUBLE_OPT_IN_CONTENT, DEFAULT_DOUBLE_OPT_IN_CONTENT,
DEFAULT_DOUBLE_OPT_IN_SUBJECT, DEFAULT_DOUBLE_OPT_IN_SUBJECT,
hasDoubleOptInUrlPlaceholder, hasDoubleOptInUrlPlaceholder,
} from "~/lib/constants/double-opt-in"; } from "~/lib/constants/double-opt-in";
import { db } from "../db";
import { UnsendApiError } from "../public-api/api-error";
import { validateDomainFromEmail } from "./domain-service"; import { validateDomainFromEmail } from "./domain-service";
import { LimitService } from "./limit-service";
import {
normalizeContactBookVariables,
validateContactBookVariables,
} from "./contact-variable-service";
type ContactBookDbClient = Pick<typeof db, "contactBook">; type ContactBookDbClient = Pick<typeof db, "contactBook">;
@@ -22,6 +26,7 @@ export async function getContactBooks(teamId: number, search?: string) {
name: true, name: true,
teamId: true, teamId: true,
properties: true, properties: true,
variables: true,
emoji: true, emoji: true,
createdAt: true, createdAt: true,
updatedAt: true, updatedAt: true,
@@ -39,6 +44,7 @@ export async function getContactBooks(teamId: number, search?: string) {
export async function createContactBook( export async function createContactBook(
teamId: number, teamId: number,
name: string, name: string,
variables?: string[],
client: ContactBookDbClient = db, client: ContactBookDbClient = db,
) { ) {
const { isLimitReached, reason } = 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({ const created = await client.contactBook.create({
data: { data: {
name, name,
teamId, teamId,
properties: {}, properties: {},
variables: normalizedVariables,
doubleOptInEnabled: true, doubleOptInEnabled: true,
doubleOptInSubject: DEFAULT_DOUBLE_OPT_IN_SUBJECT, doubleOptInSubject: DEFAULT_DOUBLE_OPT_IN_SUBJECT,
doubleOptInContent: DEFAULT_DOUBLE_OPT_IN_CONTENT, doubleOptInContent: DEFAULT_DOUBLE_OPT_IN_CONTENT,
@@ -98,6 +116,7 @@ export async function updateContactBook(
name?: string; name?: string;
properties?: Record<string, string>; properties?: Record<string, string>;
emoji?: string; emoji?: string;
variables?: string[];
doubleOptInEnabled?: boolean; doubleOptInEnabled?: boolean;
doubleOptInFrom?: string | null; doubleOptInFrom?: string | null;
doubleOptInSubject?: string; doubleOptInSubject?: string;
@@ -105,7 +124,39 @@ export async function updateContactBook(
}, },
client: ContactBookDbClient = db, 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) { if (data.doubleOptInFrom !== undefined) {
const normalizedFrom = data.doubleOptInFrom?.trim() ?? ""; const normalizedFrom = data.doubleOptInFrom?.trim() ?? "";
@@ -84,6 +84,7 @@ describe("contact-book-service", () => {
name: "Newsletter", name: "Newsletter",
teamId: 12, teamId: 12,
properties: {}, properties: {},
variables: [],
doubleOptInEnabled: true, doubleOptInEnabled: true,
doubleOptInSubject: DEFAULT_DOUBLE_OPT_IN_SUBJECT, doubleOptInSubject: DEFAULT_DOUBLE_OPT_IN_SUBJECT,
doubleOptInContent: DEFAULT_DOUBLE_OPT_IN_CONTENT, doubleOptInContent: DEFAULT_DOUBLE_OPT_IN_CONTENT,
+41 -3
View File
@@ -3,6 +3,10 @@ import {
type ContactPayload, type ContactPayload,
type ContactWebhookEventType, type ContactWebhookEventType,
} from "@usesend/lib/src/webhook/webhook-events"; } from "@usesend/lib/src/webhook/webhook-events";
import {
mergeContactProperties,
normalizeContactProperties,
} from "~/lib/contact-properties";
import { db } from "../db"; import { db } from "../db";
import { ContactQueueService } from "./contact-queue-service"; import { ContactQueueService } from "./contact-queue-service";
import { WebhookService } from "./webhook-service"; import { WebhookService } from "./webhook-service";
@@ -29,6 +33,7 @@ export async function addOrUpdateContact(
select: { select: {
doubleOptInEnabled: true, doubleOptInEnabled: true,
teamId: true, teamId: true,
variables: true,
}, },
}); });
@@ -47,6 +52,7 @@ export async function addOrUpdateContact(
select: { select: {
subscribed: true, subscribed: true,
unsubscribeReason: true, unsubscribeReason: true,
properties: true,
}, },
}); });
@@ -75,6 +81,19 @@ export async function addOrUpdateContact(
existingContact === null && existingContact === null &&
!isExplicitUnsubscribeRequest; !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({ const savedContact = await db.contact.upsert({
where: { where: {
contactBookId_email: { contactBookId_email: {
@@ -87,7 +106,7 @@ export async function addOrUpdateContact(
email: contact.email, email: contact.email,
firstName: contact.firstName, firstName: contact.firstName,
lastName: contact.lastName, lastName: contact.lastName,
properties: contact.properties ?? {}, properties: normalizedProperties ?? {},
subscribed: shouldCreatePendingContact subscribed: shouldCreatePendingContact
? false ? false
: (contact.subscribed ?? true), : (contact.subscribed ?? true),
@@ -100,7 +119,9 @@ export async function addOrUpdateContact(
update: { update: {
firstName: contact.firstName, firstName: contact.firstName,
lastName: contact.lastName, lastName: contact.lastName,
properties: contact.properties ?? {}, ...(mergedProperties !== undefined
? { properties: mergedProperties }
: {}),
...(subscribedValue !== undefined ...(subscribedValue !== undefined
? { ? {
subscribed: subscribedValue, subscribed: subscribedValue,
@@ -168,12 +189,29 @@ export async function updateContactInContactBook(
return null; 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({ const updatedContact = await db.contact.update({
where: { where: {
id: contactId, id: contactId,
}, },
data: { data: {
...contact, ...contact,
...(mergedProperties !== undefined
? { properties: mergedProperties }
: {}),
...(contact.subscribed !== undefined ...(contact.subscribed !== undefined
? { ? {
unsubscribeReason: contact.subscribed unsubscribeReason: contact.subscribed
@@ -243,7 +281,7 @@ export async function bulkDeleteContactsInContactBook(
), ),
); );
return contacts; return contacts;
} }
export async function resendDoubleOptInConfirmationInContactBook( export async function resendDoubleOptInConfirmationInContactBook(
@@ -14,6 +14,7 @@ const {
contact: { contact: {
findFirst: vi.fn(), findFirst: vi.fn(),
findUnique: vi.fn(), findUnique: vi.fn(),
update: vi.fn(),
upsert: vi.fn(), upsert: vi.fn(),
}, },
}, },
@@ -53,6 +54,7 @@ vi.mock("~/server/logger/log", () => ({
import { import {
addOrUpdateContact, addOrUpdateContact,
resendDoubleOptInConfirmationInContactBook, resendDoubleOptInConfirmationInContactBook,
updateContactInContactBook,
} from "~/server/service/contact-service"; } from "~/server/service/contact-service";
const createdAt = new Date("2026-02-08T00:00:00.000Z"); const createdAt = new Date("2026-02-08T00:00:00.000Z");
@@ -62,6 +64,7 @@ describe("contact-service addOrUpdateContact", () => {
mockDb.contactBook.findUnique.mockReset(); mockDb.contactBook.findUnique.mockReset();
mockDb.contact.findFirst.mockReset(); mockDb.contact.findFirst.mockReset();
mockDb.contact.findUnique.mockReset(); mockDb.contact.findUnique.mockReset();
mockDb.contact.update.mockReset();
mockDb.contact.upsert.mockReset(); mockDb.contact.upsert.mockReset();
mockWebhookEmit.mockReset(); mockWebhookEmit.mockReset();
mockSendDoubleOptInConfirmationEmail.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 () => { it("throws when contact book does not exist", async () => {
mockDb.contactBook.findUnique.mockResolvedValue(null); mockDb.contactBook.findUnique.mockResolvedValue(null);
@@ -325,4 +456,59 @@ describe("contact-service addOrUpdateContact", () => {
).resolves.toBeNull(); ).resolves.toBeNull();
expect(mockSendDoubleOptInConfirmationEmail).not.toHaveBeenCalled(); 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.`,
);
}
}
}
+7 -1
View File
@@ -69,6 +69,7 @@ export type EditorProps = {
initialContent?: Content; initialContent?: Content;
variables?: Array<string>; variables?: Array<string>;
uploadImage?: UploadFn; uploadImage?: UploadFn;
variableSuggestionsHelperText?: string;
}; };
export const Editor: React.FC<EditorProps> = ({ export const Editor: React.FC<EditorProps> = ({
@@ -76,6 +77,7 @@ export const Editor: React.FC<EditorProps> = ({
initialContent, initialContent,
variables, variables,
uploadImage, uploadImage,
variableSuggestionsHelperText,
}) => { }) => {
const menuContainerRef = useRef(null); const menuContainerRef = useRef(null);
@@ -96,7 +98,11 @@ export const Editor: React.FC<EditorProps> = ({
}, },
}, },
}, },
extensions: extensions({ variables, uploadImage }), extensions: extensions({
variables,
uploadImage,
variableSuggestionsHelperText,
}),
onUpdate: ({ editor }) => { onUpdate: ({ editor }) => {
onUpdate?.(editor); onUpdate?.(editor);
}, },
@@ -21,9 +21,11 @@ import { ResizableImageExtension, UploadFn } from "./ImageExtension";
export function extensions({ export function extensions({
variables, variables,
uploadImage, uploadImage,
variableSuggestionsHelperText,
}: { }: {
variables?: Array<string>; variables?: Array<string>;
uploadImage?: UploadFn; uploadImage?: UploadFn;
variableSuggestionsHelperText?: string;
}) { }) {
const extensions = [ const extensions = [
StarterKit.configure({ StarterKit.configure({
@@ -79,7 +81,10 @@ export function extensions({
ButtonExtension, ButtonExtension,
GlobalDragHandle, GlobalDragHandle,
VariableExtension.configure({ VariableExtension.configure({
suggestion: getVariableSuggestions(variables), suggestion: getVariableSuggestions(
variables,
variableSuggestionsHelperText,
),
}), }),
UnsubscribeFooterExtension, UnsubscribeFooterExtension,
ResizableImageExtension.configure({ uploadImage }), ResizableImageExtension.configure({ uploadImage }),
+19 -6
View File
@@ -36,7 +36,7 @@ export const VariableList = forwardRef((props: any, ref) => {
onKeyDown: ({ event }: { event: KeyboardEvent }) => { onKeyDown: ({ event }: { event: KeyboardEvent }) => {
if (event.key === "ArrowUp") { if (event.key === "ArrowUp") {
setSelectedIndex( setSelectedIndex(
(selectedIndex + props.items.length - 1) % props.items.length (selectedIndex + props.items.length - 1) % props.items.length,
); );
return true; return true;
} }
@@ -64,7 +64,7 @@ export const VariableList = forwardRef((props: any, ref) => {
onClick={() => selectItem(index)} onClick={() => selectItem(index)}
className={cn( className={cn(
"flex w-full space-x-2 rounded-md px-2 py-1 text-left text-sm text-gray-900 hover:bg-gray-100", "flex w-full space-x-2 rounded-md px-2 py-1 text-left text-sm text-gray-900 hover:bg-gray-100",
index === selectedIndex ? "bg-gray-200" : "bg-white" index === selectedIndex ? "bg-gray-200" : "bg-white",
)} )}
> >
{item} {item}
@@ -75,6 +75,11 @@ export const VariableList = forwardRef((props: any, ref) => {
No result No result
</button> </button>
)} )}
{props.helperText ? (
<div className="px-2 pt-1 text-[11px] text-gray-400">
{props.helperText}
</div>
) : null}
</div> </div>
); );
}); });
@@ -82,14 +87,15 @@ export const VariableList = forwardRef((props: any, ref) => {
VariableList.displayName = "VariableList"; VariableList.displayName = "VariableList";
export function getVariableSuggestions( export function getVariableSuggestions(
variables: Array<string> = [] variables: Array<string> = [],
helperText?: string,
): Omit<SuggestionOptions, "editor"> { ): Omit<SuggestionOptions, "editor"> {
return { return {
items: ({ query }) => { items: ({ query }) => {
return variables return variables
.concat(query.length > 0 ? [query] : []) .concat(query.length > 0 ? [query] : [])
.filter((item) => item.toLowerCase().startsWith(query.toLowerCase())) .filter((item) => item.toLowerCase().startsWith(query.toLowerCase()))
.slice(0, 5); .slice(0, 10);
}, },
render: () => { render: () => {
@@ -102,6 +108,10 @@ export function getVariableSuggestions(
props, props,
editor: props.editor, editor: props.editor,
}); });
component.updateProps({
...props,
helperText,
});
if (!props.clientRect) { if (!props.clientRect) {
return; return;
@@ -119,7 +129,10 @@ export function getVariableSuggestions(
}, },
onUpdate(props) { onUpdate(props) {
component.updateProps(props); component.updateProps({
...props,
helperText,
});
if (!props.clientRect) { if (!props.clientRect) {
return; return;
@@ -180,7 +193,7 @@ export function VariableComponent(props: NodeViewProps) {
<button <button
className={cn( className={cn(
"inline-flex items-center justify-center rounded-md text-sm gap-1 ring-offset-white transition-colors", "inline-flex items-center justify-center rounded-md text-sm gap-1 ring-offset-white transition-colors",
"px-2 border border-gray-300 shadow-sm cursor-pointer text-foreground/80" "px-2 border border-gray-300 shadow-sm cursor-pointer text-foreground/80",
)} )}
onClick={(e) => { onClick={(e) => {
e.preventDefault(); e.preventDefault();