feat: add contact-book variable registry for campaign personalization (#359)
* feat: add contact-book variable registry for campaign personalization * test: include contact-book variables default in service expectation * fix: address personalization review issues * fix text * fix: normalize contact variable access across contact flows * stuff * fix
This commit is contained in:
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "ContactBook" ADD COLUMN "variables" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[];
|
||||||
@@ -296,6 +296,7 @@ model ContactBook {
|
|||||||
id String @id @default(cuid())
|
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();
|
||||||
|
if (controlledOpen === undefined) {
|
||||||
setOpen(false);
|
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);
|
||||||
|
if (controlledOpen === undefined) {
|
||||||
setOpen(false);
|
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)
|
|
||||||
if (isFirstLine) {
|
|
||||||
const firstPart = parts[0]?.toLowerCase();
|
const firstPart = parts[0]?.toLowerCase();
|
||||||
if (
|
if (
|
||||||
firstPart === "email" ||
|
firstPart === "email" ||
|
||||||
firstPart === "e-mail" ||
|
firstPart === "e-mail" ||
|
||||||
firstPart === "email address"
|
firstPart === "email address"
|
||||||
) {
|
) {
|
||||||
return null; // Skip header row
|
return null;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const email = parts[0]!.toLowerCase();
|
const getHeader = (index: number) => headers?.[index]?.trim().toLowerCase();
|
||||||
|
|
||||||
|
const email = (
|
||||||
|
headers
|
||||||
|
? parts[
|
||||||
|
headers.findIndex((header) => {
|
||||||
|
const normalized = header.trim().toLowerCase();
|
||||||
|
return (
|
||||||
|
normalized === "email" ||
|
||||||
|
normalized === "e-mail" ||
|
||||||
|
normalized === "email address"
|
||||||
|
);
|
||||||
|
})
|
||||||
|
]
|
||||||
|
: parts[0]
|
||||||
|
)?.toLowerCase();
|
||||||
|
|
||||||
// Skip if doesn't look like an email
|
// 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,54 +25,34 @@ 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 =
|
||||||
const contactBookForm = useForm<z.infer<typeof contactBookSchema>>({
|
trigger ??
|
||||||
resolver: zodResolver(contactBookSchema),
|
(controlledOpen === undefined ? (
|
||||||
defaultValues: {
|
|
||||||
name: contactBook.name || "",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
async function onContactBookUpdate(
|
|
||||||
values: z.infer<typeof contactBookSchema>
|
|
||||||
) {
|
|
||||||
updateContactBookMutation.mutate(
|
|
||||||
{
|
|
||||||
contactBookId: contactBook.id,
|
|
||||||
...values,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
onSuccess: async () => {
|
|
||||||
utils.contacts.getContactBooks.invalidate();
|
|
||||||
setOpen(false);
|
|
||||||
toast.success("Contact book updated successfully");
|
|
||||||
},
|
|
||||||
onError: async (error) => {
|
|
||||||
toast.error(error.message);
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog
|
|
||||||
open={open}
|
|
||||||
onOpenChange={(_open) => (_open !== open ? setOpen(_open) : null)}
|
|
||||||
>
|
|
||||||
<DialogTrigger asChild>
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -80,7 +61,63 @@ export const EditContactBook: React.FC<{
|
|||||||
>
|
>
|
||||||
<Edit className="h-4 w-4 text-foreground/80 hover:text-foreground/70" />
|
<Edit className="h-4 w-4 text-foreground/80 hover:text-foreground/70" />
|
||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
) : null);
|
||||||
|
|
||||||
|
const contactBookForm = useForm<z.infer<typeof contactBookSchema>>({
|
||||||
|
resolver: zodResolver(contactBookSchema),
|
||||||
|
defaultValues: {
|
||||||
|
name: contactBook.name || "",
|
||||||
|
variables: (contactBook.variables ?? []).join(", "),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
async function onContactBookUpdate(
|
||||||
|
values: z.infer<typeof contactBookSchema>,
|
||||||
|
) {
|
||||||
|
updateContactBookMutation.mutate(
|
||||||
|
{
|
||||||
|
contactBookId: contactBook.id,
|
||||||
|
name: values.name,
|
||||||
|
variables: values.variables
|
||||||
|
?.split(",")
|
||||||
|
.map((variable) => variable.trim())
|
||||||
|
.filter(Boolean),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: async () => {
|
||||||
|
utils.contacts.getContactBooks.invalidate();
|
||||||
|
await onSuccess?.();
|
||||||
|
if (controlledOpen === undefined) {
|
||||||
|
setOpen(false);
|
||||||
|
} else {
|
||||||
|
onOpenChange?.(false);
|
||||||
|
}
|
||||||
|
toast.success("Contact book updated successfully");
|
||||||
|
},
|
||||||
|
onError: async (error) => {
|
||||||
|
toast.error(error.message);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={controlledOpen ?? open}
|
||||||
|
onOpenChange={(nextOpen) => {
|
||||||
|
if (controlledOpen === undefined) {
|
||||||
|
if (nextOpen !== open) {
|
||||||
|
setOpen(nextOpen);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onOpenChange?.(nextOpen);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{dialogTrigger ? (
|
||||||
|
<DialogTrigger asChild>{dialogTrigger}</DialogTrigger>
|
||||||
|
) : null}
|
||||||
<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]"
|
||||||
|
|||||||
@@ -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: "📙",
|
||||||
|
|||||||
@@ -21,11 +21,12 @@ export const contactsRouter = createTRPCRouter({
|
|||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
|
variables: z.array(z.string()).optional(),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.mutation(async ({ ctx: { team }, input }) => {
|
.mutation(async ({ ctx: { team }, input }) => {
|
||||||
const { name } = input;
|
const { name, variables } = input;
|
||||||
return contactBookService.createContactBook(team.id, name);
|
return contactBookService.createContactBook(team.id, name, variables);
|
||||||
}),
|
}),
|
||||||
|
|
||||||
getContactBookDetails: contactBookProcedure.query(
|
getContactBookDetails: contactBookProcedure.query(
|
||||||
@@ -53,6 +54,7 @@ export const contactsRouter = createTRPCRouter({
|
|||||||
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 }) => {
|
.mutation(async ({ ctx: { contactBook }, input }) => {
|
||||||
@@ -104,6 +106,7 @@ export const contactsRouter = createTRPCRouter({
|
|||||||
email: true,
|
email: true,
|
||||||
firstName: true,
|
firstName: true,
|
||||||
lastName: true,
|
lastName: true,
|
||||||
|
properties: true,
|
||||||
subscribed: true,
|
subscribed: true,
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
contactBookId: true,
|
contactBookId: true,
|
||||||
@@ -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,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ function getContactBooks(app: PublicAPIApp) {
|
|||||||
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);
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
return renderer.render({
|
const variableValues = createCaseInsensitiveVariableValues({
|
||||||
shouldReplaceVariableValues: true,
|
|
||||||
variableValues: {
|
|
||||||
email: contact.email,
|
email: contact.email,
|
||||||
firstName: contact.firstName,
|
firstName: contact.firstName,
|
||||||
lastName: contact.lastName,
|
lastName: contact.lastName,
|
||||||
|
...allowedVariables.reduce(
|
||||||
|
(acc, variable) => {
|
||||||
|
const value = getContactReplacementValue({
|
||||||
|
contact,
|
||||||
|
key: variable,
|
||||||
|
allowedVariables,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (value !== undefined) {
|
||||||
|
acc[variable] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return acc;
|
||||||
},
|
},
|
||||||
|
{} as Record<string, string | null | undefined>,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
return renderer.render({
|
||||||
|
shouldReplaceVariableValues: true,
|
||||||
|
variableValues,
|
||||||
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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }),
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
Reference in New Issue
Block a user